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)