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) <noreply@anthropic.com>
This commit is contained in:
Jan Nedbal
2026-04-14 21:05:43 +02:00
parent dcc2b0fc42
commit ee7edfd990
4 changed files with 164 additions and 0 deletions

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