Skip to content
5 changes: 5 additions & 0 deletions GenHub/GenHub.Core/Constants/FileTypes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,4 +84,9 @@ public static class FileTypes
/// File extension for user data manifest files.
/// </summary>
public const string UserDataManifestExtension = ".userdata.json";

/// <summary>
/// File name of the launch receipt written into a workspace; the latest launch wins.
/// </summary>
public const string LaunchReceiptFileName = "launch-receipt.json";
}
48 changes: 48 additions & 0 deletions GenHub/GenHub.Core/Interfaces/Launching/ILaunchReceiptService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using GenHub.Core.Models.Launching;
using GenHub.Core.Models.Results;

namespace GenHub.Core.Interfaces.Launching;

/// <summary>
/// Records a receipt of what each launch consisted of and cheaply revalidates it before
/// subsequent launches so drift is detected without a full re-scan.
/// </summary>
public interface ILaunchReceiptService
{
/// <summary>
/// Records a receipt for a launch into the workspace directory, replacing any previous one.
/// </summary>
/// <param name="context">What the launch consisted of.</param>
/// <param name="cancellationToken">A cancellation token to observe while waiting for the task to complete.</param>
/// <returns>The recorded receipt, or a failure that must not block the launch.</returns>
Task<OperationResult<LaunchReceipt>> RecordLaunchAsync(LaunchReceiptContext context, CancellationToken cancellationToken = default);

/// <summary>
/// Cheaply compares the receipt in a workspace, if one exists, against the current
/// on-disk state. Only existence, counts, sizes and timestamps are recomputed; nothing
/// is hashed.
/// </summary>
/// <param name="workspacePath">The workspace directory the receipt would live in.</param>
/// <param name="cancellationToken">A cancellation token to observe while waiting for the task to complete.</param>
/// <returns>A drift report; an absent receipt yields an empty report, not a failure.</returns>
Task<OperationResult<LaunchReceiptDriftReport>> RevalidateAsync(string workspacePath, CancellationToken cancellationToken = default);

/// <summary>
/// Compares an upcoming launch's configuration against a previously recorded receipt:
/// game client, game type, executable path, manifest set and versions, and the archive
/// root paths about to be configured — the configuration itself, where
/// <see cref="RevalidateAsync"/> checks what is on disk. Touches no filesystem state.
/// </summary>
/// <remarks>
/// A separate step because the two halves are known at different times: the receipt must
/// be read before workspace preparation rebuilds the workspace, while the upcoming
/// configuration — the resolved executable path in particular — exists only afterwards.
/// Profile identity is deliberately not compared: the receipt lives in the workspace and
/// the workspace is per-profile, so a mismatch cannot occur without the receipt being a
/// different file.
/// </remarks>
/// <param name="receipt">The receipt from the previous launch.</param>
/// <param name="upcoming">The configuration of the launch about to happen.</param>
/// <returns>A drift report naming each configuration field that changed.</returns>
LaunchReceiptDriftReport CompareUpcomingLaunch(LaunchReceipt receipt, LaunchReceiptContext upcoming);
}
7 changes: 7 additions & 0 deletions GenHub/GenHub.Core/Models/GameProfile/GameLaunchInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ public class GameLaunchInfo
/// <summary>Gets or sets the termination timestamp.</summary>
public DateTime? TerminatedAt { get; set; }

/// <summary>
/// Gets or sets the launch receipt drift detected before this launch, one warning per
/// drifted field, so a UI can show it. Informational only — drift never blocks a
/// launch — and empty when no receipt existed or nothing drifted.
/// </summary>
public List<string> ReceiptDriftWarnings { get; set; } = [];

/// <summary>Gets a value indicating whether the game is still running.</summary>
public bool IsRunning => TerminatedAt == null;
}
87 changes: 87 additions & 0 deletions GenHub/GenHub.Core/Models/Launching/LaunchReceipt.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
using GenHub.Core.Models.Enums;

namespace GenHub.Core.Models.Launching;

/// <summary>
/// Record of what a launch consisted of, written into the workspace so subsequent launches
/// can cheaply detect drift and misbehaving launches have something to compare against.
/// </summary>
public class LaunchReceipt
{
/// <summary>Gets or sets the receipt schema version.</summary>
public int SchemaVersion { get; set; } = 1;

/// <summary>Gets or sets when the receipt was recorded, in UTC.</summary>
public DateTime RecordedAtUtc { get; set; }

/// <summary>Gets or sets the launch identifier the receipt belongs to.</summary>
public string LaunchId { get; set; } = string.Empty;

/// <summary>Gets or sets the profile that was launched.</summary>
public string ProfileId { get; set; } = string.Empty;

/// <summary>Gets or sets the game client identifier, when the profile declared one.</summary>
public string? GameClientId { get; set; }

/// <summary>Gets or sets the game that was launched.</summary>
public GameType GameType { get; set; }

/// <summary>Gets or sets the workspace the launch ran from.</summary>
public string WorkspaceId { get; set; } = string.Empty;

/// <summary>Gets or sets the working directory the process was started in.</summary>
public string WorkingDirectory { get; set; } = string.Empty;

/// <summary>Gets or sets the fingerprint of the launched executable.</summary>
public LaunchReceiptExecutable Executable { get; set; } = new();

/// <summary>
/// Gets or sets the retail archive roots the engine was pointed at, keyed by the
/// environment variable that carried each root.
/// </summary>
public Dictionary<string, LaunchReceiptArchiveRoot> ArchiveRoots { get; set; } = [];

/// <summary>
/// Gets or sets a hash per environment variable GenHub itself set for the child process:
/// the built launch environment — retail archive roots plus any profile-defined variables.
/// The inherited process environment is deliberately not recorded; it is large, differs
/// between hosts without meaning anything for the launch, and can carry secrets that a
/// receipt on disk must never capture.
/// </summary>
/// <remarks>
/// Values are hashed rather than stored, because a profile-defined variable can itself
/// carry a secret and detecting drift only needs to know that a value changed, not what
/// it changed to. Archive root paths are exempt and recorded in full under
/// <see cref="ArchiveRoots"/>: they are locations, not credentials, and naming them is
/// what makes a misconfigured root actionable.
/// </remarks>
public Dictionary<string, string> EnvironmentVariableHashes { get; set; } = [];

/// <summary>
/// Gets or sets the random salt the environment value hashes were computed with, so the
/// same value hashes differently in every receipt.
/// </summary>
/// <remarks>
/// Comparison always runs against the receipt that carries the salt, so drift detection is
/// unaffected. This does not defeat an attacker who holds the receipt and guesses likely
/// values — they hold the salt too — but it does stop precomputed tables, and it stops
/// receipts being compared across hosts or profiles to confirm that two installations share
/// a value without ever recovering it.
/// </remarks>
public string EnvironmentHashSalt { get; set; } = string.Empty;

/// <summary>
/// Gets or sets the resolved variant and entry-point identity that determined what was
/// launched. Null when the profile carried no game client manifest: the legacy fallback
/// resolves the executable by filename search and no variant machinery participates, so
/// there is no variant identity to record. Populated whenever a game client manifest is
/// part of the launch, which is what workspace preparation resolves the entry point from.
/// </summary>
public LaunchReceiptVariant? Variant { get; set; }

/// <summary>Gets or sets the manifest identifiers resolved for the launch.</summary>
public List<string> ManifestIds { get; set; } = [];

/// <summary>Gets or sets the manifest versions resolved for the launch, keyed by manifest identifier.</summary>
public Dictionary<string, string> ManifestVersions { get; set; } = [];
}
18 changes: 18 additions & 0 deletions GenHub/GenHub.Core/Models/Launching/LaunchReceiptArchiveEntry.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace GenHub.Core.Models.Launching;

/// <summary>
/// Cheap fingerprint of one archive within a retail root: name, size and timestamp, never
/// content. An equal-size replacement is visible through the timestamp where a count and
/// byte total alone could not see it.
/// </summary>
public class LaunchReceiptArchiveEntry
{
/// <summary>Gets or sets the archive file name, without its directory.</summary>
public string FileName { get; set; } = string.Empty;

/// <summary>Gets or sets the archive size in bytes.</summary>
public long SizeBytes { get; set; }

/// <summary>Gets or sets the archive's last write time, in UTC.</summary>
public DateTime LastWriteUtc { get; set; }
}
21 changes: 21 additions & 0 deletions GenHub/GenHub.Core/Models/Launching/LaunchReceiptArchiveRoot.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
namespace GenHub.Core.Models.Launching;

/// <summary>
/// Cheap fingerprint of one retail archive root: a per-archive list of name, size and
/// timestamp from a single directory listing, never content hashes, so revalidation never
/// rereads gigabytes of archives.
/// </summary>
public class LaunchReceiptArchiveRoot
{
/// <summary>Gets or sets the archive root path.</summary>
public string Path { get; set; } = string.Empty;

/// <summary>Gets or sets the number of archives in the root; a summary of <see cref="Archives"/>.</summary>
public int ArchiveCount { get; set; }

/// <summary>Gets or sets the total size of the archives in the root, in bytes; a summary of <see cref="Archives"/>.</summary>
public long TotalArchiveBytes { get; set; }

/// <summary>Gets or sets the fingerprint of each archive in the root.</summary>
public List<LaunchReceiptArchiveEntry> Archives { get; set; } = [];
}
51 changes: 51 additions & 0 deletions GenHub/GenHub.Core/Models/Launching/LaunchReceiptContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
using GenHub.Core.Models.Enums;

namespace GenHub.Core.Models.Launching;

/// <summary>
/// Everything a launch supplies for a receipt to be recorded from.
/// </summary>
public class LaunchReceiptContext
{
/// <summary>Gets or sets the launch identifier.</summary>
public string LaunchId { get; set; } = string.Empty;

/// <summary>Gets or sets the profile being launched.</summary>
public string ProfileId { get; set; } = string.Empty;

/// <summary>Gets or sets the game client identifier, when the profile declares one.</summary>
public string? GameClientId { get; set; }

/// <summary>Gets or sets the game being launched.</summary>
public GameType GameType { get; set; }

/// <summary>Gets or sets the workspace the launch runs from.</summary>
public string WorkspaceId { get; set; } = string.Empty;

/// <summary>Gets or sets the workspace directory the receipt is written into.</summary>
public string WorkspacePath { get; set; } = string.Empty;

/// <summary>Gets or sets the executable being started.</summary>
public string ExecutablePath { get; set; } = string.Empty;

/// <summary>Gets or sets the working directory the process is started in.</summary>
public string WorkingDirectory { get; set; } = string.Empty;

/// <summary>
/// Gets or sets the environment GenHub built for the child process — retail archive
/// roots plus profile-defined variables, never the inherited process environment.
/// </summary>
public IReadOnlyDictionary<string, string> EnvironmentVariables { get; set; } = new Dictionary<string, string>();

/// <summary>
/// Gets or sets the resolved variant and entry-point identity, when a game client
/// manifest is part of the launch.
/// </summary>
public LaunchReceiptVariant? Variant { get; set; }

/// <summary>Gets or sets the manifest identifiers resolved for the launch.</summary>
public IReadOnlyList<string> ManifestIds { get; set; } = [];

/// <summary>Gets or sets the manifest versions resolved for the launch, keyed by manifest identifier.</summary>
public IReadOnlyDictionary<string, string> ManifestVersions { get; set; } = new Dictionary<string, string>();
}
29 changes: 29 additions & 0 deletions GenHub/GenHub.Core/Models/Launching/LaunchReceiptDriftReport.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
namespace GenHub.Core.Models.Launching;

/// <summary>
/// Outcome of cheaply revalidating a launch receipt against the current on-disk state.
/// </summary>
public class LaunchReceiptDriftReport
{
/// <summary>Gets or sets the path the receipt was looked for at.</summary>
public string ReceiptPath { get; set; } = string.Empty;

/// <summary>
/// Gets or sets a value indicating whether a receipt was present. An absent receipt is
/// not an error; there is simply nothing to compare against.
/// </summary>
public bool HasReceipt { get; set; }

/// <summary>
/// Gets or sets the parsed receipt when one was present and readable, so the upcoming
/// launch's configuration can be compared against it after the workspace — and the
/// receipt file with it — has been rebuilt.
/// </summary>
public LaunchReceipt? Receipt { get; set; }

/// <summary>Gets or sets the description of each field that drifted since the receipt was recorded.</summary>
public List<string> DriftedFields { get; set; } = [];

/// <summary>Gets a value indicating whether any drift was detected.</summary>
public bool HasDrift => DriftedFields.Count > 0;
}
19 changes: 19 additions & 0 deletions GenHub/GenHub.Core/Models/Launching/LaunchReceiptExecutable.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace GenHub.Core.Models.Launching;

/// <summary>
/// Fingerprint of the executable a launch started.
/// </summary>
public class LaunchReceiptExecutable
{
/// <summary>Gets or sets the executable path.</summary>
public string Path { get; set; } = string.Empty;

/// <summary>Gets or sets the executable size in bytes.</summary>
public long SizeBytes { get; set; }

/// <summary>Gets or sets the executable's last write time, in UTC.</summary>
public DateTime LastWriteUtc { get; set; }

/// <summary>Gets or sets the SHA-256 hash of the executable as a lowercase hex string.</summary>
public string Sha256 { get; set; } = string.Empty;
}
30 changes: 30 additions & 0 deletions GenHub/GenHub.Core/Models/Launching/LaunchReceiptVariant.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
namespace GenHub.Core.Models.Launching;

/// <summary>
/// The resolved variant and entry-point identity that determined what a launch started:
/// the same <c>ManifestVariantResolver</c> resolution workspace preparation applies to the
/// game client manifest, re-run against the same manifest and host runtime at receipt time.
/// </summary>
public class LaunchReceiptVariant
{
/// <summary>Gets or sets the game client manifest the resolution ran against.</summary>
public string GameClientManifestId { get; set; } = string.Empty;

/// <summary>Gets or sets the host runtime identifier the resolution ran on, for example <c>osx-arm64</c>.</summary>
public string RuntimeIdentifier { get; set; } = string.Empty;

/// <summary>Gets or sets a value indicating whether the manifest declares variants at all.</summary>
public bool HasVariants { get; set; }

/// <summary>
/// Gets or sets the runtime identifiers of the variant that matched; empty when the
/// matched variant is platform-neutral or the manifest declares no variants.
/// </summary>
public List<string> VariantRuntimeIdentifiers { get; set; } = [];

/// <summary>Gets or sets the resolved entry point, relative to the workspace, when resolution succeeded.</summary>
public string? EntryPointRelativePath { get; set; }

/// <summary>Gets or sets the resolver's stated reason for the outcome.</summary>
public string? Resolution { get; set; }
}
Loading
Loading