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.Runtime.CompilerServices;
using System.Threading.Tasks;
using GFramework.Game.Abstractions.Config;
using GFramework.Game.Config;
using GFramework.Godot.Config;
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>

View File

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

View File

@ -22,6 +22,20 @@ public sealed class GodotYamlConfigLoader : IConfigLoader
/// 使用指定选项创建一个 Godot YAML 配置加载器。
/// </summary>
/// <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)
: this(options, GodotYamlConfigEnvironment.Default)
{
@ -115,6 +129,19 @@ public sealed class GodotYamlConfigLoader : IConfigLoader
/// <param name="registry">要被热重载更新的配置注册表。</param>
/// <param name="options">热重载选项;为空时使用默认值。</param>
/// <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(
IConfigRegistry registry,
YamlConfigHotReloadOptions? options = null)
@ -171,7 +198,7 @@ public sealed class GodotYamlConfigLoader : IConfigLoader
var sourceDirectoryPath = CombinePath(SourceRootPath, representative.ConfigRelativePath);
var targetDirectoryPath = CombineAbsolutePath(LoaderRootPath, representative.ConfigRelativePath);
ResetDirectory(targetDirectoryPath);
ResetDirectory(representative.TableName, sourceDirectoryPath, targetDirectoryPath);
CopyYamlFilesInDirectory(
representative.TableName,
sourceDirectoryPath,
@ -215,8 +242,6 @@ public sealed class GodotYamlConfigLoader : IConfigLoader
configDirectoryPath: DescribePath(sourceDirectoryPath));
}
Directory.CreateDirectory(targetDirectoryPath);
foreach (var entry in entries)
{
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)
@ -417,6 +456,7 @@ public sealed class GodotYamlConfigLoader : IConfigLoader
string? configDirectoryPath = null,
string? yamlPath = null,
string? schemaPath = null,
string? detail = null,
Exception? innerException = null)
{
return new ConfigLoadException(
@ -425,7 +465,8 @@ public sealed class GodotYamlConfigLoader : IConfigLoader
tableName,
configDirectoryPath: configDirectoryPath,
yamlPath: yamlPath,
schemaPath: schemaPath),
schemaPath: schemaPath,
detail: detail),
message,
innerException);
}