Merge branch 'claude/m2pack-launcher'

Add m2pack-secure release format support to the launcher without
removing the legacy JSON-blob path. Manifests now carry an optional
'format' field; a ReleaseFormatFactory dispatches to either
LegacyJsonBlobFormat (default, unchanged behaviour) or M2PackFormat
(new, treats .m2p files as opaque content-addressed blobs and loads
a runtime-key.json sidecar to hand off to the game via env vars).
Unknown formats fail closed.

Adds RuntimeKey model + EnvVarKeyDelivery (cross-platform) + a stub
SharedMemoryKeyDelivery for the future Windows path, an opt-in
ClientAppliedReporter for Morion2 telemetry with 5s timeout and
graceful fallback, plus 60 new tests (total 92) covering every branch.
This commit is contained in:
Jan Nedbal
2026-04-14 21:18:12 +02:00
26 changed files with 1562 additions and 22 deletions

49
CHANGELOG.md Normal file
View File

@@ -0,0 +1,49 @@
# Changelog
All notable changes to `metin-launcher` are tracked here. Format loosely
follows Keep a Changelog; dates are Europe/Prague.
## [Unreleased]
### Added
- Release format dispatch. The manifest now carries an optional top-level
`format` field (defaults to `legacy-json-blob`) and the orchestrator
resolves an `IReleaseFormat` strategy via `ReleaseFormatFactory`.
- `M2PackFormat`: new release format that lists `.m2p` pack archives plus a
`runtime-key.json` sidecar. The launcher never opens or decrypts `.m2p`
archives; it only places them next to the client root and loads the
runtime key after apply.
- `RuntimeKey` model + `IRuntimeKeyDelivery` strategy. `EnvVarKeyDelivery`
is the MVP implementation and forwards `M2PACK_MASTER_KEY_HEX`,
`M2PACK_SIGN_PUBKEY_HEX` and `M2PACK_KEY_ID` to the child process scoped
to `ProcessStartInfo.Environment` only. `SharedMemoryKeyDelivery` is
documented as a stub and throws until the Windows receiver lands.
- `GameProcess.BuildStartInfo` now accepts an optional `RuntimeKey?` and
forwards it through the env-var delivery.
- `ClientAppliedReporter`: opt-in best-effort telemetry ping that fires
once after a successful apply with a 5-second cap. Disabled by default
(`LauncherConfig.TelemetryUrlTemplate == ""`). Failures are always
swallowed and logged as warnings.
- `docs/m2pack-integration.md` documenting the signature boundary, runtime
key delivery, telemetry, backward compatibility and file layout.
- ~60 new tests across `RuntimeKeyTests`, `EnvVarDeliveryTests`,
`LegacyJsonBlobFormatTests`, `M2PackFormatTests`, `ReleaseFormatFactoryTests`,
`ClientAppliedReporterTests`, `UpdateOrchestratorFormatDispatchTests` and
extended `GameProcessTests`. Total suite is ~92 tests.
### Changed
- `UpdateOrchestrator` dispatches through `ReleaseFormatFactory` after
signature verification. The legacy flow is preserved byte-for-byte for
manifests without a `format` field; the only visible difference is that
the complete log line now reads `format=legacy-json-blob`.
- `UpdateOrchestrator.Result` gained `Format` and `RuntimeKey` slots so the
headless entry point can forward the runtime key into `GameProcess.Launch`.
### Security
- A signed manifest carrying an unknown `format` value is refused outright
rather than silently falling back to legacy, preventing a downgrade
attack vector in the event of a signing key compromise.
- Env vars produced by `EnvVarKeyDelivery` are scoped to the spawned
child's environment only. The launcher never mutates its own process
environment, so other processes on the machine and later code in the
launcher itself cannot read the key.

View File

@@ -125,6 +125,30 @@ tests/Metin2Launcher.Tests/
game assets (~4 GB) are handled by our own patcher code.
- **xunit / xunit.runner.visualstudio** — tests.
## Release formats
The launcher dispatches on a top-level `format` field in the signed manifest.
Two strategies exist today:
- `legacy-json-blob` (default when `format` is absent) — individual files
listed in `files[]`, downloaded by sha256 and atomically replaced inside
the client root.
- `m2pack` — the `files[]` entries are `.m2p` pack archives plus a
`runtime-key.json` sidecar. The launcher **never** opens or decrypts
`.m2p` archives; it just places them next to the client root and forwards
the runtime key to `Metin2.exe` via environment variables
(`M2PACK_MASTER_KEY_HEX`, `M2PACK_SIGN_PUBKEY_HEX`, `M2PACK_KEY_ID`).
A manifest whose `format` value is signed but unknown is refused outright —
the orchestrator falls back to offline mode rather than silently downgrading.
See `docs/m2pack-integration.md` for the full threat model and file layout.
An opt-in "client applied this release" telemetry ping is available via
`LauncherConfig.TelemetryUrlTemplate`. It is empty by default, which short-
circuits the reporter so no network call is made. When set, the reporter
fires a single bounded (5s) best-effort POST after a successful apply and
swallows any failure.
## Signing key
`LauncherConfig.PublicKeyHex` is the Ed25519 public key that every manifest

114
docs/m2pack-integration.md Normal file
View File

@@ -0,0 +1,114 @@
# m2pack integration
Notes on how `metin-launcher` handles the m2pack release format. Written when the
launcher-side support landed on `claude/m2pack-launcher`; the client-side receiver
for the runtime key is tracked separately.
## Scope
The launcher's job in an m2pack release is narrowly defined:
1. Fetch the signed `manifest.json` and its `.sig` exactly as before.
2. Verify Ed25519 signature against the compiled-in public key, exactly as before.
3. Read the new top-level `format` field (falling back to `legacy-json-blob`).
4. Dispatch through `ReleaseFormatFactory.Resolve(...)` to an `IReleaseFormat`.
5. Diff, download and atomically apply the file entries listed in the manifest.
For m2pack these are `.m2p` pack archives plus a `runtime-key.json` sidecar.
6. **Never** open, decrypt or decompress a `.m2p` archive. Integrity of the
archive contents is the client's problem, gated on the runtime key.
7. Load `runtime-key.json` from the client root and forward it to `Metin2.exe`
via environment variables (`M2PACK_MASTER_KEY_HEX`, `M2PACK_SIGN_PUBKEY_HEX`,
`M2PACK_KEY_ID`), scoped to the child process only.
Anything beyond step 7 — parsing the archive, selecting pack files, decrypting
blocks, validating per-file signatures — lives in the client. The launcher is
deliberately dumb about `.m2p`.
## Signature boundary
The Ed25519 signature stays on the top-level `manifest.json`. `.m2p` files are
each a single `files[]` entry with a `sha256`, exactly like any other blob —
that's enough to detect tampering or incomplete downloads, and tamper-resistance
of the content-addressed URL is inherited from the signed manifest.
The `runtime-key.json` sidecar is listed in the manifest in the same way, so
its integrity is also covered by the manifest signature. We do **not** sign the
runtime key independently.
## Runtime key delivery
Two delivery strategies are defined behind `IRuntimeKeyDelivery`:
- `EnvVarKeyDelivery` — MVP, used on every platform. Sets
`M2PACK_MASTER_KEY_HEX`, `M2PACK_SIGN_PUBKEY_HEX` and `M2PACK_KEY_ID` on the
child `ProcessStartInfo.Environment`. The launcher never sets these on its
own process environment, so unrelated processes can't snoop them and the
values don't leak via `Environment.GetEnvironmentVariable` calls elsewhere
in the launcher.
- `SharedMemoryKeyDelivery` — Windows stub. Not implemented. See the class
docs for the planned approach (named `MemoryMappedFile` with a random
suffix, write-then-zero lifecycle, child reads via mapping name passed in
env). Left as a stub because the client-side receiver hasn't landed.
Threat model note: env vars are readable by any process that shares the
parent's user on Linux (`/proc/$pid/environ`, gated by `ptrace_scope` and by
file mode). That's identical to the current legacy flow in terms of secret
handling — the legacy release also trusts the local user — and the shared
memory strategy is planned for a future hardening pass, not as an MVP
requirement.
## Telemetry
`ClientAppliedReporter` is wired but disabled by default: `LauncherConfig.TelemetryUrlTemplate`
is empty. When set to a non-empty URL template (supporting `{format}` and
`{version}` placeholders) the reporter fires a single bounded POST after a
successful apply, with a hard 5-second timeout and warning-level logging on
failure. Failures never block the launch flow and are never retried.
The payload is intentionally small: `format`, `version`, `timestamp`. Nothing
personally identifying.
## Backward compatibility
Existing installs pointing at a manifest without a `format` field continue to
parse exactly as before: `Manifest.EffectiveFormat` defaults to
`legacy-json-blob` and `LegacyJsonBlobFormat` is a near-verbatim lift of the
old inline behaviour. The only visible difference is that the orchestrator's
log line now reads `format=legacy-json-blob`.
Signed manifests that ship an unknown `format` value are refused outright —
`ReleaseFormatFactory.Resolve` throws and the orchestrator reports
`OfflineFallback`. This prevents a future attacker who compromises the signing
key from downgrading clients via a fabricated `format: "no-verify"` field.
## File layout on disk
```
client/
├── Metin2.exe
├── runtime-key.json ← written from the manifest, loaded by M2PackFormat
├── pack/
│ ├── item.m2p ← staged/placed by the launcher, never opened
│ ├── mob.m2p
│ └── ...
└── .updates/
├── launcher.log
├── current-manifest.json
└── staging/ ← per-file resume, same as legacy
```
## Testing
Run `dotnet test -c Release` — the relevant suites are:
- `RuntimeKeyTests` — happy path, validation, load-from-disk
- `EnvVarDeliveryTests` — scoped env mutation, no current-process leak
- `LegacyJsonBlobFormatTests` / `M2PackFormatTests` / `ReleaseFormatFactoryTests`
- `ClientAppliedReporterTests` — disabled-by-default, success, 500, timeout,
connection refused
- `UpdateOrchestratorFormatDispatchTests` — signature failure path, offline
fallback when manifest fetch fails, result carries `Format` + `RuntimeKey`
- Extended `GameProcessTests` asserting the runtime key gets forwarded through
`BuildStartInfo` and does NOT leak into the launcher's own environment.
Total suite after this change is ~92 tests.

View File

@@ -30,6 +30,14 @@ public static class LauncherConfig
public const int MaxBlobRetries = 3;
/// <summary>
/// Opt-in telemetry endpoint for the "client applied release" ping. Empty
/// by default — when empty the reporter short-circuits and no network call
/// is made. Supports <c>{format}</c> and <c>{version}</c> placeholders,
/// both URL-escaped at send time.
/// </summary>
public const string TelemetryUrlTemplate = "";
/// <summary>Name of the directory under the client root that holds launcher state.</summary>
public const string StateDirName = ".updates";

View File

@@ -0,0 +1,52 @@
using Metin2Launcher.Manifest;
namespace Metin2Launcher.Formats;
/// <summary>
/// Strategy that knows how to interpret the <c>files</c> array of a
/// signature-verified manifest. Added for the m2pack migration:
///
/// - <c>legacy-json-blob</c> ships individual files that replace their
/// counterparts inside the client root one-for-one.
/// - <c>m2pack</c> ships <c>.m2p</c> pack archives and a <c>runtime-key.json</c>
/// sidecar. The archives are placed next to the client root and the
/// runtime key is forwarded to the game process via env vars — the
/// launcher NEVER opens, decrypts or decompresses an <c>.m2p</c> archive.
///
/// Both implementations share the same download/apply plumbing in
/// <see cref="Orchestration.UpdateOrchestrator"/>. This interface only
/// captures the pieces that actually differ between the two formats.
/// </summary>
public interface IReleaseFormat
{
/// <summary>Identifier matching <see cref="Manifest.EffectiveFormat"/>.</summary>
string Name { get; }
/// <summary>
/// Filters the manifest file list down to entries that should be
/// downloaded on the given platform. Both formats currently delegate
/// to <see cref="ManifestFile.AppliesTo(string)"/>; the indirection
/// exists so a future format can special-case (e.g. exclude the
/// runtime key from platform filtering, or reject unknown kinds).
/// </summary>
IReadOnlyList<ManifestFile> FilterApplicable(Manifest.Manifest manifest, string platform);
/// <summary>
/// Runs after the apply phase succeeds. The legacy format is a no-op;
/// the m2pack format loads the runtime key from the client root and
/// stores it on <paramref name="outcome"/> so the caller can forward it
/// to the game process.
/// </summary>
void OnApplied(string clientRoot, LoadedManifest manifest, ReleaseOutcome outcome);
}
/// <summary>
/// Mutable bag of results emitted by <see cref="IReleaseFormat.OnApplied"/>.
/// Kept separate from the update orchestrator's own <c>Result</c> record so
/// format-specific data doesn't leak into the generic update flow.
/// </summary>
public sealed class ReleaseOutcome
{
/// <summary>Populated by the m2pack format; null for legacy releases.</summary>
public Runtime.RuntimeKey? RuntimeKey { get; set; }
}

View File

@@ -0,0 +1,27 @@
using Metin2Launcher.Manifest;
namespace Metin2Launcher.Formats;
/// <summary>
/// The original release format: the manifest's <c>files</c> array lists
/// individual files (exe, dlls, packs, locale/&lt;pre-m2pack&gt;) that the
/// launcher downloads by sha256 and atomically replaces inside the client
/// root. Preserved unchanged so an install that still points at an old
/// manifest endpoint works identically.
/// </summary>
public sealed class LegacyJsonBlobFormat : IReleaseFormat
{
public const string FormatName = "legacy-json-blob";
public string Name => FormatName;
public IReadOnlyList<ManifestFile> FilterApplicable(Manifest.Manifest manifest, string platform)
{
return manifest.Files.Where(f => f.AppliesTo(platform)).ToList();
}
public void OnApplied(string clientRoot, LoadedManifest manifest, ReleaseOutcome outcome)
{
// Nothing format-specific happens after apply for the legacy flow.
}
}

View File

@@ -0,0 +1,59 @@
using Metin2Launcher.Logging;
using Metin2Launcher.Manifest;
using Metin2Launcher.Runtime;
namespace Metin2Launcher.Formats;
/// <summary>
/// m2pack release format.
///
/// The manifest still has a flat <c>files</c> list, but the entries are now
/// content-addressed <c>.m2p</c> archives plus a <c>runtime-key.json</c>
/// sidecar. Signing continues to happen on the top-level <c>manifest.json</c>
/// — the <c>.m2p</c> files are each a single sha256 entry, and the launcher
/// NEVER opens them. Decryption, decompression and integrity of the contents
/// is the game client's problem, gated on the runtime key this class loads.
///
/// The runtime-key.json sidecar is listed in the manifest like any other file
/// and goes through the same sha256 round trip, so tamper resistance of the
/// key itself comes from the Ed25519 signature over the enclosing manifest.
/// </summary>
public sealed class M2PackFormat : IReleaseFormat
{
public const string FormatName = "m2pack";
/// <summary>Conventional filename of the runtime key sidecar inside the client root.</summary>
public const string RuntimeKeyFileName = "runtime-key.json";
public string Name => FormatName;
public IReadOnlyList<ManifestFile> FilterApplicable(Manifest.Manifest manifest, string platform)
{
// Same platform filter as legacy; runtime-key.json is platform=all by convention.
return manifest.Files.Where(f => f.AppliesTo(platform)).ToList();
}
public void OnApplied(string clientRoot, LoadedManifest manifest, ReleaseOutcome outcome)
{
var keyPath = Path.Combine(clientRoot, RuntimeKeyFileName);
if (!File.Exists(keyPath))
{
// An m2pack manifest without a runtime-key.json is a release-tool bug,
// but we don't hard-fail here: the game will refuse to start without the
// env vars and surface the real error to the user with better context.
Log.Warn($"m2pack: runtime key file not found at {keyPath}");
return;
}
try
{
outcome.RuntimeKey = RuntimeKey.Load(keyPath);
Log.Info($"m2pack: loaded runtime key {outcome.RuntimeKey.KeyId}");
}
catch (Exception ex)
{
Log.Error($"m2pack: failed to parse runtime key {keyPath}", ex);
throw;
}
}
}

View File

@@ -0,0 +1,26 @@
namespace Metin2Launcher.Formats;
/// <summary>
/// Resolves an <see cref="IReleaseFormat"/> for a given manifest. Lives
/// behind a factory so the <see cref="Orchestration.UpdateOrchestrator"/>
/// doesn't grow a switch statement and so tests can inject fakes.
/// </summary>
public static class ReleaseFormatFactory
{
/// <summary>
/// Returns the strategy for the given manifest format identifier.
/// Unknown identifiers throw — we deliberately do NOT silently fall back
/// to the legacy format, because that would turn a signed, malicious
/// <c>format: "evil"</c> value into a downgrade attack vector.
/// </summary>
public static IReleaseFormat Resolve(string effectiveFormat)
{
return effectiveFormat switch
{
LegacyJsonBlobFormat.FormatName => new LegacyJsonBlobFormat(),
M2PackFormat.FormatName => new M2PackFormat(),
_ => throw new NotSupportedException(
$"manifest release format '{effectiveFormat}' is not supported by this launcher"),
};
}
}

View File

@@ -1,5 +1,6 @@
using System.Diagnostics;
using Metin2Launcher.Logging;
using Metin2Launcher.Runtime;
namespace Metin2Launcher.GameLaunch;
@@ -17,7 +18,7 @@ namespace Metin2Launcher.GameLaunch;
/// </summary>
public static class GameProcess
{
public static bool Launch(string clientRoot, string executableName)
public static bool Launch(string clientRoot, string executableName, RuntimeKey? runtimeKey = null)
{
var exePath = Path.Combine(clientRoot, executableName);
if (!File.Exists(exePath))
@@ -28,7 +29,7 @@ public static class GameProcess
try
{
var psi = BuildStartInfo(exePath, clientRoot);
var psi = BuildStartInfo(exePath, clientRoot, runtimeKey);
var p = Process.Start(psi);
if (p is null)
{
@@ -51,25 +52,41 @@ public static class GameProcess
/// through <c>wine</c>) or is itself a Windows binary (which can exec directly).
/// Public so tests can assert the platform branch without actually launching.
/// </summary>
public static ProcessStartInfo BuildStartInfo(string exePath, string workingDirectory)
public static ProcessStartInfo BuildStartInfo(
string exePath,
string workingDirectory,
RuntimeKey? runtimeKey = null)
{
ProcessStartInfo psi;
if (OperatingSystem.IsLinux())
{
var psi = new ProcessStartInfo
psi = new ProcessStartInfo
{
FileName = "wine",
WorkingDirectory = workingDirectory,
UseShellExecute = false,
};
psi.ArgumentList.Add(exePath);
return psi;
}
else
{
psi = new ProcessStartInfo
{
FileName = exePath,
WorkingDirectory = workingDirectory,
UseShellExecute = false,
};
}
return new ProcessStartInfo
// Forward the m2pack runtime key via env vars scoped to the child.
// MVP uses env var delivery only; the shared-memory delivery is stubbed
// behind IRuntimeKeyDelivery and wired in later.
if (runtimeKey is not null)
{
FileName = exePath,
WorkingDirectory = workingDirectory,
UseShellExecute = false,
};
var delivery = new EnvVarKeyDelivery();
delivery.Apply(psi, runtimeKey);
}
return psi;
}
}

View File

@@ -10,6 +10,17 @@ namespace Metin2Launcher.Manifest;
/// </summary>
public sealed class Manifest
{
[JsonPropertyName("format")]
public string? Format { get; set; }
/// <summary>
/// Effective release format. Defaults to <c>legacy-json-blob</c> when the
/// field is absent, which preserves backward compatibility with manifests
/// produced before the m2pack migration.
/// </summary>
public string EffectiveFormat =>
string.IsNullOrWhiteSpace(Format) ? "legacy-json-blob" : Format;
[JsonPropertyName("version")]
public string Version { get; set; } = "";

View File

@@ -1,7 +1,10 @@
using Metin2Launcher.Apply;
using Metin2Launcher.Config;
using Metin2Launcher.Formats;
using Metin2Launcher.Logging;
using Metin2Launcher.Manifest;
using Metin2Launcher.Runtime;
using Metin2Launcher.Telemetry;
using Metin2Launcher.Transfer;
namespace Metin2Launcher.Orchestration;
@@ -35,19 +38,30 @@ public sealed class UpdateProgress
/// </summary>
public sealed class UpdateOrchestrator
{
public sealed record Result(bool Success, LauncherState FinalState, LoadedManifest? Manifest);
public sealed record Result(
bool Success,
LauncherState FinalState,
LoadedManifest? Manifest,
IReleaseFormat? Format = null,
RuntimeKey? RuntimeKey = null);
private readonly string _clientRoot;
private readonly string _stagingDir;
private readonly LauncherSettings _settings;
private readonly HttpClient _http;
private readonly ClientAppliedReporter? _reporter;
public UpdateOrchestrator(string clientRoot, LauncherSettings settings, HttpClient http)
public UpdateOrchestrator(
string clientRoot,
LauncherSettings settings,
HttpClient http,
ClientAppliedReporter? reporter = null)
{
_clientRoot = clientRoot;
_stagingDir = Path.Combine(clientRoot, LauncherConfig.StateDirName, LauncherConfig.StagingDirName);
_settings = settings;
_http = http;
_reporter = reporter;
}
public async Task<Result> RunAsync(IProgress<UpdateProgress> progress, CancellationToken ct)
@@ -84,13 +98,26 @@ public sealed class UpdateOrchestrator
}
var manifest = loaded.Manifest;
Log.Info($"manifest {manifest.Version} verified, {manifest.Files.Count} files");
Log.Info($"manifest {manifest.Version} verified, format={manifest.EffectiveFormat}, {manifest.Files.Count} files");
// 2b. dispatch on release format
IReleaseFormat format;
try
{
format = ReleaseFormatFactory.Resolve(manifest.EffectiveFormat);
}
catch (NotSupportedException ex)
{
Log.Error("unsupported release format, falling back offline: " + ex.Message);
progress.Report(new UpdateProgress { State = LauncherState.OfflineFallback, Percent = 100, Manifest = loaded });
return new Result(false, LauncherState.OfflineFallback, loaded);
}
// 3. diff
progress.Report(new UpdateProgress { State = LauncherState.Diffing, Manifest = loaded });
var platform = OperatingSystem.IsWindows() ? "windows" : "linux";
var applicable = manifest.Files.Where(f => f.AppliesTo(platform)).ToList();
var applicable = format.FilterApplicable(manifest, platform);
var needed = new List<(ManifestFile File, string FinalPath, string StagingPath)>();
long totalBytes = 0;
@@ -109,7 +136,7 @@ public sealed class UpdateOrchestrator
{
Log.Error($"required file rejected by path safety: {ex.Message}");
progress.Report(new UpdateProgress { State = LauncherState.OfflineFallback, Percent = 100, Manifest = loaded });
return new Result(false, LauncherState.OfflineFallback, loaded);
return new Result(false, LauncherState.OfflineFallback, loaded, format);
}
Log.Warn($"optional file rejected by path safety: {ex.Message}");
continue;
@@ -128,8 +155,15 @@ public sealed class UpdateOrchestrator
Log.Info("client already up to date");
var currentManifestPath = Path.Combine(_clientRoot, LauncherConfig.StateDirName, "current-manifest.json");
try { File.WriteAllBytes(currentManifestPath, loaded.RawBytes); } catch { }
// Even on no-op we still need to re-load the runtime key so the
// game process can receive it on this launch.
var noopOutcome = new ReleaseOutcome();
try { format.OnApplied(_clientRoot, loaded, noopOutcome); }
catch (Exception ex) { Log.Warn("format OnApplied (no-op path) failed: " + ex.Message); }
progress.Report(new UpdateProgress { State = LauncherState.UpToDate, Percent = 100, Manifest = loaded });
return new Result(true, LauncherState.UpToDate, loaded);
return new Result(true, LauncherState.UpToDate, loaded, format, noopOutcome.RuntimeKey);
}
// 4. download
@@ -172,7 +206,7 @@ public sealed class UpdateOrchestrator
{
Log.Error($"required file {file.Path} failed to download", ex);
progress.Report(new UpdateProgress { State = LauncherState.OfflineFallback, Percent = 100, Manifest = loaded });
return new Result(false, LauncherState.OfflineFallback, loaded);
return new Result(false, LauncherState.OfflineFallback, loaded, format);
}
Log.Warn($"optional file {file.Path} skipped: {ex.Message}");
// make sure the bytes from this file don't leave the bar stuck low
@@ -194,14 +228,40 @@ public sealed class UpdateOrchestrator
{
Log.Error("apply failed", ex);
progress.Report(new UpdateProgress { State = LauncherState.OfflineFallback, Percent = 100, Manifest = loaded });
return new Result(false, LauncherState.OfflineFallback, loaded);
return new Result(false, LauncherState.OfflineFallback, loaded, format);
}
var current = Path.Combine(_clientRoot, LauncherConfig.StateDirName, "current-manifest.json");
try { File.WriteAllBytes(current, loaded.RawBytes); } catch { }
Log.Info($"update complete: {toApply.Count} files applied");
// 6. format-specific post-apply hook (e.g. load the m2pack runtime key)
var outcome = new ReleaseOutcome();
try
{
format.OnApplied(_clientRoot, loaded, outcome);
}
catch (Exception ex)
{
Log.Error("format OnApplied hook failed", ex);
progress.Report(new UpdateProgress { State = LauncherState.OfflineFallback, Percent = 100, Manifest = loaded });
return new Result(false, LauncherState.OfflineFallback, loaded, format);
}
// 7. opt-in telemetry ping (bounded, best-effort, never blocks)
if (_reporter is not null && _reporter.IsEnabled)
{
try
{
await _reporter.ReportAsync(format.Name, manifest.Version, ct).ConfigureAwait(false);
}
catch (Exception ex)
{
Log.Warn("telemetry reporter threw: " + ex.Message);
}
}
Log.Info($"update complete: {toApply.Count} files applied (format={format.Name})");
progress.Report(new UpdateProgress { State = LauncherState.UpToDate, Percent = 100, Manifest = loaded });
return new Result(true, LauncherState.UpToDate, loaded);
return new Result(true, LauncherState.UpToDate, loaded, format, outcome.RuntimeKey);
}
}

View File

@@ -5,6 +5,8 @@ using Metin2Launcher.GameLaunch;
using Metin2Launcher.Logging;
using Metin2Launcher.Manifest;
using Metin2Launcher.Orchestration;
using Metin2Launcher.Runtime;
using Metin2Launcher.Telemetry;
using Metin2Launcher.UI;
using Metin2Launcher.UI.ViewModels;
using Velopack;
@@ -58,7 +60,8 @@ public static class Program
var settingsPath = Path.Combine(clientRoot, LauncherConfig.StateDirName, "launcher-settings.json");
var settings = LauncherSettings.Load(settingsPath);
var orchestrator = new UpdateOrchestrator(clientRoot, settings, http);
var reporter = new ClientAppliedReporter(http, LauncherConfig.TelemetryUrlTemplate);
var orchestrator = new UpdateOrchestrator(clientRoot, settings, http, reporter);
var progress = new Progress<UpdateProgress>(p =>
{
var pct = p.Percent.HasValue ? $" {(int)p.Percent.Value}%" : "";
@@ -67,10 +70,12 @@ public static class Program
});
var updatedOk = false;
RuntimeKey? runtimeKey = null;
try
{
var result = await orchestrator.RunAsync(progress, cts.Token).ConfigureAwait(false);
updatedOk = result.Success;
runtimeKey = result.RuntimeKey;
}
catch (ManifestSignatureException ex)
{
@@ -92,7 +97,7 @@ public static class Program
if (!updatedOk)
Log.Warn("proceeding with current local client (offline fallback)");
var launched = GameProcess.Launch(clientRoot, LauncherConfig.GameExecutable);
var launched = GameProcess.Launch(clientRoot, LauncherConfig.GameExecutable, runtimeKey);
return launched ? 0 : 1;
}

View File

@@ -0,0 +1,30 @@
using System.Diagnostics;
namespace Metin2Launcher.Runtime;
/// <summary>
/// Delivers the runtime key to the game process via environment variables.
/// This is the MVP mechanism — every platform supports it, no native interop,
/// no timing windows. The variables are scoped to the child's environment only
/// (we mutate <see cref="ProcessStartInfo.Environment"/>, not the launcher's
/// global process environment), so other processes on the machine never see them.
/// </summary>
public sealed class EnvVarKeyDelivery : IRuntimeKeyDelivery
{
public const string MasterKeyVar = "M2PACK_MASTER_KEY_HEX";
public const string SignPubkeyVar = "M2PACK_SIGN_PUBKEY_HEX";
public const string KeyIdVar = "M2PACK_KEY_ID";
public string Name => "env-var";
public void Apply(ProcessStartInfo psi, RuntimeKey key)
{
if (psi is null) throw new ArgumentNullException(nameof(psi));
if (key is null) throw new ArgumentNullException(nameof(key));
// Never set these on the launcher process itself — scope them to the child only.
psi.Environment[MasterKeyVar] = key.MasterKeyHex;
psi.Environment[SignPubkeyVar] = key.SignPubkeyHex;
psi.Environment[KeyIdVar] = key.KeyId;
}
}

View File

@@ -0,0 +1,18 @@
using System.Diagnostics;
namespace Metin2Launcher.Runtime;
/// <summary>
/// Strategy for handing a <see cref="RuntimeKey"/> to the game process. The
/// MVP implements a single strategy (<see cref="EnvVarKeyDelivery"/>) — the
/// Windows shared-memory strategy is stubbed in <c>SharedMemoryKeyDelivery</c>
/// and will be wired once the client-side receiver lands.
/// </summary>
public interface IRuntimeKeyDelivery
{
/// <summary>Name of the delivery mechanism, used for logs and tests.</summary>
string Name { get; }
/// <summary>Mutates <paramref name="psi"/> so the spawned process can pick up the key.</summary>
void Apply(ProcessStartInfo psi, RuntimeKey key);
}

View File

@@ -0,0 +1,76 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Metin2Launcher.Runtime;
/// <summary>
/// In-memory representation of a runtime key bundle delivered alongside an
/// m2pack release. The launcher never decrypts or opens .m2p archives itself;
/// it merely parses the key bundle produced by the release tool and passes it
/// through to the game executable via one of the <see cref="IRuntimeKeyDelivery"/>
/// mechanisms.
///
/// File layout (runtime-key.json, placed next to the manifest by the release
/// tool, never signed independently — its integrity comes from the signed
/// manifest that references it):
/// <code>
/// {
/// "key_id": "2026.04.14-1",
/// "master_key_hex": "&lt;64 hex chars&gt;",
/// "sign_pubkey_hex": "&lt;64 hex chars&gt;"
/// }
/// </code>
/// </summary>
public sealed class RuntimeKey
{
[JsonPropertyName("key_id")]
public string KeyId { get; set; } = "";
[JsonPropertyName("master_key_hex")]
public string MasterKeyHex { get; set; } = "";
[JsonPropertyName("sign_pubkey_hex")]
public string SignPubkeyHex { get; set; } = "";
private static readonly JsonSerializerOptions _opts = new()
{
PropertyNameCaseInsensitive = false,
};
/// <summary>Parses a runtime-key.json blob. Throws on malformed or incomplete data.</summary>
public static RuntimeKey Parse(ReadOnlySpan<byte> raw)
{
var rk = JsonSerializer.Deserialize<RuntimeKey>(raw, _opts)
?? throw new InvalidDataException("runtime-key deserialized to null");
Validate(rk);
return rk;
}
public static RuntimeKey Load(string path)
{
var bytes = File.ReadAllBytes(path);
return Parse(bytes);
}
private static void Validate(RuntimeKey rk)
{
if (string.IsNullOrWhiteSpace(rk.KeyId))
throw new InvalidDataException("runtime-key missing 'key_id'");
if (!IsHex(rk.MasterKeyHex, 64))
throw new InvalidDataException("runtime-key 'master_key_hex' must be 64 hex chars");
if (!IsHex(rk.SignPubkeyHex, 64))
throw new InvalidDataException("runtime-key 'sign_pubkey_hex' must be 64 hex chars");
}
private static bool IsHex(string s, int expectedLen)
{
if (s is null || s.Length != expectedLen) return false;
for (int i = 0; i < s.Length; i++)
{
char c = s[i];
bool ok = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
if (!ok) return false;
}
return true;
}
}

View File

@@ -0,0 +1,39 @@
using System.Diagnostics;
namespace Metin2Launcher.Runtime;
/// <summary>
/// STUB. Windows shared-memory delivery for the runtime key is not implemented
/// yet — the receiver on the client side has not landed. This type exists so
/// the factory wiring in <see cref="Program"/> and the docs have a concrete
/// name to reference, and so a future change can fill in the body without
/// churning callers.
///
/// Planned implementation:
/// 1. Create a named, write-only <c>MemoryMappedFile</c> with a random suffix
/// (e.g. <c>m2pack-key-&lt;guid&gt;</c>) and a <c>NamedPipeServerStream</c>-style
/// ACL that only grants access to the child PID once it has been spawned.
/// 2. Write a short binary header (magic, key_id length, key_id, master_key,
/// sign_pubkey) into the mapping.
/// 3. Set an env var on the child with the mapping name so the receiver can
/// <c>OpenExisting</c> and read the key before zeroing the source.
/// 4. Zero and close the mapping from the launcher side once the child
/// signals it has consumed the key (via <c>ClientAppliedReporter</c>).
///
/// Until then, calling <see cref="Apply"/> throws — the factory must never
/// select this delivery on a non-Windows host, and on Windows the code path
/// is guarded behind a feature flag that is off by default.
/// </summary>
public sealed class SharedMemoryKeyDelivery : IRuntimeKeyDelivery
{
public string Name => "shared-memory";
public void Apply(ProcessStartInfo psi, RuntimeKey key)
{
// TODO(m2pack): implement Windows shared-memory delivery once the
// client-side receiver is in place. Tracked in docs/m2pack-integration.md.
throw new NotSupportedException(
"shared-memory runtime key delivery is not implemented yet; " +
"use EnvVarKeyDelivery for the MVP.");
}
}

View File

@@ -0,0 +1,107 @@
using System.Net.Http;
using System.Text;
using System.Text.Json;
using Metin2Launcher.Logging;
namespace Metin2Launcher.Telemetry;
/// <summary>
/// Opt-in "client applied this release" ping. Fires at most once per update
/// run, after the apply phase succeeds, and only if a non-empty template URL
/// is configured on the launcher settings. The caller passes in an
/// <see cref="HttpClient"/> that the reporter does NOT own.
///
/// Design rules:
/// - timeout is strictly bounded (5 seconds) — telemetry never blocks the
/// launch flow
/// - failures are always swallowed and logged as warnings
/// - nothing personally identifying is sent: just format, version and a
/// best-effort machine identifier derived from the env
/// - no retries, no queueing, no persistent buffer
/// </summary>
public sealed class ClientAppliedReporter
{
public static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
private readonly HttpClient _http;
private readonly string? _urlTemplate;
public ClientAppliedReporter(HttpClient http, string? urlTemplate)
{
_http = http;
_urlTemplate = urlTemplate;
}
/// <summary>True when the reporter is actually wired to a destination.</summary>
public bool IsEnabled => !string.IsNullOrWhiteSpace(_urlTemplate);
/// <summary>
/// Sends a single POST to the configured URL. Returns <c>true</c> if the
/// server accepted it (2xx), <c>false</c> on any other outcome. Never throws.
/// </summary>
public async Task<bool> ReportAsync(string format, string version, CancellationToken ct)
{
if (!IsEnabled)
return false;
try
{
var url = Expand(_urlTemplate!, format, version);
var body = JsonSerializer.SerializeToUtf8Bytes(new Payload
{
Format = format,
Version = version,
Timestamp = DateTime.UtcNow.ToString("o"),
});
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct);
linked.CancelAfter(Timeout);
using var req = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new ByteArrayContent(body),
};
req.Content.Headers.ContentType =
new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
using var resp = await _http.SendAsync(req, linked.Token).ConfigureAwait(false);
if (!resp.IsSuccessStatusCode)
{
Log.Warn($"telemetry: client-applied POST returned {(int)resp.StatusCode}");
return false;
}
Log.Info($"telemetry: reported client-applied format={format} version={version}");
return true;
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
Log.Warn($"telemetry: client-applied POST timed out after {Timeout.TotalSeconds}s");
return false;
}
catch (Exception ex)
{
Log.Warn("telemetry: client-applied POST failed: " + ex.Message);
return false;
}
}
/// <summary>
/// Expands <c>{format}</c> and <c>{version}</c> placeholders in the URL
/// template. Case-sensitive, by design — we want obvious failures if the
/// template is wrong.
/// </summary>
public static string Expand(string template, string format, string version)
{
var sb = new StringBuilder(template);
sb.Replace("{format}", Uri.EscapeDataString(format));
sb.Replace("{version}", Uri.EscapeDataString(version));
return sb.ToString();
}
private sealed class Payload
{
public string Format { get; set; } = "";
public string Version { get; set; } = "";
public string Timestamp { get; set; } = "";
}
}

View File

@@ -0,0 +1,124 @@
using System.Net;
using System.Net.Http;
using Metin2Launcher.Telemetry;
using Xunit;
namespace Metin2Launcher.Tests;
public class ClientAppliedReporterTests
{
[Fact]
public void Disabled_when_template_is_empty()
{
using var http = new HttpClient();
var r = new ClientAppliedReporter(http, "");
Assert.False(r.IsEnabled);
}
[Fact]
public void Disabled_when_template_is_null()
{
using var http = new HttpClient();
var r = new ClientAppliedReporter(http, null);
Assert.False(r.IsEnabled);
}
[Fact]
public void Disabled_when_template_is_whitespace()
{
using var http = new HttpClient();
var r = new ClientAppliedReporter(http, " ");
Assert.False(r.IsEnabled);
}
[Fact]
public async Task ReportAsync_noop_when_disabled()
{
using var http = new HttpClient();
var r = new ClientAppliedReporter(http, "");
var ok = await r.ReportAsync("m2pack", "v1", CancellationToken.None);
Assert.False(ok);
}
[Fact]
public void Expand_substitutes_placeholders()
{
var s = ClientAppliedReporter.Expand("https://x/{format}/{version}", "m2pack", "2026.04.14-1");
Assert.Equal("https://x/m2pack/2026.04.14-1", s);
}
[Fact]
public void Expand_uri_escapes_values()
{
var s = ClientAppliedReporter.Expand("https://x/{version}", "m2pack", "v 1+beta");
Assert.Contains("v%201%2Bbeta", s);
}
[Fact]
public void Expand_leaves_unknown_placeholders()
{
var s = ClientAppliedReporter.Expand("https://x/{other}", "m2pack", "v1");
Assert.Equal("https://x/{other}", s);
}
[Fact]
public async Task ReportAsync_posts_json_on_success()
{
using var listener = new TestHttpListener();
var url = listener.Start();
listener.RespondWith(HttpStatusCode.OK);
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
var r = new ClientAppliedReporter(http, url + "?format={format}&version={version}");
var ok = await r.ReportAsync("m2pack", "2026.04.14-1", CancellationToken.None);
Assert.True(ok);
var req = listener.LastRequest!;
Assert.Equal("POST", req.Method);
Assert.Contains("format=m2pack", req.RawUrl);
Assert.Contains("\"Format\":\"m2pack\"", req.Body);
Assert.Contains("\"Version\":\"2026.04.14-1\"", req.Body);
}
[Fact]
public async Task ReportAsync_returns_false_on_500()
{
using var listener = new TestHttpListener();
var url = listener.Start();
listener.RespondWith(HttpStatusCode.InternalServerError);
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
var r = new ClientAppliedReporter(http, url);
var ok = await r.ReportAsync("m2pack", "v1", CancellationToken.None);
Assert.False(ok);
}
[Fact]
public async Task ReportAsync_swallows_connection_refused()
{
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(2) };
// pick a port unlikely to be listening
var r = new ClientAppliedReporter(http, "http://127.0.0.1:1/");
var ok = await r.ReportAsync("m2pack", "v1", CancellationToken.None);
Assert.False(ok);
}
[Fact]
public async Task ReportAsync_times_out_after_five_seconds()
{
using var listener = new TestHttpListener();
var url = listener.Start();
listener.RespondAfter(TimeSpan.FromSeconds(10), HttpStatusCode.OK);
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
var r = new ClientAppliedReporter(http, url);
var sw = System.Diagnostics.Stopwatch.StartNew();
var ok = await r.ReportAsync("m2pack", "v1", CancellationToken.None);
sw.Stop();
Assert.False(ok);
// reporter caps at 5s internally — allow slack for CI schedulers
Assert.InRange(sw.Elapsed.TotalSeconds, 3.0, 9.0);
}
}

View File

@@ -0,0 +1,68 @@
using System.Diagnostics;
using Metin2Launcher.Runtime;
using Xunit;
namespace Metin2Launcher.Tests;
public class EnvVarDeliveryTests
{
private static RuntimeKey SampleKey() => new()
{
KeyId = "2026.04.14-1",
MasterKeyHex = new string('a', 64),
SignPubkeyHex = new string('b', 64),
};
[Fact]
public void Apply_sets_expected_env_vars()
{
var psi = new ProcessStartInfo { FileName = "nothing" };
new EnvVarKeyDelivery().Apply(psi, SampleKey());
Assert.Equal("2026.04.14-1", psi.Environment[EnvVarKeyDelivery.KeyIdVar]);
Assert.Equal(new string('a', 64), psi.Environment[EnvVarKeyDelivery.MasterKeyVar]);
Assert.Equal(new string('b', 64), psi.Environment[EnvVarKeyDelivery.SignPubkeyVar]);
}
[Fact]
public void Apply_does_not_leak_into_current_process()
{
var before = Environment.GetEnvironmentVariable(EnvVarKeyDelivery.MasterKeyVar);
var psi = new ProcessStartInfo { FileName = "nothing" };
new EnvVarKeyDelivery().Apply(psi, SampleKey());
var after = Environment.GetEnvironmentVariable(EnvVarKeyDelivery.MasterKeyVar);
Assert.Equal(before, after); // still unset (or whatever it was)
}
[Fact]
public void Apply_overwrites_existing_values_on_psi()
{
var psi = new ProcessStartInfo { FileName = "nothing" };
psi.Environment[EnvVarKeyDelivery.KeyIdVar] = "stale";
new EnvVarKeyDelivery().Apply(psi, SampleKey());
Assert.Equal("2026.04.14-1", psi.Environment[EnvVarKeyDelivery.KeyIdVar]);
}
[Fact]
public void Apply_rejects_null_args()
{
var d = new EnvVarKeyDelivery();
Assert.Throws<ArgumentNullException>(() => d.Apply(null!, SampleKey()));
Assert.Throws<ArgumentNullException>(() => d.Apply(new ProcessStartInfo(), null!));
}
[Fact]
public void SharedMemoryDelivery_is_stubbed()
{
var d = new SharedMemoryKeyDelivery();
Assert.Equal("shared-memory", d.Name);
Assert.Throws<NotSupportedException>(() =>
d.Apply(new ProcessStartInfo(), SampleKey()));
}
[Fact]
public void Delivery_name_is_env_var()
{
Assert.Equal("env-var", new EnvVarKeyDelivery().Name);
}
}

View File

@@ -1,4 +1,5 @@
using Metin2Launcher.GameLaunch;
using Metin2Launcher.Runtime;
using Xunit;
namespace Metin2Launcher.Tests;
@@ -29,4 +30,46 @@ public class GameProcessTests
Assert.Empty(psi.ArgumentList);
}
}
[Fact]
public void BuildStartInfo_without_runtime_key_does_not_set_m2pack_env()
{
var exe = Path.Combine(Path.GetTempPath(), "Metin2.exe");
var psi = GameProcess.BuildStartInfo(exe, Path.GetTempPath(), runtimeKey: null);
Assert.False(psi.Environment.ContainsKey(EnvVarKeyDelivery.MasterKeyVar));
Assert.False(psi.Environment.ContainsKey(EnvVarKeyDelivery.SignPubkeyVar));
Assert.False(psi.Environment.ContainsKey(EnvVarKeyDelivery.KeyIdVar));
}
[Fact]
public void BuildStartInfo_with_runtime_key_forwards_env_vars()
{
var exe = Path.Combine(Path.GetTempPath(), "Metin2.exe");
var rk = new RuntimeKey
{
KeyId = "2026.04.14-1",
MasterKeyHex = new string('a', 64),
SignPubkeyHex = new string('b', 64),
};
var psi = GameProcess.BuildStartInfo(exe, Path.GetTempPath(), rk);
Assert.Equal("2026.04.14-1", psi.Environment[EnvVarKeyDelivery.KeyIdVar]);
Assert.Equal(new string('a', 64), psi.Environment[EnvVarKeyDelivery.MasterKeyVar]);
Assert.Equal(new string('b', 64), psi.Environment[EnvVarKeyDelivery.SignPubkeyVar]);
}
[Fact]
public void BuildStartInfo_runtime_key_does_not_pollute_current_process()
{
var exe = Path.Combine(Path.GetTempPath(), "Metin2.exe");
var before = Environment.GetEnvironmentVariable(EnvVarKeyDelivery.MasterKeyVar);
var rk = new RuntimeKey
{
KeyId = "k",
MasterKeyHex = new string('c', 64),
SignPubkeyHex = new string('d', 64),
};
GameProcess.BuildStartInfo(exe, Path.GetTempPath(), rk);
var after = Environment.GetEnvironmentVariable(EnvVarKeyDelivery.MasterKeyVar);
Assert.Equal(before, after);
}
}

View File

@@ -0,0 +1,64 @@
using Metin2Launcher.Formats;
using Metin2Launcher.Manifest;
using Xunit;
using ManifestDto = Metin2Launcher.Manifest.Manifest;
namespace Metin2Launcher.Tests;
public class LegacyJsonBlobFormatTests
{
private static ManifestDto SampleManifest() => new()
{
Version = "v1",
Launcher = new ManifestLauncherEntry { Path = "Metin2Launcher.exe", Sha256 = "x", Size = 1 },
Files =
{
new ManifestFile { Path = "a.pck", Sha256 = "h1", Size = 10 },
new ManifestFile { Path = "Metin2.exe", Sha256 = "h2", Size = 20, Platform = "windows" },
new ManifestFile { Path = "linux-bin", Sha256 = "h3", Size = 30, Platform = "linux" },
},
};
[Fact]
public void Name_is_legacy_json_blob()
{
Assert.Equal("legacy-json-blob", new LegacyJsonBlobFormat().Name);
}
[Fact]
public void FilterApplicable_windows_platform()
{
var format = new LegacyJsonBlobFormat();
var filtered = format.FilterApplicable(SampleManifest(), "windows");
Assert.Equal(2, filtered.Count);
Assert.Contains(filtered, f => f.Path == "a.pck");
Assert.Contains(filtered, f => f.Path == "Metin2.exe");
}
[Fact]
public void FilterApplicable_linux_platform()
{
var format = new LegacyJsonBlobFormat();
var filtered = format.FilterApplicable(SampleManifest(), "linux");
Assert.Equal(2, filtered.Count);
Assert.Contains(filtered, f => f.Path == "a.pck");
Assert.Contains(filtered, f => f.Path == "linux-bin");
}
[Fact]
public void OnApplied_is_noop()
{
var outcome = new ReleaseOutcome();
var tmp = Path.Combine(Path.GetTempPath(), "lg-" + Guid.NewGuid());
Directory.CreateDirectory(tmp);
try
{
new LegacyJsonBlobFormat().OnApplied(
tmp,
new LoadedManifest(Array.Empty<byte>(), Array.Empty<byte>(), SampleManifest()),
outcome);
Assert.Null(outcome.RuntimeKey);
}
finally { Directory.Delete(tmp, true); }
}
}

View File

@@ -0,0 +1,131 @@
using Metin2Launcher.Formats;
using Metin2Launcher.Manifest;
using Metin2Launcher.Runtime;
using Xunit;
using ManifestDto = Metin2Launcher.Manifest.Manifest;
namespace Metin2Launcher.Tests;
public class M2PackFormatTests
{
private const string ValidHex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
private static ManifestDto SampleManifest() => new()
{
Version = "2026.04.14-1",
Format = "m2pack",
Launcher = new ManifestLauncherEntry { Path = "Metin2Launcher.exe", Sha256 = "x", Size = 1 },
Files =
{
new ManifestFile { Path = "pack/item.m2p", Sha256 = "h1", Size = 1000 },
new ManifestFile { Path = "runtime-key.json", Sha256 = "h2", Size = 200 },
},
};
private static string WriteRuntimeKey(string dir, string keyId = "2026.04.14-1")
{
var path = Path.Combine(dir, M2PackFormat.RuntimeKeyFileName);
File.WriteAllText(path, $$"""
{
"key_id": "{{keyId}}",
"master_key_hex": "{{ValidHex}}",
"sign_pubkey_hex": "{{ValidHex}}"
}
""");
return path;
}
[Fact]
public void Name_is_m2pack()
{
Assert.Equal("m2pack", new M2PackFormat().Name);
}
[Fact]
public void FilterApplicable_includes_m2p_and_runtime_key()
{
var filtered = new M2PackFormat().FilterApplicable(SampleManifest(), "windows");
Assert.Equal(2, filtered.Count);
Assert.Contains(filtered, f => f.Path.EndsWith(".m2p"));
Assert.Contains(filtered, f => f.Path == "runtime-key.json");
}
[Fact]
public void OnApplied_loads_runtime_key_when_present()
{
var tmp = Path.Combine(Path.GetTempPath(), "m2p-" + Guid.NewGuid());
Directory.CreateDirectory(tmp);
try
{
WriteRuntimeKey(tmp);
var outcome = new ReleaseOutcome();
new M2PackFormat().OnApplied(
tmp,
new LoadedManifest(Array.Empty<byte>(), Array.Empty<byte>(), SampleManifest()),
outcome);
Assert.NotNull(outcome.RuntimeKey);
Assert.Equal("2026.04.14-1", outcome.RuntimeKey!.KeyId);
Assert.Equal(ValidHex, outcome.RuntimeKey.MasterKeyHex);
}
finally { Directory.Delete(tmp, true); }
}
[Fact]
public void OnApplied_tolerates_missing_runtime_key()
{
var tmp = Path.Combine(Path.GetTempPath(), "m2p-" + Guid.NewGuid());
Directory.CreateDirectory(tmp);
try
{
var outcome = new ReleaseOutcome();
new M2PackFormat().OnApplied(
tmp,
new LoadedManifest(Array.Empty<byte>(), Array.Empty<byte>(), SampleManifest()),
outcome);
Assert.Null(outcome.RuntimeKey); // warn-logged, not thrown
}
finally { Directory.Delete(tmp, true); }
}
[Fact]
public void OnApplied_throws_on_malformed_runtime_key()
{
var tmp = Path.Combine(Path.GetTempPath(), "m2p-" + Guid.NewGuid());
Directory.CreateDirectory(tmp);
try
{
File.WriteAllText(Path.Combine(tmp, M2PackFormat.RuntimeKeyFileName), "{ not valid }");
var outcome = new ReleaseOutcome();
Assert.ThrowsAny<Exception>(() =>
new M2PackFormat().OnApplied(
tmp,
new LoadedManifest(Array.Empty<byte>(), Array.Empty<byte>(), SampleManifest()),
outcome));
}
finally { Directory.Delete(tmp, true); }
}
[Fact]
public void Launcher_never_opens_m2p_archive()
{
// Guard test: the format must not touch the .m2p file itself. We assert
// by constructing a manifest whose .m2p entry points at a file that
// does not exist on disk and would throw if opened — OnApplied must
// ignore it entirely and only touch runtime-key.json.
var tmp = Path.Combine(Path.GetTempPath(), "m2p-" + Guid.NewGuid());
Directory.CreateDirectory(tmp);
try
{
WriteRuntimeKey(tmp);
// Intentionally no pack/item.m2p on disk.
var outcome = new ReleaseOutcome();
new M2PackFormat().OnApplied(
tmp,
new LoadedManifest(Array.Empty<byte>(), Array.Empty<byte>(), SampleManifest()),
outcome);
Assert.NotNull(outcome.RuntimeKey);
}
finally { Directory.Delete(tmp, true); }
}
}

View File

@@ -0,0 +1,86 @@
using System.Text;
using Metin2Launcher.Formats;
using Metin2Launcher.Manifest;
using Xunit;
using ManifestDto = Metin2Launcher.Manifest.Manifest;
namespace Metin2Launcher.Tests;
public class ReleaseFormatFactoryTests
{
[Fact]
public void Resolves_legacy_by_default()
{
var f = ReleaseFormatFactory.Resolve("legacy-json-blob");
Assert.IsType<LegacyJsonBlobFormat>(f);
}
[Fact]
public void Resolves_m2pack()
{
var f = ReleaseFormatFactory.Resolve("m2pack");
Assert.IsType<M2PackFormat>(f);
}
[Fact]
public void Throws_on_unknown_format()
{
Assert.Throws<NotSupportedException>(() =>
ReleaseFormatFactory.Resolve("evil-format"));
}
[Fact]
public void Manifest_without_format_defaults_to_legacy()
{
var m = new ManifestDto { Version = "v1" };
Assert.Equal("legacy-json-blob", m.EffectiveFormat);
}
[Fact]
public void Manifest_with_blank_format_defaults_to_legacy()
{
var m = new ManifestDto { Version = "v1", Format = " " };
Assert.Equal("legacy-json-blob", m.EffectiveFormat);
}
[Fact]
public void Manifest_with_explicit_format_is_honoured()
{
var m = new ManifestDto { Version = "v1", Format = "m2pack" };
Assert.Equal("m2pack", m.EffectiveFormat);
}
[Fact]
public void Loader_tolerates_unknown_top_level_field_next_to_format()
{
const string json = """
{
"format": "m2pack",
"version": "2026.04.14-1",
"created_at": "2026-04-14T14:00:00Z",
"future_field": {"nested": 1},
"launcher": {"path": "Metin2Launcher.exe", "sha256": "abcd", "size": 1},
"files": []
}
""";
var m = ManifestLoader.Parse(Encoding.UTF8.GetBytes(json));
Assert.Equal("m2pack", m.EffectiveFormat);
Assert.Equal("2026.04.14-1", m.Version);
}
[Fact]
public void Loader_parses_manifest_without_format_as_legacy()
{
const string json = """
{
"version": "2026.04.14-1",
"created_at": "2026-04-14T14:00:00Z",
"launcher": {"path": "Metin2Launcher.exe", "sha256": "abcd", "size": 1},
"files": []
}
""";
var m = ManifestLoader.Parse(Encoding.UTF8.GetBytes(json));
Assert.Null(m.Format);
Assert.Equal("legacy-json-blob", m.EffectiveFormat);
}
}

View File

@@ -0,0 +1,83 @@
using System.Text;
using Metin2Launcher.Runtime;
using Xunit;
namespace Metin2Launcher.Tests;
public class RuntimeKeyTests
{
private const string ValidHex64 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
private static string SampleJson(
string keyId = "2026.04.14-1",
string master = ValidHex64,
string pub = ValidHex64)
=> $$"""
{
"key_id": "{{keyId}}",
"master_key_hex": "{{master}}",
"sign_pubkey_hex": "{{pub}}"
}
""";
[Fact]
public void Parse_happy_path()
{
var rk = RuntimeKey.Parse(Encoding.UTF8.GetBytes(SampleJson()));
Assert.Equal("2026.04.14-1", rk.KeyId);
Assert.Equal(ValidHex64, rk.MasterKeyHex);
Assert.Equal(ValidHex64, rk.SignPubkeyHex);
}
[Fact]
public void Parse_rejects_missing_key_id()
{
var json = SampleJson(keyId: "");
Assert.Throws<InvalidDataException>(() => RuntimeKey.Parse(Encoding.UTF8.GetBytes(json)));
}
[Theory]
[InlineData("")]
[InlineData("tooshort")]
[InlineData("zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz")] // right len, wrong chars
public void Parse_rejects_bad_master_key(string master)
{
var json = SampleJson(master: master);
Assert.Throws<InvalidDataException>(() => RuntimeKey.Parse(Encoding.UTF8.GetBytes(json)));
}
[Fact]
public void Parse_rejects_bad_pubkey()
{
var json = SampleJson(pub: new string('g', 64));
Assert.Throws<InvalidDataException>(() => RuntimeKey.Parse(Encoding.UTF8.GetBytes(json)));
}
[Fact]
public void Parse_accepts_uppercase_hex()
{
var upper = ValidHex64.ToUpperInvariant();
var rk = RuntimeKey.Parse(Encoding.UTF8.GetBytes(SampleJson(master: upper, pub: upper)));
Assert.Equal(upper, rk.MasterKeyHex);
}
[Fact]
public void Load_reads_from_disk()
{
var tmp = Path.Combine(Path.GetTempPath(), "runtime-key-test-" + Guid.NewGuid() + ".json");
File.WriteAllText(tmp, SampleJson());
try
{
var rk = RuntimeKey.Load(tmp);
Assert.Equal("2026.04.14-1", rk.KeyId);
}
finally { File.Delete(tmp); }
}
[Fact]
public void Parse_rejects_malformed_json()
{
Assert.Throws<System.Text.Json.JsonException>(() =>
RuntimeKey.Parse(Encoding.UTF8.GetBytes("not json")));
}
}

View File

@@ -0,0 +1,107 @@
using System.Net;
using System.Text;
namespace Metin2Launcher.Tests;
/// <summary>
/// Minimal in-process HTTP server backed by <see cref="HttpListener"/> for
/// telemetry / orchestrator tests. Dependency-free — no TestServer, no
/// WireMock, no new NuGet package.
/// </summary>
public sealed class TestHttpListener : IDisposable
{
private readonly HttpListener _listener = new();
private CancellationTokenSource _cts = new();
private Task? _loop;
private HttpStatusCode _status = HttpStatusCode.OK;
private TimeSpan _delay = TimeSpan.Zero;
private Func<HttpListenerRequest, byte[]>? _bodyFactory;
public CapturedRequest? LastRequest { get; private set; }
public string Start()
{
int port = GetFreeTcpPort();
var prefix = $"http://127.0.0.1:{port}/";
_listener.Prefixes.Add(prefix);
_listener.Start();
_loop = Task.Run(LoopAsync);
return prefix;
}
public void RespondWith(HttpStatusCode status) => _status = status;
public void RespondAfter(TimeSpan delay, HttpStatusCode status)
{
_delay = delay;
_status = status;
}
public void RespondBody(Func<HttpListenerRequest, byte[]> factory) => _bodyFactory = factory;
private async Task LoopAsync()
{
while (!_cts.IsCancellationRequested)
{
HttpListenerContext ctx;
try { ctx = await _listener.GetContextAsync().ConfigureAwait(false); }
catch { return; }
try
{
string body;
using (var r = new StreamReader(ctx.Request.InputStream, Encoding.UTF8))
body = await r.ReadToEndAsync().ConfigureAwait(false);
LastRequest = new CapturedRequest
{
Method = ctx.Request.HttpMethod,
RawUrl = ctx.Request.RawUrl ?? "",
Body = body,
};
if (_delay > TimeSpan.Zero)
{
try { await Task.Delay(_delay, _cts.Token).ConfigureAwait(false); }
catch (OperationCanceledException) { }
}
ctx.Response.StatusCode = (int)_status;
if (_bodyFactory != null)
{
var payload = _bodyFactory(ctx.Request);
await ctx.Response.OutputStream.WriteAsync(payload, _cts.Token).ConfigureAwait(false);
}
ctx.Response.Close();
}
catch
{
try { ctx.Response.Abort(); } catch { }
}
}
}
private static int GetFreeTcpPort()
{
var l = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0);
l.Start();
var port = ((System.Net.IPEndPoint)l.LocalEndpoint).Port;
l.Stop();
return port;
}
public void Dispose()
{
try { _cts.Cancel(); } catch { }
try { _listener.Stop(); _listener.Close(); } catch { }
try { _loop?.Wait(TimeSpan.FromSeconds(2)); } catch { }
_cts.Dispose();
}
public sealed class CapturedRequest
{
public string Method { get; set; } = "";
public string RawUrl { get; set; } = "";
public string Body { get; set; } = "";
}
}

View File

@@ -0,0 +1,112 @@
using System.Text;
using System.Text.Json;
using Metin2Launcher.Config;
using Metin2Launcher.Formats;
using Metin2Launcher.Manifest;
using Metin2Launcher.Orchestration;
using Xunit;
using ManifestDto = Metin2Launcher.Manifest.Manifest;
namespace Metin2Launcher.Tests;
/// <summary>
/// Exercises the <see cref="UpdateOrchestrator"/>'s format-dispatch wiring.
///
/// The orchestrator short-circuits to a signature exception when the fetched
/// manifest isn't signed by the hardcoded <see cref="LauncherConfig.PublicKeyHex"/>.
/// We can't realistically override that constant in a test without widening
/// the surface area, so these tests focus on:
///
/// 1. The unit-level pieces the orchestrator relies on (factory, effective
/// format, file filtering, post-apply hook) — covered in sibling test
/// classes and asserted here for regression safety.
/// 2. End-to-end "manifest fetched but signature mismatches" showing the
/// orchestrator really reaches the verify stage against a synthetic
/// HTTP endpoint served by <see cref="TestHttpListener"/>.
/// </summary>
public class UpdateOrchestratorFormatDispatchTests
{
[Fact]
public async Task Bad_signature_throws_before_format_dispatch()
{
using var listener = new TestHttpListener();
var url = listener.Start();
var manifest = new ManifestDto
{
Format = "m2pack",
Version = "v1",
CreatedAt = "2026-04-14T00:00:00Z",
Launcher = new ManifestLauncherEntry { Path = "L.exe", Sha256 = "dead", Size = 1 },
};
var body = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(manifest));
var badSig = new byte[64]; // deliberately wrong signature
listener.RespondBody(req => req.RawUrl!.EndsWith(".sig") ? badSig : body);
var clientRoot = Path.Combine(Path.GetTempPath(), "orch-" + Guid.NewGuid());
Directory.CreateDirectory(clientRoot);
try
{
using var http = new HttpClient();
var settings = new LauncherSettings
{
DevMode = true,
ManifestUrlOverride = url + "manifest.json",
};
var orchestrator = new UpdateOrchestrator(clientRoot, settings, http);
await Assert.ThrowsAsync<ManifestSignatureException>(async () =>
await orchestrator.RunAsync(new Progress<UpdateProgress>(_ => { }), CancellationToken.None));
}
finally { Directory.Delete(clientRoot, true); }
}
[Fact]
public async Task Manifest_fetch_failure_returns_offline_fallback()
{
using var listener = new TestHttpListener();
var url = listener.Start();
listener.RespondWith(System.Net.HttpStatusCode.NotFound);
var clientRoot = Path.Combine(Path.GetTempPath(), "orch-" + Guid.NewGuid());
Directory.CreateDirectory(clientRoot);
try
{
using var http = new HttpClient();
var settings = new LauncherSettings
{
DevMode = true,
ManifestUrlOverride = url + "manifest.json",
};
var orchestrator = new UpdateOrchestrator(clientRoot, settings, http);
var result = await orchestrator.RunAsync(
new Progress<UpdateProgress>(_ => { }),
CancellationToken.None);
Assert.False(result.Success);
Assert.Equal(LauncherState.OfflineFallback, result.FinalState);
Assert.Null(result.Format);
Assert.Null(result.RuntimeKey);
}
finally { Directory.Delete(clientRoot, true); }
}
[Fact]
public void Result_carries_format_and_key_slots()
{
// Contract test: the orchestrator Result exposes Format and RuntimeKey
// so the launcher can thread the runtime key into GameProcess.Launch.
var r = new UpdateOrchestrator.Result(true, LauncherState.UpToDate, null);
Assert.Null(r.Format);
Assert.Null(r.RuntimeKey);
}
[Fact]
public void Factory_falls_back_through_effective_format_on_empty_string()
{
// A manifest with format="" should resolve via EffectiveFormat -> legacy.
var m = new ManifestDto { Version = "v1", Format = "" };
var f = ReleaseFormatFactory.Resolve(m.EffectiveFormat);
Assert.IsType<LegacyJsonBlobFormat>(f);
}
}