diff --git a/.github/workflows/mh3g-converter-windows.yml b/.github/workflows/mh3g-converter-windows.yml index fcb5918..99cf737 100644 --- a/.github/workflows/mh3g-converter-windows.yml +++ b/.github/workflows/mh3g-converter-windows.yml @@ -16,6 +16,7 @@ on: - scripts/mh3g-windows-launcher.ps1 - scripts/package-mh3g-save-converter-windows.ps1 - scripts/verify-mh3g-save-converter-windows-source.py + - tests/mh3g-save-converter-windows-cli-bridge-smoke/** - scripts/verify-github-hosted-actions.py - .github/workflows/mh3g-converter-windows.yml push: @@ -32,6 +33,7 @@ on: - scripts/mh3g-windows-launcher.ps1 - scripts/package-mh3g-save-converter-windows.ps1 - scripts/verify-mh3g-save-converter-windows-source.py + - tests/mh3g-save-converter-windows-cli-bridge-smoke/** - scripts/verify-github-hosted-actions.py - .github/workflows/mh3g-converter-windows.yml @@ -64,6 +66,8 @@ jobs: run: | python scripts/verify-mh3g-save-converter-windows-source.py python scripts/verify-github-hosted-actions.py + - name: Verify UTF-8 CLI JSON bridge + run: dotnet run --configuration Release --project tests/mh3g-save-converter-windows-cli-bridge-smoke/MH3GSaveConverter.CliBridgeSmoke.csproj # PR/main validation compiles the same WinUI + Rust payload but stops # before creating distributable ZIP/portable/installer files. Real # release artifacts are produced only by mh3g-converter-release.yml on v*. diff --git a/.gitignore b/.gitignore index fc56283..e7438bb 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ .vscode/ .superpowers/ target/ +**/bin/ +**/obj/ .env *.env !*.env.example diff --git a/apps/mh3g-save-converter-windows/Services/ConverterCliClient.cs b/apps/mh3g-save-converter-windows/Services/ConverterCliClient.cs index 0bd0709..afb77d8 100644 --- a/apps/mh3g-save-converter-windows/Services/ConverterCliClient.cs +++ b/apps/mh3g-save-converter-windows/Services/ConverterCliClient.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Text; using System.Text.Json; using MHToolkit.MH3GSaveConverter.Windows.Models; @@ -11,6 +12,10 @@ namespace MHToolkit.MH3GSaveConverter.Windows.Services; /// public sealed class ConverterCliClient { + private static readonly Encoding StrictUtf8 = new UTF8Encoding( + encoderShouldEmitUTF8Identifier: false, + throwOnInvalidBytes: true); + private static readonly byte[] LegacyWrapperMarker = "mh3g-save-convert-core.exe"u8.ToArray(); @@ -62,6 +67,8 @@ public async Task ExecuteAsync( UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, + StandardOutputEncoding = StrictUtf8, + StandardErrorEncoding = StrictUtf8, CreateNoWindow = true, WorkingDirectory = Path.GetDirectoryName(executable) ?? AppContext.BaseDirectory, }; diff --git a/scripts/verify-mh3g-save-converter-windows-source.py b/scripts/verify-mh3g-save-converter-windows-source.py index b20ac76..8ed17ae 100644 --- a/scripts/verify-mh3g-save-converter-windows-source.py +++ b/scripts/verify-mh3g-save-converter-windows-source.py @@ -398,6 +398,10 @@ def main() -> int: for expected in ( "UseShellExecute = false", "startInfo.ArgumentList.Add(argument)", + "StandardOutputEncoding = StrictUtf8", + "StandardErrorEncoding = StrictUtf8", + "new UTF8Encoding(", + "throwOnInvalidBytes: true", "JsonDocument.Parse(candidate)", "mh3g-save-convert-core.exe", "Legacy compatibility wrapper", @@ -406,6 +410,21 @@ def main() -> int: require("startInfo.Arguments" not in bridge, "CLI bridge must not build a command-string argument list") require("cmd.exe" not in bridge and "powershell" not in bridge.lower(), "CLI bridge must not invoke a shell") + utf8_smoke = ROOT / "tests" / "mh3g-save-converter-windows-cli-bridge-smoke" / "Program.cs" + require(utf8_smoke.is_file(), "UTF-8 CLI JSON bridge smoke is missing") + utf8_smoke_text = utf8_smoke.read_text(encoding="utf-8") + for expected in ( + "存档转换·枫叶峰", + "WriteUtf8(Console.OpenStandardOutput()", + "WriteUtf8(Console.OpenStandardError()", + "Encoding.GetEncoding(", + "regressionVectorRejected", + "Console.OutputEncoding = cp936", + "new ConverterCliClient().ExecuteAsync(", + 'result.TryGetString("source")', + ): + require(expected in utf8_smoke_text, f"UTF-8 CLI JSON bridge smoke is missing {expected}") + update_service = read("Services/GitHubUpdateService.cs") for expected in ( "https://api.github.com/repos/MHToolkit/mh-save-sync/releases/latest", diff --git a/tests/mh3g-save-converter-windows-cli-bridge-smoke/MH3GSaveConverter.CliBridgeSmoke.csproj b/tests/mh3g-save-converter-windows-cli-bridge-smoke/MH3GSaveConverter.CliBridgeSmoke.csproj new file mode 100644 index 0000000..dcf8709 --- /dev/null +++ b/tests/mh3g-save-converter-windows-cli-bridge-smoke/MH3GSaveConverter.CliBridgeSmoke.csproj @@ -0,0 +1,15 @@ + + + Exe + net8.0 + enable + enable + + + + + + + diff --git a/tests/mh3g-save-converter-windows-cli-bridge-smoke/Program.cs b/tests/mh3g-save-converter-windows-cli-bridge-smoke/Program.cs new file mode 100644 index 0000000..e7f2d3a --- /dev/null +++ b/tests/mh3g-save-converter-windows-cli-bridge-smoke/Program.cs @@ -0,0 +1,105 @@ +using System.Text; +using System.Text.Json; +using MHToolkit.MH3GSaveConverter.Windows.Models; +using MHToolkit.MH3GSaveConverter.Windows.Services; + +const string EmitArgument = "--emit-utf8-json"; +// This exact synthetic path is also a regression vector: interpreting its +// UTF-8 JSON bytes as CP936 consumes one of the two separators before `user1` +// and produces the original invalid `\u` escape failure. +const string ExpectedPath = @"F:\存档转换·枫叶峰\3ds存档·镰版峰\user1"; +const string ExpectedError = "辅助进程 UTF-8 错误流"; + +if (args is [EmitArgument]) +{ + WriteUtf8(Console.OpenStandardOutput(), BuildPayload() + "\n"); + WriteUtf8(Console.OpenStandardError(), ExpectedError + "\n"); + return 0; +} + +var executable = Environment.ProcessPath + ?? throw new InvalidOperationException("Could not resolve the smoke-test process path."); +var childArguments = new List(); +if (string.Equals(Path.GetFileNameWithoutExtension(executable), "dotnet", StringComparison.OrdinalIgnoreCase)) +{ + childArguments.Add(typeof(Program).Assembly.Location); +} +childArguments.Add(EmitArgument); + +Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); +var cp936 = Encoding.GetEncoding( + 936, + EncoderFallback.ReplacementFallback, + DecoderFallback.ReplacementFallback); +var corruptedJson = cp936.GetString(Encoding.UTF8.GetBytes(BuildPayload())); +var regressionVectorRejected = false; +try +{ + using var _ = JsonDocument.Parse(corruptedJson); +} +catch (JsonException) +{ + regressionVectorRejected = true; +} +Require( + regressionVectorRejected, + "The CP936 regression vector no longer reproduces the invalid JSON \\u escape."); + +CliExecutionResult result; +// The child emits raw UTF-8 regardless of the parent console locale. This is +// the same protocol used by the Rust sidecar. On a CP936 Windows desktop the +// old bridge decoded that byte stream with the console code page; the product +// client must now select UTF-8 explicitly before Process.Start. +var originalConsoleEncoding = Console.OutputEncoding; +try +{ + if (OperatingSystem.IsWindows()) + { + Console.OutputEncoding = cp936; + } + + result = await new ConverterCliClient().ExecuteAsync( + executable, + childArguments, + CancellationToken.None); +} +finally +{ + Console.OutputEncoding = originalConsoleEncoding; +} + +Require(result.Succeeded, $"UTF-8 JSON report was not parsed: {result.JsonParseError}"); +Require( + string.Equals(result.TryGetString("source"), ExpectedPath, StringComparison.Ordinal), + $"UTF-8 path changed during process transport: {result.TryGetString("source")}"); +Require( + string.Equals(result.StandardError.TrimEnd(), ExpectedError, StringComparison.Ordinal), + $"UTF-8 stderr changed during process transport: {result.StandardError}"); + +Console.WriteLine("Windows CLI UTF-8 bridge smoke passed."); +return 0; + +static string BuildPayload() => JsonSerializer.Serialize(new +{ + operation = "repair-converted", + status = "dry-run", + source = ExpectedPath, +}, new JsonSerializerOptions +{ + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, +}); + +static void WriteUtf8(Stream stream, string value) +{ + var bytes = Encoding.UTF8.GetBytes(value); + stream.Write(bytes); + stream.Flush(); +} + +static void Require(bool condition, string message) +{ + if (!condition) + { + throw new InvalidOperationException(message); + } +}