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:
52
src/Metin2Launcher/Formats/IReleaseFormat.cs
Normal file
52
src/Metin2Launcher/Formats/IReleaseFormat.cs
Normal 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; }
|
||||
}
|
||||
27
src/Metin2Launcher/Formats/LegacyJsonBlobFormat.cs
Normal file
27
src/Metin2Launcher/Formats/LegacyJsonBlobFormat.cs
Normal 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/<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.
|
||||
/// </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.
|
||||
}
|
||||
}
|
||||
59
src/Metin2Launcher/Formats/M2PackFormat.cs
Normal file
59
src/Metin2Launcher/Formats/M2PackFormat.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
26
src/Metin2Launcher/Formats/ReleaseFormatFactory.cs
Normal file
26
src/Metin2Launcher/Formats/ReleaseFormatFactory.cs
Normal 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"),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user