diff --git a/GFramework.Godot.Tests/Config/GodotYamlConfigLoaderTests.cs b/GFramework.Godot.Tests/Config/GodotYamlConfigLoaderTests.cs index 7fa43f71..b38bdcec 100644 --- a/GFramework.Godot.Tests/Config/GodotYamlConfigLoaderTests.cs +++ b/GFramework.Godot.Tests/Config/GodotYamlConfigLoaderTests.cs @@ -1,6 +1,9 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; using System.Threading.Tasks; using GFramework.Game.Config; using GFramework.Godot.Config; @@ -115,31 +118,140 @@ public sealed class GodotYamlConfigLoaderTests Assert.That(exception!.Message, Does.Contain("Hot reload")); } + /// + /// 验证导出态会按父目录优先同步缓存,避免父目录重置删掉先前复制到子目录的内容。 + /// + [Test] + public async Task LoadAsync_Should_Synchronize_Parent_Directories_Before_Children() + { + WriteFile( + _resourceRoot, + "monster/slime.yaml", + """ + id: 1 + name: Slime + hp: 10 + """); + WriteFile( + _resourceRoot, + "monster/boss/dragon.yaml", + """ + id: 99 + name: Dragon + hp: 500 + """); + + var loader = CreateLoader( + isEditor: false, + tableSources: + [ + new GodotYamlConfigTableSource("boss", "monster/boss"), + new GodotYamlConfigTableSource("monster", "monster") + ], + configureLoader: loader => + { + loader.RegisterTable( + "boss", + "monster/boss", + keySelector: static config => config.Id); + loader.RegisterTable( + "monster", + "monster", + keySelector: static config => config.Id); + }); + var registry = new ConfigRegistry(); + + await loader.LoadAsync(registry); + + var cacheRoot = Path.Combine(_userRoot, "config_cache"); + var bossTable = registry.GetTable("boss"); + var monsterTable = registry.GetTable("monster"); + + Assert.Multiple(() => + { + Assert.That(monsterTable.Count, Is.EqualTo(1)); + Assert.That(bossTable.Count, Is.EqualTo(1)); + Assert.That(bossTable.Get(99).Name, Is.EqualTo("Dragon")); + Assert.That(File.Exists(Path.Combine(cacheRoot, "monster", "boss", "dragon.yaml")), Is.True); + }); + } + + /// + /// 验证加载器自身会拒绝可能逃逸缓存根目录的非法配置目录路径,即使调用方绕过了公开构造约束。 + /// + [Test] + public void LoadAsync_Should_Reject_Invalid_Config_Relative_Path_When_Metadata_Is_Corrupted() + { + var corruptedSource = CreateUnsafeTableSource("monster", "../outside"); + var loader = CreateLoader( + isEditor: false, + tableSources: [corruptedSource], + configureLoader: static _ => { }); + + var exception = Assert.ThrowsAsync(async () => + await loader.LoadAsync(new ConfigRegistry())); + + Assert.That(exception!.ParamName, Is.EqualTo("relativePath")); + } + + /// + /// 验证加载器自身会拒绝可能逃逸缓存根目录的非法 schema 路径,即使调用方绕过了公开构造约束。 + /// + [Test] + public void LoadAsync_Should_Reject_Invalid_Schema_Relative_Path_When_Metadata_Is_Corrupted() + { + WriteFile( + _resourceRoot, + "monster/slime.yaml", + """ + id: 1 + name: Slime + hp: 10 + """); + + var corruptedSource = CreateUnsafeTableSource("monster", "monster", "../schemas/monster.schema.json"); + var loader = CreateLoader( + isEditor: false, + tableSources: [corruptedSource], + configureLoader: static _ => { }); + + var exception = Assert.ThrowsAsync(async () => + await loader.LoadAsync(new ConfigRegistry())); + + Assert.That(exception!.ParamName, Is.EqualTo("relativePath")); + } + /// /// 创建一个基于临时目录映射的 Godot YAML 配置加载器。 /// /// 是否模拟编辑器环境。 + /// 要同步的配置表来源集合;为空时使用默认 monster 表。 + /// 底层 YAML 加载器注册逻辑;为空时使用默认 monster 表注册。 /// 已配置好的加载器实例。 - private GodotYamlConfigLoader CreateLoader(bool isEditor) + private GodotYamlConfigLoader CreateLoader( + bool isEditor, + IReadOnlyCollection? tableSources = null, + Action? configureLoader = null) { return new GodotYamlConfigLoader( new GodotYamlConfigLoaderOptions { SourceRootPath = "res://", RuntimeCacheRootPath = "user://config_cache", - TableSources = + TableSources = tableSources ?? [ new GodotYamlConfigTableSource( "monster", "monster", "schemas/monster.schema.json") ], - ConfigureLoader = static loader => - loader.RegisterTable( - "monster", - "monster", - "schemas/monster.schema.json", - static config => config.Id) + ConfigureLoader = configureLoader ?? + (static loader => + loader.RegisterTable( + "monster", + "monster", + "schemas/monster.schema.json", + static config => config.Id)) }, CreateEnvironment(isEditor)); } @@ -241,6 +353,50 @@ public sealed class GodotYamlConfigLoaderTests File.WriteAllText(fullPath, content); } + /// + /// 构造一个绕过公开构造校验的配置来源对象,用于验证加载器的防御式路径校验。 + /// + /// 伪造的表名称。 + /// 伪造的配置目录路径。 + /// 伪造的 schema 路径。 + /// 已写入指定字段值的未初始化对象。 + private static GodotYamlConfigTableSource CreateUnsafeTableSource( + string tableName, + string configRelativePath, + string? schemaRelativePath = null) + { + var source = + (GodotYamlConfigTableSource)RuntimeHelpers.GetUninitializedObject(typeof(GodotYamlConfigTableSource)); + SetAutoPropertyBackingField(source, nameof(GodotYamlConfigTableSource.TableName), tableName); + SetAutoPropertyBackingField(source, nameof(GodotYamlConfigTableSource.ConfigRelativePath), configRelativePath); + SetAutoPropertyBackingField(source, nameof(GodotYamlConfigTableSource.SchemaRelativePath), schemaRelativePath); + return source; + } + + /// + /// 直接写入自动属性的编译器生成字段,用于构造损坏的测试对象。 + /// + /// 字段值类型。 + /// 要写入字段的目标对象。 + /// 对应的属性名称。 + /// 要写入的字段值。 + private static void SetAutoPropertyBackingField( + object instance, + string propertyName, + TValue value) + { + var field = instance.GetType().GetField( + $"<{propertyName}>k__BackingField", + BindingFlags.Instance | BindingFlags.NonPublic); + if (field == null) + { + throw new InvalidOperationException( + $"Backing field for property '{propertyName}' was not found on type '{instance.GetType().FullName}'."); + } + + field.SetValue(instance, value); + } + /// /// 将测试中的 Godot 路径映射到本地临时目录。 /// diff --git a/GFramework.Godot/Config/GodotYamlConfigLoader.cs b/GFramework.Godot/Config/GodotYamlConfigLoader.cs index 41282753..7ba1e9e5 100644 --- a/GFramework.Godot/Config/GodotYamlConfigLoader.cs +++ b/GFramework.Godot/Config/GodotYamlConfigLoader.cs @@ -159,7 +159,11 @@ public sealed class GodotYamlConfigLoader : IConfigLoader { foreach (var group in _options.TableSources .GroupBy(static source => NormalizeRelativePath(source.ConfigRelativePath), - StringComparer.Ordinal)) + StringComparer.Ordinal) + // Parent directories must be reset before children, otherwise resetting "a" later + // would erase files that were already synchronized into "a/b" during the same pass. + .OrderBy(static group => CountPathDepth(group.Key)) + .ThenBy(static group => group.Key, StringComparer.Ordinal)) { cancellationToken.ThrowIfCancellationRequested(); @@ -365,7 +369,39 @@ public sealed class GodotYamlConfigLoader : IConfigLoader private static string NormalizeRelativePath(string relativePath) { - return relativePath.Replace('\\', '/').TrimStart('/'); + ArgumentException.ThrowIfNullOrWhiteSpace(relativePath); + + var normalizedPath = relativePath.Replace('\\', '/').Trim(); + if (normalizedPath.StartsWith("/", StringComparison.Ordinal) || + normalizedPath.StartsWith("res://", StringComparison.Ordinal) || + normalizedPath.StartsWith("user://", StringComparison.Ordinal) || + Path.IsPathRooted(normalizedPath) || + HasWindowsDrivePrefix(normalizedPath)) + { + throw new ArgumentException("Relative path must be an unrooted path.", nameof(relativePath)); + } + + var segments = normalizedPath.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (segments.Any(static segment => segment is "." or "..")) + { + throw new ArgumentException( + "Relative path must not contain '.' or '..' segments.", + nameof(relativePath)); + } + + return string.Join('/', segments); + } + + private static int CountPathDepth(string normalizedRelativePath) + { + return normalizedRelativePath.Count(static ch => ch == '/'); + } + + private static bool HasWindowsDrivePrefix(string path) + { + return path.Length >= 2 && + char.IsLetter(path[0]) && + path[1] == ':'; } private static bool IsYamlFile(string fileName)