fix(config): 修复Godot YAML配置加载器的目录重置异常处理

- 为构造函数添加ArgumentNullException和ArgumentException异常说明
- 为EnableHotReload方法添加InvalidOperationException异常说明
- 重构ResetDirectory方法以捕获目录操作异常并包装为ConfigLoadException
- 添加detail参数到CreateConfigLoadException方法用于提供更详细的错误信息
- 新增单元测试验证运行时缓存目录重置失败时的异常处理
- 添加GodotYamlConfigTableSourceTests测试类验证安全相对路径约束
This commit is contained in:
GeWuYou 2026-04-11 07:28:49 +08:00
parent e746297496
commit 1bf5d287e9
3 changed files with 86 additions and 9 deletions

View File

@ -5,6 +5,7 @@ using System.Linq;
using System.Reflection; using System.Reflection;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using System.Threading.Tasks; using System.Threading.Tasks;
using GFramework.Game.Abstractions.Config;
using GFramework.Game.Config; using GFramework.Game.Config;
using GFramework.Godot.Config; using GFramework.Godot.Config;
using NUnit.Framework; using NUnit.Framework;
@ -176,6 +177,31 @@ public sealed class GodotYamlConfigLoaderTests
}); });
} }
/// <summary>
/// 验证运行时缓存目录无法重置时Godot 适配层仍会返回结构化的配置加载诊断。
/// </summary>
[Test]
public void LoadAsync_Should_Wrap_Runtime_Cache_Directory_Reset_Failure_As_ConfigLoadException()
{
CreateMonsterFiles(_resourceRoot);
WriteFile(_userRoot, "config_cache", "occupied");
var loader = CreateLoader(isEditor: false);
var exception = Assert.ThrowsAsync<ConfigLoadException>(async () =>
await loader.LoadAsync(new ConfigRegistry()));
Assert.Multiple(() =>
{
Assert.That(exception, Is.Not.Null);
Assert.That(exception!.Diagnostic.FailureKind, Is.EqualTo(ConfigLoadFailureKind.ConfigFileReadFailed));
Assert.That(exception.Diagnostic.TableName, Is.EqualTo("monster"));
Assert.That(exception.Diagnostic.ConfigDirectoryPath, Is.EqualTo(Path.Combine(_resourceRoot, "monster")));
Assert.That(exception.Diagnostic.Detail, Does.Contain(Path.Combine(_userRoot, "config_cache", "monster")));
Assert.That(exception.InnerException, Is.InstanceOf<IOException>());
});
}
/// <summary> /// <summary>
/// 验证加载器自身会拒绝可能逃逸缓存根目录的非法配置目录路径,即使调用方绕过了公开构造约束。 /// 验证加载器自身会拒绝可能逃逸缓存根目录的非法配置目录路径,即使调用方绕过了公开构造约束。
/// </summary> /// </summary>

View File

@ -15,11 +15,16 @@ public sealed class GodotYamlConfigTableSourceTests
/// </summary> /// </summary>
/// <param name="configRelativePath">待验证的配置目录路径。</param> /// <param name="configRelativePath">待验证的配置目录路径。</param>
[TestCase("../outside")] [TestCase("../outside")]
[TestCase(@"..\outside")]
[TestCase("./monster")] [TestCase("./monster")]
[TestCase(@".\monster")]
[TestCase("monster/../outside")] [TestCase("monster/../outside")]
[TestCase(@"monster\..\outside")]
[TestCase("monster/./child")] [TestCase("monster/./child")]
[TestCase(@"monster\.\child")]
[TestCase("/monster")] [TestCase("/monster")]
[TestCase("C:/monster")] [TestCase("C:/monster")]
[TestCase(@"C:\monster")]
[TestCase("res://monster")] [TestCase("res://monster")]
[TestCase("user://monster")] [TestCase("user://monster")]
public void Constructor_Should_Throw_When_Config_Relative_Path_Is_Not_Safe(string configRelativePath) public void Constructor_Should_Throw_When_Config_Relative_Path_Is_Not_Safe(string configRelativePath)
@ -35,11 +40,16 @@ public sealed class GodotYamlConfigTableSourceTests
/// </summary> /// </summary>
/// <param name="schemaRelativePath">待验证的 schema 路径。</param> /// <param name="schemaRelativePath">待验证的 schema 路径。</param>
[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(@".\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("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("C:/schemas/monster.schema.json")]
[TestCase(@"C:\schemas\monster.schema.json")]
[TestCase("res://schemas/monster.schema.json")] [TestCase("res://schemas/monster.schema.json")]
[TestCase("user://schemas/monster.schema.json")] [TestCase("user://schemas/monster.schema.json")]
public void Constructor_Should_Throw_When_Schema_Relative_Path_Is_Not_Safe(string schemaRelativePath) public void Constructor_Should_Throw_When_Schema_Relative_Path_Is_Not_Safe(string schemaRelativePath)

View File

@ -22,6 +22,20 @@ public sealed class GodotYamlConfigLoader : IConfigLoader
/// 使用指定选项创建一个 Godot YAML 配置加载器。 /// 使用指定选项创建一个 Godot YAML 配置加载器。
/// </summary> /// </summary>
/// <param name="options">加载器初始化选项。</param> /// <param name="options">加载器初始化选项。</param>
/// <exception cref="ArgumentNullException">当 <paramref name="options" /> 为 <see langword="null" /> 时抛出。</exception>
/// <exception cref="ArgumentException">
/// 当 <see cref="GodotYamlConfigLoaderOptions.SourceRootPath" /> 或
/// <see cref="GodotYamlConfigLoaderOptions.RuntimeCacheRootPath" /> 为空白字符串时抛出。
/// </exception>
/// <exception cref="InvalidOperationException">
/// 当 Godot 特殊路径无法被全局化为非空绝对路径时抛出。
/// </exception>
/// <remarks>
/// 构造完成后,加载器会根据当前环境决定直接读取 <see cref="SourceRootPath" />,还是先同步到
/// <see cref="RuntimeCacheRootPath" /> 再交给底层 <see cref="YamlConfigLoader" />。
/// 只有源根目录可直接作为普通文件系统目录访问时,<see cref="CanEnableHotReload" /> 才会返回
/// <see langword="true" />。
/// </remarks>
public GodotYamlConfigLoader(GodotYamlConfigLoaderOptions options) public GodotYamlConfigLoader(GodotYamlConfigLoaderOptions options)
: this(options, GodotYamlConfigEnvironment.Default) : this(options, GodotYamlConfigEnvironment.Default)
{ {
@ -115,6 +129,19 @@ public sealed class GodotYamlConfigLoader : IConfigLoader
/// <param name="registry">要被热重载更新的配置注册表。</param> /// <param name="registry">要被热重载更新的配置注册表。</param>
/// <param name="options">热重载选项;为空时使用默认值。</param> /// <param name="options">热重载选项;为空时使用默认值。</param>
/// <returns>用于停止监听的注销句柄。</returns> /// <returns>用于停止监听的注销句柄。</returns>
/// <exception cref="ArgumentNullException">当 <paramref name="registry" /> 为 <see langword="null" /> 时抛出。</exception>
/// <exception cref="InvalidOperationException">
/// 当当前实例必须通过运行时缓存访问配置源,无法直接监听真实源目录时抛出。
/// </exception>
/// <exception cref="ArgumentOutOfRangeException">
/// 当 <paramref name="options" /> 的防抖延迟小于 <see cref="TimeSpan.Zero" /> 时,
/// 底层 <see cref="YamlConfigLoader" /> 会拒绝启用热重载。
/// </exception>
/// <remarks>
/// 调用前应先检查 <see cref="CanEnableHotReload" />。
/// 当 <see cref="SourceRootPath" /> 只能通过缓存同步访问时,拒绝启用热重载是为了避免监听缓存副本后误导调用方,
/// 让其误以为源目录改动会被自动反映到运行时。
/// </remarks>
public IUnRegister EnableHotReload( public IUnRegister EnableHotReload(
IConfigRegistry registry, IConfigRegistry registry,
YamlConfigHotReloadOptions? options = null) YamlConfigHotReloadOptions? options = null)
@ -171,7 +198,7 @@ public sealed class GodotYamlConfigLoader : IConfigLoader
var sourceDirectoryPath = CombinePath(SourceRootPath, representative.ConfigRelativePath); var sourceDirectoryPath = CombinePath(SourceRootPath, representative.ConfigRelativePath);
var targetDirectoryPath = CombineAbsolutePath(LoaderRootPath, representative.ConfigRelativePath); var targetDirectoryPath = CombineAbsolutePath(LoaderRootPath, representative.ConfigRelativePath);
ResetDirectory(targetDirectoryPath); ResetDirectory(representative.TableName, sourceDirectoryPath, targetDirectoryPath);
CopyYamlFilesInDirectory( CopyYamlFilesInDirectory(
representative.TableName, representative.TableName,
sourceDirectoryPath, sourceDirectoryPath,
@ -215,8 +242,6 @@ public sealed class GodotYamlConfigLoader : IConfigLoader
configDirectoryPath: DescribePath(sourceDirectoryPath)); configDirectoryPath: DescribePath(sourceDirectoryPath));
} }
Directory.CreateDirectory(targetDirectoryPath);
foreach (var entry in entries) foreach (var entry in entries)
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
@ -303,14 +328,28 @@ public sealed class GodotYamlConfigLoader : IConfigLoader
} }
} }
private void ResetDirectory(string directoryPath) private void ResetDirectory(string tableName, string sourceDirectoryPath, string targetDirectoryPath)
{ {
if (Directory.Exists(directoryPath)) try
{ {
Directory.Delete(directoryPath, recursive: true); if (Directory.Exists(targetDirectoryPath))
{
Directory.Delete(targetDirectoryPath, recursive: true);
} }
Directory.CreateDirectory(directoryPath); Directory.CreateDirectory(targetDirectoryPath);
}
catch (Exception exception)
{
var describedSourceDirectoryPath = DescribePath(sourceDirectoryPath);
throw CreateConfigLoadException(
ConfigLoadFailureKind.ConfigFileReadFailed,
tableName,
$"Failed to reset runtime cache directory '{targetDirectoryPath}' while preparing config directory '{describedSourceDirectoryPath}'.",
configDirectoryPath: describedSourceDirectoryPath,
detail: $"Runtime cache directory: {targetDirectoryPath}.",
innerException: exception);
}
} }
private string EnsureAbsolutePath(string path, string optionName) private string EnsureAbsolutePath(string path, string optionName)
@ -417,6 +456,7 @@ public sealed class GodotYamlConfigLoader : IConfigLoader
string? configDirectoryPath = null, string? configDirectoryPath = null,
string? yamlPath = null, string? yamlPath = null,
string? schemaPath = null, string? schemaPath = null,
string? detail = null,
Exception? innerException = null) Exception? innerException = null)
{ {
return new ConfigLoadException( return new ConfigLoadException(
@ -425,7 +465,8 @@ public sealed class GodotYamlConfigLoader : IConfigLoader
tableName, tableName,
configDirectoryPath: configDirectoryPath, configDirectoryPath: configDirectoryPath,
yamlPath: yamlPath, yamlPath: yamlPath,
schemaPath: schemaPath), schemaPath: schemaPath,
detail: detail),
message, message,
innerException); innerException);
} }