Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions GenHub/GenHub.Core/Constants/ManifestConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,17 @@ public static class ManifestConstants
/// </summary>
public const string DefaultManifestVersion = "1";

/// <summary>
/// Manifest format version that introduces artifact variants.
/// </summary>
/// <remarks>
/// Bumped from <see cref="DefaultManifestFormatVersion"/> so that a manifest using
/// variants is identifiable as such rather than presenting as a version 1 manifest
/// with an unexpected field. Ingestion rejects this version for now — see
/// <see cref="Models.Manifest.ManifestIngestionGate"/>.
/// </remarks>
public const int VariantsManifestFormatVersion = 2;

/// <summary>
/// Prefix for publisher content IDs.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
namespace GenHub.Core.Interfaces.Workspace;

/// <summary>
/// Reports whether this process can create symbolic links.
/// <para>
/// Previously the launcher asked "is this process an administrator", computed it only on
/// Windows, and left it <c>false</c> everywhere else. It then downgraded the
/// <c>SymlinkOnly</c> and <c>HybridCopySymlink</c> strategies to <c>HardLink</c> whenever
/// the answer was false, which made both strategies permanently unreachable on Linux and
/// macOS — where <c>symlink(2)</c> needs no privilege at all. Users could select a
/// strategy in Settings that silently never applied.
/// </para>
/// <para>
/// Naming the capability rather than the privilege makes the platform answer obvious:
/// Windows genuinely gates symlink creation behind <c>SeCreateSymbolicLinkPrivilege</c>
/// (or Developer Mode); Unix does not gate it at all.
/// </para>
/// </summary>
public interface ISymlinkCapabilityProvider
{
/// <summary>
/// Gets a value indicating whether this process can create symbolic links.
/// </summary>
bool CanCreateSymlinks { get; }
}
77 changes: 77 additions & 0 deletions GenHub/GenHub.Core/Models/Manifest/ArtifactVariant.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;

namespace GenHub.Core.Models.Manifest;

/// <summary>
/// A platform-specific build within a single content release.
/// <para>
/// One release of a game client can produce several builds — win-x64, linux-x64 and
/// osx-arm64 — that share a version and a name but not a file list or an entry point.
/// A single flat file list cannot describe that: the same manifest would advertise a
/// Windows executable to a macOS host, which installs cleanly and then cannot run.
/// </para>
/// <para>
/// Variants are optional. A manifest with no variants is a single unconstrained build
/// described by <see cref="ContentManifest.Files"/>, which is what every manifest
/// written before this type existed looks like.
/// </para>
/// </summary>
public class ArtifactVariant
{
/// <summary>
/// Gets or sets the runtime identifiers this variant can run on, for example
/// <c>osx-arm64</c> or <c>win-x64</c>.
/// <para>
/// Architecture matters, not just the operating system: an x64 build is not
/// interchangeable with an arm64 one, and a macOS user on Apple Silicon offered an
/// <c>osx-x64</c> build gets a launch that fails in the loader.
/// </para>
/// <para>
/// An empty list means the variant is platform-neutral, which is correct for map
/// packs, INI tweaks and <c>.big</c> content that contains no native code.
/// </para>
/// </summary>
[JsonPropertyName("runtimeIdentifiers")]
public List<string> RuntimeIdentifiers { get; set; } = [];

/// <summary>
/// Gets or sets the relative path of the file to launch for this variant.
/// <para>
/// Declared rather than inferred. Inferring it from file extensions is ambiguous
/// the moment a variant ships more than one runnable file, and the result then
/// depends on file enumeration order.
/// </para>
/// </summary>
[JsonPropertyName("entryPoint")]
public string? EntryPoint { get; set; }

/// <summary>
/// Gets or sets the files belonging to this variant.
/// </summary>
[JsonPropertyName("files")]
public List<ManifestFile> Files { get; set; } = [];

/// <summary>
/// Determines whether this variant can run on the given runtime identifier.
/// </summary>
/// <param name="runtimeIdentifier">The host runtime identifier, for example <c>osx-arm64</c>.</param>
/// <returns><c>true</c> when the variant is platform-neutral or explicitly targets the runtime.</returns>
public bool SupportsRuntime(string runtimeIdentifier)
{
if (RuntimeIdentifiers.Count == 0)
{
return true;
}

foreach (var candidate in RuntimeIdentifiers)
{
if (string.Equals(candidate, runtimeIdentifier, System.StringComparison.OrdinalIgnoreCase))
{
return true;
}
}

return false;
}
}
44 changes: 42 additions & 2 deletions GenHub/GenHub.Core/Models/Manifest/ContentManifest.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
using GenHub.Core.Constants;
using GenHub.Core.Models.Enums;
using System.Text.Json.Serialization;

namespace GenHub.Core.Models.Manifest;

Expand All @@ -10,6 +11,8 @@ namespace GenHub.Core.Models.Manifest;
/// </summary>
public class ContentManifest
{
private List<ArtifactVariant> _variants = [];

/// <summary>Gets or sets the manifest format version.</summary>
public string ManifestVersion { get; set; } = ManifestConstants.DefaultManifestVersion;

Expand Down Expand Up @@ -61,9 +64,46 @@ public class ContentManifest
/// <summary>Gets or sets the list of known addons for this game (manifest-driven, not hardcoded).</summary>
public List<string> KnownAddons { get; set; } = [];

/// <summary>Gets or sets all files included in this content package.</summary>
/// <summary>
/// Gets or sets all files included in this content package.
/// <para>
/// This describes the single, unconstrained build. When <see cref="Variants"/> is
/// non-empty this list is ignored in favour of the matching variant. Consumers
/// should resolve through <c>ManifestVariantResolver</c> rather than reading this
/// directly, so that multi-platform manifests behave correctly.
/// </para>
/// </summary>
public List<ManifestFile> Files { get; set; } = [];

/// <summary>
/// Gets or sets platform-specific builds of this content.
/// <para>
/// Optional and empty by default, so every manifest written before variants existed
/// keeps working unchanged: an empty list means "<see cref="Files"/> is the only
/// build". Populate it when one release ships several platform builds that share a
/// version but differ in file list or entry point.
/// </para>
/// </summary>
public List<ArtifactVariant> Variants
{
get => _variants;
set => _variants = value ?? [];
}

/// <summary>
/// Gets or sets the relative path of the file to launch, for single-variant content.
/// <para>
/// Declared rather than inferred from file extensions. Without it, resolution falls
/// back to guessing from the file list, which is ambiguous as soon as more than one
/// file qualifies and then depends on enumeration order.
/// </para>
/// <para>
/// When <see cref="Variants"/> is populated, each variant carries its own entry
/// point and this is ignored.
/// </para>
/// </summary>
public string? EntryPoint { get; set; }

/// <summary>Gets or sets the required directory structure.</summary>
public List<string> RequiredDirectories { get; set; } = [];

Expand Down
66 changes: 66 additions & 0 deletions GenHub/GenHub.Core/Models/Manifest/EntryPointResolution.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
using System.Collections.Generic;
using System.Linq;

namespace GenHub.Core.Models.Manifest;

/// <summary>
/// The outcome of resolving which file a manifest launches.
/// <para>
/// Failure carries the candidates that were considered. Resolution failing silently, or
/// failing with only "no executable found", leaves whoever hits it guessing at what the
/// manifest actually contained.
/// </para>
/// </summary>
public sealed class EntryPointResolution
{
private EntryPointResolution(string? relativePath, string reason, IReadOnlyList<string> candidates)
{
RelativePath = relativePath;
Reason = reason;
Candidates = candidates;
}

/// <summary>Gets a value indicating whether an entry point was determined.</summary>
public bool Success => RelativePath is not null;

/// <summary>Gets the resolved relative path, or <c>null</c> when resolution failed.</summary>
public string? RelativePath { get; }

/// <summary>
/// Gets a human-readable explanation: on success, how the entry point was chosen; on
/// failure, why it could not be.
/// </summary>
public string Reason { get; }

/// <summary>Gets the file paths considered, for diagnosing a failure.</summary>
public IReadOnlyList<string> Candidates { get; }

/// <summary>
/// Creates a successful resolution.
/// </summary>
/// <param name="relativePath">The resolved entry point.</param>
/// <param name="reason">How it was chosen.</param>
/// <returns>A successful resolution.</returns>
public static EntryPointResolution Resolved(string relativePath, string reason) =>
new(relativePath, reason, []);

/// <summary>
/// Creates a failed resolution.
/// </summary>
/// <param name="reason">Why resolution failed.</param>
/// <param name="candidates">The files that were considered.</param>
/// <returns>A failed resolution.</returns>
public static EntryPointResolution Failed(string reason, IEnumerable<ManifestFile> candidates) =>
new(null, reason, candidates.Select(f => f.RelativePath).ToList());

/// <summary>
/// Builds a log-ready description including the candidates considered.
/// </summary>
/// <returns>A diagnostic string.</returns>
public override string ToString() =>
Success
? $"{RelativePath} ({Reason})"
: Candidates.Count == 0
? Reason
: $"{Reason} Candidates: {string.Join(", ", Candidates)}";
}
73 changes: 73 additions & 0 deletions GenHub/GenHub.Core/Models/Manifest/ManifestIngestionGate.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
using System.Globalization;
using GenHub.Core.Constants;

namespace GenHub.Core.Models.Manifest;

/// <summary>
/// Fail-closed gate for manifests that declare artifact variants.
/// </summary>
/// <remarks>
/// Variants are expressed by <see cref="ContentManifest.Variants"/> and resolved by
/// <see cref="ManifestVariantResolver"/>, but the consumers that act on a manifest —
/// deliverers, validators, CAS reference counting and garbage collection — still read
/// <see cref="ContentManifest.Files"/> directly. A manifest declaring variants would be
/// accepted and then mishandled by every one of them: <c>Files</c> is empty for a
/// variant manifest, so content would appear to install successfully while delivering
/// nothing, and reference counting would record the wrong set of blobs.
/// <para>
/// Rejecting at ingestion is therefore deliberate and temporary. It is the only
/// protection until the consumers are migrated to the resolved-variant model, and it
/// should be removed as part of that migration rather than relaxed piecemeal.
/// </para>
/// </remarks>
public static class ManifestIngestionGate
{
/// <summary>
/// Determines whether a manifest may be ingested.
/// </summary>
/// <param name="manifest">The manifest to check.</param>
/// <param name="rejectionReason">
/// When the manifest is rejected, a message naming the manifest and the reason;
/// otherwise <c>null</c>.
/// </param>
/// <returns><c>true</c> when the manifest may be ingested; otherwise <c>false</c>.</returns>
public static bool TryAccept(ContentManifest? manifest, out string? rejectionReason)
{
rejectionReason = null;

if (manifest is null)
{
return true;
}

// Checked independently rather than as one condition. Declared version alone is
// not trustworthy — a manifest can carry variants while still claiming version 1 —
// and variants alone are not the only signal, since a format-2 manifest may use
// other version-2 features this pipeline equally cannot handle.
var declaresVariants = manifest.Variants.Count > 0;
var declaresVariantFormat =
int.TryParse(
manifest.ManifestVersion,
NumberStyles.None,
CultureInfo.InvariantCulture,
out var declaredFormat)
&& declaredFormat >= ManifestConstants.VariantsManifestFormatVersion;

if (!declaresVariants && !declaresVariantFormat)
{
return true;
}

var cause = declaresVariants
? $"declares {manifest.Variants.Count} artifact variant(s)"
: $"declares manifest format version {manifest.ManifestVersion}";

rejectionReason =
$"Manifest '{manifest.Id.Value}' {cause}, which requires manifest format version " +
$"{ManifestConstants.VariantsManifestFormatVersion} and is not yet accepted. " +
"Variant manifests are rejected until the content pipeline is migrated to the " +
"resolved-variant model; publish a manifest without variants in the meantime.";

return false;
}
}
Loading
Loading