diff --git a/GFramework.Godot.Tests/Config/GodotYamlConfigTableSourceTests.cs b/GFramework.Godot.Tests/Config/GodotYamlConfigTableSourceTests.cs new file mode 100644 index 00000000..36f56e52 --- /dev/null +++ b/GFramework.Godot.Tests/Config/GodotYamlConfigTableSourceTests.cs @@ -0,0 +1,71 @@ +using System; +using GFramework.Godot.Config; +using NUnit.Framework; + +namespace GFramework.Godot.Tests.Config; + +/// +/// 验证 Godot YAML 配置表来源描述会拒绝可能逃逸缓存根目录的不安全相对路径。 +/// +[TestFixture] +public sealed class GodotYamlConfigTableSourceTests +{ + /// + /// 验证配置目录路径必须保持为无根、无遍历段的安全相对路径。 + /// + /// 待验证的配置目录路径。 + [TestCase("../outside")] + [TestCase("./monster")] + [TestCase("monster/../outside")] + [TestCase("monster/./child")] + [TestCase("/monster")] + [TestCase("C:/monster")] + [TestCase("res://monster")] + [TestCase("user://monster")] + public void Constructor_Should_Throw_When_Config_Relative_Path_Is_Not_Safe(string configRelativePath) + { + var exception = Assert.Throws(() => + _ = new GodotYamlConfigTableSource("monster", configRelativePath)); + + Assert.That(exception!.ParamName, Is.EqualTo("configRelativePath")); + } + + /// + /// 验证 schema 路径在提供时也必须满足同样的安全相对路径约束。 + /// + /// 待验证的 schema 路径。 + [TestCase("../schemas/monster.schema.json")] + [TestCase("./schemas/monster.schema.json")] + [TestCase("schemas/../monster.schema.json")] + [TestCase("schemas/./monster.schema.json")] + [TestCase("/schemas/monster.schema.json")] + [TestCase("C:/schemas/monster.schema.json")] + [TestCase("res://schemas/monster.schema.json")] + [TestCase("user://schemas/monster.schema.json")] + public void Constructor_Should_Throw_When_Schema_Relative_Path_Is_Not_Safe(string schemaRelativePath) + { + var exception = Assert.Throws(() => + _ = new GodotYamlConfigTableSource("monster", "monster", schemaRelativePath)); + + Assert.That(exception!.ParamName, Is.EqualTo("schemaRelativePath")); + } + + /// + /// 验证合法的相对目录和 schema 路径仍可正常构造元数据对象。 + /// + [Test] + public void Constructor_Should_Accept_Safe_Relative_Paths() + { + var source = new GodotYamlConfigTableSource( + "monster", + "monster/configs", + "schemas/monster.schema.json"); + + Assert.Multiple(() => + { + Assert.That(source.TableName, Is.EqualTo("monster")); + Assert.That(source.ConfigRelativePath, Is.EqualTo("monster/configs")); + Assert.That(source.SchemaRelativePath, Is.EqualTo("schemas/monster.schema.json")); + }); + } +} diff --git a/GFramework.Godot/Config/GodotYamlConfigLoader.cs b/GFramework.Godot/Config/GodotYamlConfigLoader.cs index 3cd9de8c..41282753 100644 --- a/GFramework.Godot/Config/GodotYamlConfigLoader.cs +++ b/GFramework.Godot/Config/GodotYamlConfigLoader.cs @@ -27,6 +27,25 @@ public sealed class GodotYamlConfigLoader : IConfigLoader { } + /// + /// 使用指定选项和宿主环境抽象创建一个 Godot YAML 配置加载器。 + /// + /// 加载器初始化选项。 + /// + /// 封装编辑器探测、Godot 路径全局化、目录枚举与文件读取行为的宿主环境抽象。 + /// + /// + /// 时抛出。 + /// + /// + /// 或 + /// 为空白字符串时抛出。 + /// + /// + /// 该重载用于把与 Godot 引擎强耦合的环境行为收敛到可替换委托中。 + /// 编辑器态下,res:// 可以被全局化后直接交给底层 ; + /// 导出态下,则需要先同步到 user:// 缓存再切换到普通文件系统路径。 + /// internal GodotYamlConfigLoader( GodotYamlConfigLoaderOptions options, GodotYamlConfigEnvironment environment) @@ -376,8 +395,40 @@ public sealed class GodotYamlConfigLoader : IConfigLoader } } +/// +/// 抽象 与具体宿主环境之间的 Godot 路径和文件访问边界。 +/// +/// +/// 该抽象存在的原因,是编辑器态与导出态对 res://user:// 的访问方式不同: +/// 编辑器态通常可以把 Godot 特殊路径全局化后直接落到普通文件系统,而导出态往往只能通过 Godot API 读取原始文本资源, +/// 再把它们复制到运行时缓存目录。 在目录不存在或当前环境无法枚举时必须返回 +/// ,用来表达“不可访问”而不是抛出未找到异常; 则应保留底层读取失败异常, +/// 交由加载器包装成配置诊断。对于普通文件系统路径,应遵循 / 语义; +/// 对于 Godot 特殊路径,则应使用引擎提供的路径解析和读取能力。 +/// internal sealed class GodotYamlConfigEnvironment { + /// + /// 初始化一个可替换的 Godot YAML 配置宿主环境抽象。 + /// + /// 返回当前进程是否处于 Godot 编辑器态的委托。 + /// + /// 把 Godot 特殊路径转换为普通绝对路径的委托。 + /// 当前加载器仅会在输入为 res://user:// 时调用它,返回值必须为非空绝对路径。 + /// + /// + /// 枚举指定目录直接子项的委托。 + /// 当目录不存在、无法访问或当前环境无法枚举该路径时,必须返回 。 + /// + /// + /// 检查指定路径上的文件是否存在的委托。 + /// 输入既可能是 Godot 特殊路径,也可能是普通绝对路径。 + /// + /// + /// 读取指定文件完整字节内容的委托。 + /// 当文件缺失或读取失败时,应抛出底层异常,由加载器统一包装为配置加载诊断。 + /// + /// 任一委托参数为 时抛出。 public GodotYamlConfigEnvironment( Func isEditor, Func globalizePath, @@ -392,6 +443,14 @@ internal sealed class GodotYamlConfigEnvironment ReadAllBytes = readAllBytes ?? throw new ArgumentNullException(nameof(readAllBytes)); } + /// + /// 获取默认的 Godot 运行时环境实现。 + /// + /// + /// 默认实现使用 检测编辑器态, + /// 使用 处理 Godot 特殊路径, + /// 并在 Godot 路径与普通路径之间切换对应的枚举和读取 API。 + /// public static GodotYamlConfigEnvironment Default { get; } = new( static () => OS.HasFeature("editor"), static path => ProjectSettings.GlobalizePath(path), @@ -399,14 +458,40 @@ internal sealed class GodotYamlConfigEnvironment FileExistsCore, ReadAllBytesCore); + /// + /// 获取用于判断当前进程是否处于编辑器态的委托。 + /// public Func IsEditor { get; } + /// + /// 获取把 Godot 特殊路径转换为普通绝对路径的委托。 + /// + /// + /// 当前加载器只会对 res://user:// 路径调用该委托。 + /// 返回空字符串会被视为无效环境实现,并在后续路径解析阶段触发异常。 + /// public Func GlobalizePath { get; } + /// + /// 获取用于枚举目录直接子项的委托。 + /// + /// + /// 当目录不存在、无法访问,或当前环境无法枚举给定路径时,该委托必须返回 。 + /// 返回的集合只应包含当前目录下的直接子项,调用方会自行过滤隐藏项、子目录与非 YAML 文件。 + /// public Func?> EnumerateDirectory { get; } + /// + /// 获取用于检查文件是否存在的委托。 + /// public Func FileExists { get; } + /// + /// 获取用于读取文件完整字节内容的委托。 + /// + /// + /// 该委托在路径不存在、权限不足或 I/O 失败时应抛出底层异常,以便加载器保留失败原因并生成诊断信息。 + /// public Func ReadAllBytes { get; } private static IReadOnlyList? EnumerateDirectoryCore(string path) @@ -464,6 +549,34 @@ internal sealed class GodotYamlConfigEnvironment } } -internal readonly record struct GodotYamlConfigDirectoryEntry( - string Name, - bool IsDirectory); +/// +/// 描述一次目录枚举返回的单个子项。 +/// +/// +/// 该结构只承载目录扫描阶段需要的最小信息。 +/// 必须是单个目录项名称,而不是包含父目录的完整路径; +/// 对于 Godot 路径和普通路径都遵循相同约定,便于加载器统一做后续拼接与过滤。 +/// +internal readonly record struct GodotYamlConfigDirectoryEntry +{ + /// + /// 初始化一个目录枚举结果项。 + /// + /// 当前目录项的名称,不包含父目录路径。 + /// 指示该目录项是否为子目录。 + public GodotYamlConfigDirectoryEntry(string name, bool isDirectory) + { + Name = name; + IsDirectory = isDirectory; + } + + /// + /// 获取当前目录项的名称,不包含父目录路径。 + /// + public string Name { get; } + + /// + /// 获取一个值,指示当前目录项是否为子目录。 + /// + public bool IsDirectory { get; } +} diff --git a/GFramework.Godot/Config/GodotYamlConfigTableSource.cs b/GFramework.Godot/Config/GodotYamlConfigTableSource.cs index 7bba7858..4d28826a 100644 --- a/GFramework.Godot/Config/GodotYamlConfigTableSource.cs +++ b/GFramework.Godot/Config/GodotYamlConfigTableSource.cs @@ -1,3 +1,5 @@ +using System.IO; + namespace GFramework.Godot.Config; /// @@ -9,8 +11,18 @@ public sealed class GodotYamlConfigTableSource /// 初始化一个配置表来源描述。 /// /// 运行时表名称。 - /// 相对配置根目录的 YAML 目录。 - /// 相对配置根目录的 schema 文件路径;未启用 schema 时为空。 + /// + /// 相对配置根目录的 YAML 目录。 + /// 该路径必须保持为无根相对路径,且不能包含 ...res://user:// 或磁盘根路径前缀。 + /// + /// + /// 相对配置根目录的 schema 文件路径;未启用 schema 时为空。 + /// 如果提供,同样必须保持为无根相对路径,且不能包含 ... 或任何绝对路径前缀。 + /// + /// + /// + /// 不满足非空白且安全相对路径的约束时抛出。 + /// public GodotYamlConfigTableSource( string tableName, string configRelativePath, @@ -27,6 +39,13 @@ public sealed class GodotYamlConfigTableSource nameof(configRelativePath)); } + if (!IsSafeRelativePath(configRelativePath)) + { + throw new ArgumentException( + "Config relative path must be a safe relative path without root segments or traversal markers.", + nameof(configRelativePath)); + } + if (schemaRelativePath != null && string.IsNullOrWhiteSpace(schemaRelativePath)) { throw new ArgumentException( @@ -34,6 +53,13 @@ public sealed class GodotYamlConfigTableSource nameof(schemaRelativePath)); } + if (schemaRelativePath != null && !IsSafeRelativePath(schemaRelativePath)) + { + throw new ArgumentException( + "Schema relative path must be a safe relative path without root segments or traversal markers.", + nameof(schemaRelativePath)); + } + TableName = tableName; ConfigRelativePath = configRelativePath; SchemaRelativePath = schemaRelativePath; @@ -46,11 +72,43 @@ public sealed class GodotYamlConfigTableSource /// /// 获取相对配置根目录的 YAML 目录路径。 + /// 该值始终保持为无根相对路径,不会包含 ... 段。 /// public string ConfigRelativePath { get; } /// /// 获取相对配置根目录的 schema 文件路径;未启用 schema 校验时为空。 + /// 该值在非空时始终保持为无根相对路径,不会包含 ... 段。 /// public string? SchemaRelativePath { get; } + + private static bool IsSafeRelativePath(string path) + { + var normalizedPath = path.Replace('\\', '/'); + if (normalizedPath.StartsWith("/", StringComparison.Ordinal) || + normalizedPath.StartsWith("res://", StringComparison.Ordinal) || + normalizedPath.StartsWith("user://", StringComparison.Ordinal) || + Path.IsPathRooted(path) || + HasWindowsDrivePrefix(normalizedPath)) + { + return false; + } + + foreach (var segment in normalizedPath.Split('/', StringSplitOptions.RemoveEmptyEntries)) + { + if (segment is "." or "..") + { + return false; + } + } + + return true; + } + + private static bool HasWindowsDrivePrefix(string path) + { + return path.Length >= 2 && + char.IsLetter(path[0]) && + path[1] == ':'; + } } diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md index 77294352..23f204cf 100644 --- a/docs/zh-CN/game/config-system.md +++ b/docs/zh-CN/game/config-system.md @@ -96,7 +96,7 @@ GameProject/ - 必须是 JSON 字符串 - 必须是相对路径 -- 不允许包含 `..` 段 +- 不允许包含 `.` 或 `..` 段,也不能写成绝对路径 - 生成器会把反斜杠标准化为 `/` ## YAML 示例 @@ -314,7 +314,7 @@ public sealed class GameConfigHost : IDisposable `GodotYamlConfigLoader` 会按环境自动处理这两条路径: - 编辑器态:直接把 `ProjectSettings.GlobalizePath("res://...")` 交给底层 `YamlConfigLoader` -- 导出态:把当前注册会访问到的配置目录与 schema 文件同步到 `user://` 缓存,再交给底层 `YamlConfigLoader` +- 导出态:会将当前注册会访问到的 YAML 配置目录与 schema 文件同步到 `user://` 缓存,再交给底层 `YamlConfigLoader` 推荐搭配生成器元数据一起使用,这样项目不需要再自己维护一份重复的配置目录清单: