From 5edd0c5aeaf9cfe9113d135808a462019ab6ab3f Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Tue, 14 Apr 2026 21:05:30 +0200 Subject: [PATCH 1/7] manifest: add optional format field for release dispatch introduces an optional top-level "format" field on the signed manifest, defaulting to legacy-json-blob when absent so existing installs keep parsing unchanged. follow-up commits wire the release format factory and the m2pack strategy against this value. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Metin2Launcher/Manifest/Manifest.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/Metin2Launcher/Manifest/Manifest.cs b/src/Metin2Launcher/Manifest/Manifest.cs index 955195e..4c19b28 100644 --- a/src/Metin2Launcher/Manifest/Manifest.cs +++ b/src/Metin2Launcher/Manifest/Manifest.cs @@ -10,6 +10,17 @@ namespace Metin2Launcher.Manifest; /// public sealed class Manifest { + [JsonPropertyName("format")] + public string? Format { get; set; } + + /// + /// Effective release format. Defaults to legacy-json-blob when the + /// field is absent, which preserves backward compatibility with manifests + /// produced before the m2pack migration. + /// + public string EffectiveFormat => + string.IsNullOrWhiteSpace(Format) ? "legacy-json-blob" : Format; + [JsonPropertyName("version")] public string Version { get; set; } = ""; From dcc2b0fc422d88e00dfa6f387ba6beee4b7a3768 Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Tue, 14 Apr 2026 21:05:37 +0200 Subject: [PATCH 2/7] runtime: add runtime-key model and delivery strategies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit adds RuntimeKey DTO parsed from runtime-key.json, an IRuntimeKeyDelivery strategy interface, and the env-var delivery used by the m2pack MVP (M2PACK_MASTER_KEY_HEX / M2PACK_SIGN_PUBKEY_HEX / M2PACK_KEY_ID). SharedMemoryKeyDelivery is a documented stub — will be wired once the client-side receiver lands. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Runtime/EnvVarKeyDelivery.cs | 30 ++++++++ .../Runtime/IRuntimeKeyDelivery.cs | 18 +++++ src/Metin2Launcher/Runtime/RuntimeKey.cs | 76 +++++++++++++++++++ .../Runtime/SharedMemoryKeyDelivery.cs | 39 ++++++++++ 4 files changed, 163 insertions(+) create mode 100644 src/Metin2Launcher/Runtime/EnvVarKeyDelivery.cs create mode 100644 src/Metin2Launcher/Runtime/IRuntimeKeyDelivery.cs create mode 100644 src/Metin2Launcher/Runtime/RuntimeKey.cs create mode 100644 src/Metin2Launcher/Runtime/SharedMemoryKeyDelivery.cs diff --git a/src/Metin2Launcher/Runtime/EnvVarKeyDelivery.cs b/src/Metin2Launcher/Runtime/EnvVarKeyDelivery.cs new file mode 100644 index 0000000..4034de7 --- /dev/null +++ b/src/Metin2Launcher/Runtime/EnvVarKeyDelivery.cs @@ -0,0 +1,30 @@ +using System.Diagnostics; + +namespace Metin2Launcher.Runtime; + +/// +/// 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 , not the launcher's +/// global process environment), so other processes on the machine never see them. +/// +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; + } +} diff --git a/src/Metin2Launcher/Runtime/IRuntimeKeyDelivery.cs b/src/Metin2Launcher/Runtime/IRuntimeKeyDelivery.cs new file mode 100644 index 0000000..bac6681 --- /dev/null +++ b/src/Metin2Launcher/Runtime/IRuntimeKeyDelivery.cs @@ -0,0 +1,18 @@ +using System.Diagnostics; + +namespace Metin2Launcher.Runtime; + +/// +/// Strategy for handing a to the game process. The +/// MVP implements a single strategy () — the +/// Windows shared-memory strategy is stubbed in SharedMemoryKeyDelivery +/// and will be wired once the client-side receiver lands. +/// +public interface IRuntimeKeyDelivery +{ + /// Name of the delivery mechanism, used for logs and tests. + string Name { get; } + + /// Mutates so the spawned process can pick up the key. + void Apply(ProcessStartInfo psi, RuntimeKey key); +} diff --git a/src/Metin2Launcher/Runtime/RuntimeKey.cs b/src/Metin2Launcher/Runtime/RuntimeKey.cs new file mode 100644 index 0000000..25a214d --- /dev/null +++ b/src/Metin2Launcher/Runtime/RuntimeKey.cs @@ -0,0 +1,76 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Metin2Launcher.Runtime; + +/// +/// 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 +/// 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): +/// +/// { +/// "key_id": "2026.04.14-1", +/// "master_key_hex": "<64 hex chars>", +/// "sign_pubkey_hex": "<64 hex chars>" +/// } +/// +/// +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, + }; + + /// Parses a runtime-key.json blob. Throws on malformed or incomplete data. + public static RuntimeKey Parse(ReadOnlySpan raw) + { + var rk = JsonSerializer.Deserialize(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; + } +} diff --git a/src/Metin2Launcher/Runtime/SharedMemoryKeyDelivery.cs b/src/Metin2Launcher/Runtime/SharedMemoryKeyDelivery.cs new file mode 100644 index 0000000..d4726dd --- /dev/null +++ b/src/Metin2Launcher/Runtime/SharedMemoryKeyDelivery.cs @@ -0,0 +1,39 @@ +using System.Diagnostics; + +namespace Metin2Launcher.Runtime; + +/// +/// 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 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 MemoryMappedFile with a random suffix +/// (e.g. m2pack-key-<guid>) and a NamedPipeServerStream-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 +/// OpenExisting 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 ClientAppliedReporter). +/// +/// Until then, calling 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. +/// +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."); + } +} From ee7edfd9906de2009c88148d5832c0c018a5a817 Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Tue, 14 Apr 2026 21:05:43 +0200 Subject: [PATCH 3/7] formats: add release format strategy interface and implementations adds IReleaseFormat with a legacy-json-blob implementation lifting the existing per-file update behaviour, and an m2pack implementation that loads runtime-key.json after apply. a central ReleaseFormatFactory maps Manifest.EffectiveFormat onto concrete strategies and throws on unknown values so a signed but unsupported format cannot silently downgrade. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Metin2Launcher/Formats/IReleaseFormat.cs | 52 ++++++++++++++++ .../Formats/LegacyJsonBlobFormat.cs | 27 +++++++++ src/Metin2Launcher/Formats/M2PackFormat.cs | 59 +++++++++++++++++++ .../Formats/ReleaseFormatFactory.cs | 26 ++++++++ 4 files changed, 164 insertions(+) create mode 100644 src/Metin2Launcher/Formats/IReleaseFormat.cs create mode 100644 src/Metin2Launcher/Formats/LegacyJsonBlobFormat.cs create mode 100644 src/Metin2Launcher/Formats/M2PackFormat.cs create mode 100644 src/Metin2Launcher/Formats/ReleaseFormatFactory.cs diff --git a/src/Metin2Launcher/Formats/IReleaseFormat.cs b/src/Metin2Launcher/Formats/IReleaseFormat.cs new file mode 100644 index 0000000..c1bc339 --- /dev/null +++ b/src/Metin2Launcher/Formats/IReleaseFormat.cs @@ -0,0 +1,52 @@ +using Metin2Launcher.Manifest; + +namespace Metin2Launcher.Formats; + +/// +/// Strategy that knows how to interpret the files array of a +/// signature-verified manifest. Added for the m2pack migration: +/// +/// - legacy-json-blob ships individual files that replace their +/// counterparts inside the client root one-for-one. +/// - m2pack ships .m2p pack archives and a runtime-key.json +/// 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 .m2p archive. +/// +/// Both implementations share the same download/apply plumbing in +/// . This interface only +/// captures the pieces that actually differ between the two formats. +/// +public interface IReleaseFormat +{ + /// Identifier matching . + string Name { get; } + + /// + /// Filters the manifest file list down to entries that should be + /// downloaded on the given platform. Both formats currently delegate + /// to ; the indirection + /// exists so a future format can special-case (e.g. exclude the + /// runtime key from platform filtering, or reject unknown kinds). + /// + IReadOnlyList FilterApplicable(Manifest.Manifest manifest, string platform); + + /// + /// 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 so the caller can forward it + /// to the game process. + /// + void OnApplied(string clientRoot, LoadedManifest manifest, ReleaseOutcome outcome); +} + +/// +/// Mutable bag of results emitted by . +/// Kept separate from the update orchestrator's own Result record so +/// format-specific data doesn't leak into the generic update flow. +/// +public sealed class ReleaseOutcome +{ + /// Populated by the m2pack format; null for legacy releases. + public Runtime.RuntimeKey? RuntimeKey { get; set; } +} diff --git a/src/Metin2Launcher/Formats/LegacyJsonBlobFormat.cs b/src/Metin2Launcher/Formats/LegacyJsonBlobFormat.cs new file mode 100644 index 0000000..c57000e --- /dev/null +++ b/src/Metin2Launcher/Formats/LegacyJsonBlobFormat.cs @@ -0,0 +1,27 @@ +using Metin2Launcher.Manifest; + +namespace Metin2Launcher.Formats; + +/// +/// The original release format: the manifest's files array lists +/// individual files (exe, dlls, packs, locale/<pre-m2pack>) 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. +/// +public sealed class LegacyJsonBlobFormat : IReleaseFormat +{ + public const string FormatName = "legacy-json-blob"; + + public string Name => FormatName; + + public IReadOnlyList 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. + } +} diff --git a/src/Metin2Launcher/Formats/M2PackFormat.cs b/src/Metin2Launcher/Formats/M2PackFormat.cs new file mode 100644 index 0000000..fbb0069 --- /dev/null +++ b/src/Metin2Launcher/Formats/M2PackFormat.cs @@ -0,0 +1,59 @@ +using Metin2Launcher.Logging; +using Metin2Launcher.Manifest; +using Metin2Launcher.Runtime; + +namespace Metin2Launcher.Formats; + +/// +/// m2pack release format. +/// +/// The manifest still has a flat files list, but the entries are now +/// content-addressed .m2p archives plus a runtime-key.json +/// sidecar. Signing continues to happen on the top-level manifest.json +/// — the .m2p 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. +/// +public sealed class M2PackFormat : IReleaseFormat +{ + public const string FormatName = "m2pack"; + + /// Conventional filename of the runtime key sidecar inside the client root. + public const string RuntimeKeyFileName = "runtime-key.json"; + + public string Name => FormatName; + + public IReadOnlyList 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; + } + } +} diff --git a/src/Metin2Launcher/Formats/ReleaseFormatFactory.cs b/src/Metin2Launcher/Formats/ReleaseFormatFactory.cs new file mode 100644 index 0000000..23b7302 --- /dev/null +++ b/src/Metin2Launcher/Formats/ReleaseFormatFactory.cs @@ -0,0 +1,26 @@ +namespace Metin2Launcher.Formats; + +/// +/// Resolves an for a given manifest. Lives +/// behind a factory so the +/// doesn't grow a switch statement and so tests can inject fakes. +/// +public static class ReleaseFormatFactory +{ + /// + /// 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 + /// format: "evil" value into a downgrade attack vector. + /// + 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"), + }; + } +} From 3d98ac4470f1f8055de161afe5e1e21916e147cd Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Tue, 14 Apr 2026 21:05:49 +0200 Subject: [PATCH 4/7] telemetry: add opt-in client-applied reporter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit adds ClientAppliedReporter that fires a single bounded (5s) best-effort POST after a successful update, carrying only release format and version. the launcher config exposes TelemetryUrlTemplate defaulted to empty string — when empty the reporter short-circuits and nothing goes over the network. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Metin2Launcher/Config/LauncherConfig.cs | 8 ++ .../Telemetry/ClientAppliedReporter.cs | 107 ++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 src/Metin2Launcher/Telemetry/ClientAppliedReporter.cs diff --git a/src/Metin2Launcher/Config/LauncherConfig.cs b/src/Metin2Launcher/Config/LauncherConfig.cs index 8125a43..2a407dc 100644 --- a/src/Metin2Launcher/Config/LauncherConfig.cs +++ b/src/Metin2Launcher/Config/LauncherConfig.cs @@ -30,6 +30,14 @@ public static class LauncherConfig public const int MaxBlobRetries = 3; + /// + /// 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 {format} and {version} placeholders, + /// both URL-escaped at send time. + /// + public const string TelemetryUrlTemplate = ""; + /// Name of the directory under the client root that holds launcher state. public const string StateDirName = ".updates"; diff --git a/src/Metin2Launcher/Telemetry/ClientAppliedReporter.cs b/src/Metin2Launcher/Telemetry/ClientAppliedReporter.cs new file mode 100644 index 0000000..45e1ffe --- /dev/null +++ b/src/Metin2Launcher/Telemetry/ClientAppliedReporter.cs @@ -0,0 +1,107 @@ +using System.Net.Http; +using System.Text; +using System.Text.Json; +using Metin2Launcher.Logging; + +namespace Metin2Launcher.Telemetry; + +/// +/// 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 +/// 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 +/// +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; + } + + /// True when the reporter is actually wired to a destination. + public bool IsEnabled => !string.IsNullOrWhiteSpace(_urlTemplate); + + /// + /// Sends a single POST to the configured URL. Returns true if the + /// server accepted it (2xx), false on any other outcome. Never throws. + /// + public async Task 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; + } + } + + /// + /// Expands {format} and {version} placeholders in the URL + /// template. Case-sensitive, by design — we want obvious failures if the + /// template is wrong. + /// + 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; } = ""; + } +} From 6ad8e8db19745aa8c06d10cbd55b9d5c8e27897a Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Tue, 14 Apr 2026 21:05:57 +0200 Subject: [PATCH 5/7] orchestration: dispatch through release format factory UpdateOrchestrator now resolves an IReleaseFormat from the verified manifest and uses it to filter applicable files, run the post-apply hook (which loads the m2pack runtime key when present) and drive the opt-in client-applied telemetry ping. GameProcess.BuildStartInfo accepts a RuntimeKey? and forwards it through EnvVarKeyDelivery onto the child ProcessStartInfo, scoped to the child environment only. Program.cs wires the reporter and threads the key from the update result into the game launch. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Metin2Launcher/GameLaunch/GameProcess.cs | 37 ++++++--- .../Orchestration/UpdateOrchestrator.cs | 80 ++++++++++++++++--- src/Metin2Launcher/Program.cs | 9 ++- 3 files changed, 104 insertions(+), 22 deletions(-) diff --git a/src/Metin2Launcher/GameLaunch/GameProcess.cs b/src/Metin2Launcher/GameLaunch/GameProcess.cs index ced668f..add6aac 100644 --- a/src/Metin2Launcher/GameLaunch/GameProcess.cs +++ b/src/Metin2Launcher/GameLaunch/GameProcess.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using Metin2Launcher.Logging; +using Metin2Launcher.Runtime; namespace Metin2Launcher.GameLaunch; @@ -17,7 +18,7 @@ namespace Metin2Launcher.GameLaunch; /// 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 wine) or is itself a Windows binary (which can exec directly). /// Public so tests can assert the platform branch without actually launching. /// - 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; } } diff --git a/src/Metin2Launcher/Orchestration/UpdateOrchestrator.cs b/src/Metin2Launcher/Orchestration/UpdateOrchestrator.cs index 8f004ad..57c3468 100644 --- a/src/Metin2Launcher/Orchestration/UpdateOrchestrator.cs +++ b/src/Metin2Launcher/Orchestration/UpdateOrchestrator.cs @@ -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 /// 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 RunAsync(IProgress 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); } } diff --git a/src/Metin2Launcher/Program.cs b/src/Metin2Launcher/Program.cs index 8c24632..8d53caf 100644 --- a/src/Metin2Launcher/Program.cs +++ b/src/Metin2Launcher/Program.cs @@ -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(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; } From 0e95171e50258f0ace7d391f223a1ead6e577cbb Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Tue, 14 Apr 2026 21:10:48 +0200 Subject: [PATCH 6/7] test: cover runtime key, release formats and telemetry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit adds ~60 new tests across RuntimeKey parsing, EnvVarKeyDelivery, the legacy and m2pack formats, ReleaseFormatFactory dispatch, manifest loader tolerance of unknown top-level fields, orchestrator wiring and the ClientAppliedReporter (disabled-by-default, success, 5xx, timeout, connection refused). The telemetry tests spin up an in-process HttpListener helper — no new NuGet dependency. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ClientAppliedReporterTests.cs | 124 +++++++++++++++++ .../EnvVarDeliveryTests.cs | 68 +++++++++ .../Metin2Launcher.Tests/GameProcessTests.cs | 43 ++++++ .../LegacyJsonBlobFormatTests.cs | 64 +++++++++ .../Metin2Launcher.Tests/M2PackFormatTests.cs | 131 ++++++++++++++++++ .../ReleaseFormatFactoryTests.cs | 86 ++++++++++++ tests/Metin2Launcher.Tests/RuntimeKeyTests.cs | 83 +++++++++++ .../Metin2Launcher.Tests/TestHttpListener.cs | 107 ++++++++++++++ .../UpdateOrchestratorFormatDispatchTests.cs | 112 +++++++++++++++ 9 files changed, 818 insertions(+) create mode 100644 tests/Metin2Launcher.Tests/ClientAppliedReporterTests.cs create mode 100644 tests/Metin2Launcher.Tests/EnvVarDeliveryTests.cs create mode 100644 tests/Metin2Launcher.Tests/LegacyJsonBlobFormatTests.cs create mode 100644 tests/Metin2Launcher.Tests/M2PackFormatTests.cs create mode 100644 tests/Metin2Launcher.Tests/ReleaseFormatFactoryTests.cs create mode 100644 tests/Metin2Launcher.Tests/RuntimeKeyTests.cs create mode 100644 tests/Metin2Launcher.Tests/TestHttpListener.cs create mode 100644 tests/Metin2Launcher.Tests/UpdateOrchestratorFormatDispatchTests.cs diff --git a/tests/Metin2Launcher.Tests/ClientAppliedReporterTests.cs b/tests/Metin2Launcher.Tests/ClientAppliedReporterTests.cs new file mode 100644 index 0000000..27961b9 --- /dev/null +++ b/tests/Metin2Launcher.Tests/ClientAppliedReporterTests.cs @@ -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); + } +} diff --git a/tests/Metin2Launcher.Tests/EnvVarDeliveryTests.cs b/tests/Metin2Launcher.Tests/EnvVarDeliveryTests.cs new file mode 100644 index 0000000..0900e45 --- /dev/null +++ b/tests/Metin2Launcher.Tests/EnvVarDeliveryTests.cs @@ -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(() => d.Apply(null!, SampleKey())); + Assert.Throws(() => d.Apply(new ProcessStartInfo(), null!)); + } + + [Fact] + public void SharedMemoryDelivery_is_stubbed() + { + var d = new SharedMemoryKeyDelivery(); + Assert.Equal("shared-memory", d.Name); + Assert.Throws(() => + d.Apply(new ProcessStartInfo(), SampleKey())); + } + + [Fact] + public void Delivery_name_is_env_var() + { + Assert.Equal("env-var", new EnvVarKeyDelivery().Name); + } +} diff --git a/tests/Metin2Launcher.Tests/GameProcessTests.cs b/tests/Metin2Launcher.Tests/GameProcessTests.cs index 9675e96..29bfe5d 100644 --- a/tests/Metin2Launcher.Tests/GameProcessTests.cs +++ b/tests/Metin2Launcher.Tests/GameProcessTests.cs @@ -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); + } } diff --git a/tests/Metin2Launcher.Tests/LegacyJsonBlobFormatTests.cs b/tests/Metin2Launcher.Tests/LegacyJsonBlobFormatTests.cs new file mode 100644 index 0000000..e4b5ac7 --- /dev/null +++ b/tests/Metin2Launcher.Tests/LegacyJsonBlobFormatTests.cs @@ -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(), Array.Empty(), SampleManifest()), + outcome); + Assert.Null(outcome.RuntimeKey); + } + finally { Directory.Delete(tmp, true); } + } +} diff --git a/tests/Metin2Launcher.Tests/M2PackFormatTests.cs b/tests/Metin2Launcher.Tests/M2PackFormatTests.cs new file mode 100644 index 0000000..1f37438 --- /dev/null +++ b/tests/Metin2Launcher.Tests/M2PackFormatTests.cs @@ -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(), Array.Empty(), 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(), Array.Empty(), 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(() => + new M2PackFormat().OnApplied( + tmp, + new LoadedManifest(Array.Empty(), Array.Empty(), 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(), Array.Empty(), SampleManifest()), + outcome); + Assert.NotNull(outcome.RuntimeKey); + } + finally { Directory.Delete(tmp, true); } + } +} diff --git a/tests/Metin2Launcher.Tests/ReleaseFormatFactoryTests.cs b/tests/Metin2Launcher.Tests/ReleaseFormatFactoryTests.cs new file mode 100644 index 0000000..c653628 --- /dev/null +++ b/tests/Metin2Launcher.Tests/ReleaseFormatFactoryTests.cs @@ -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(f); + } + + [Fact] + public void Resolves_m2pack() + { + var f = ReleaseFormatFactory.Resolve("m2pack"); + Assert.IsType(f); + } + + [Fact] + public void Throws_on_unknown_format() + { + Assert.Throws(() => + 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); + } +} diff --git a/tests/Metin2Launcher.Tests/RuntimeKeyTests.cs b/tests/Metin2Launcher.Tests/RuntimeKeyTests.cs new file mode 100644 index 0000000..bb7ef70 --- /dev/null +++ b/tests/Metin2Launcher.Tests/RuntimeKeyTests.cs @@ -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(() => 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(() => RuntimeKey.Parse(Encoding.UTF8.GetBytes(json))); + } + + [Fact] + public void Parse_rejects_bad_pubkey() + { + var json = SampleJson(pub: new string('g', 64)); + Assert.Throws(() => 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(() => + RuntimeKey.Parse(Encoding.UTF8.GetBytes("not json"))); + } +} diff --git a/tests/Metin2Launcher.Tests/TestHttpListener.cs b/tests/Metin2Launcher.Tests/TestHttpListener.cs new file mode 100644 index 0000000..0a67b47 --- /dev/null +++ b/tests/Metin2Launcher.Tests/TestHttpListener.cs @@ -0,0 +1,107 @@ +using System.Net; +using System.Text; + +namespace Metin2Launcher.Tests; + +/// +/// Minimal in-process HTTP server backed by for +/// telemetry / orchestrator tests. Dependency-free — no TestServer, no +/// WireMock, no new NuGet package. +/// +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? _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 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; } = ""; + } +} diff --git a/tests/Metin2Launcher.Tests/UpdateOrchestratorFormatDispatchTests.cs b/tests/Metin2Launcher.Tests/UpdateOrchestratorFormatDispatchTests.cs new file mode 100644 index 0000000..a1ea526 --- /dev/null +++ b/tests/Metin2Launcher.Tests/UpdateOrchestratorFormatDispatchTests.cs @@ -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; + +/// +/// Exercises the 's format-dispatch wiring. +/// +/// The orchestrator short-circuits to a signature exception when the fetched +/// manifest isn't signed by the hardcoded . +/// 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 . +/// +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(async () => + await orchestrator.RunAsync(new Progress(_ => { }), 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(_ => { }), + 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(f); + } +} From 1790502b58945c6bf48fcefd6b7d36156bd3ff79 Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Tue, 14 Apr 2026 21:12:08 +0200 Subject: [PATCH 7/7] docs: document m2pack launcher integration adds docs/m2pack-integration.md covering the signature boundary, runtime key env-var delivery, telemetry opt-in, backward compatibility and expected on-disk layout. README gains a short "Release formats" section pointing at the new doc, and CHANGELOG tracks the [Unreleased] entries. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 49 ++++++++++++++++ README.md | 24 ++++++++ docs/m2pack-integration.md | 114 +++++++++++++++++++++++++++++++++++++ 3 files changed, 187 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 docs/m2pack-integration.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3047983 --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 028f1cb..3f4b775 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/m2pack-integration.md b/docs/m2pack-integration.md new file mode 100644 index 0000000..3bdd6d2 --- /dev/null +++ b/docs/m2pack-integration.md @@ -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.