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"), + }; + } +}