Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/mh3g-converter-windows.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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

Expand Down Expand Up @@ -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*.
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
.vscode/
.superpowers/
target/
**/bin/
**/obj/
.env
*.env
!*.env.example
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Diagnostics;
using System.Text;
using System.Text.Json;
using MHToolkit.MH3GSaveConverter.Windows.Models;

Expand All @@ -11,6 +12,10 @@ namespace MHToolkit.MH3GSaveConverter.Windows.Services;
/// </summary>
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();

Expand Down Expand Up @@ -62,6 +67,8 @@ public async Task<CliExecutionResult> ExecuteAsync(
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
StandardOutputEncoding = StrictUtf8,
StandardErrorEncoding = StrictUtf8,
CreateNoWindow = true,
WorkingDirectory = Path.GetDirectoryName(executable) ?? AppContext.BaseDirectory,
};
Expand Down
19 changes: 19 additions & 0 deletions scripts/verify-mh3g-save-converter-windows-source.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

<ItemGroup>
<Compile Include="..\..\apps\mh3g-save-converter-windows\Models\ConverterModels.cs"
Link="Product\ConverterModels.cs" />
<Compile Include="..\..\apps\mh3g-save-converter-windows\Services\ConverterCliClient.cs"
Link="Product\ConverterCliClient.cs" />
</ItemGroup>
</Project>
105 changes: 105 additions & 0 deletions tests/mh3g-save-converter-windows-cli-bridge-smoke/Program.cs
Original file line number Diff line number Diff line change
@@ -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<string>();
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);
}
}
Loading