Files
wiicompiled/Launcher/WiiCompiled.Setup/ToolkitFingerprint.cs
T
2026-08-29 12:06:58 -04:00

287 lines
15 KiB
C#

using System.Security.Cryptography;
using System.Text;
namespace WiiCompiled.Setup;
/// <summary>
/// Content identity of everything that decides what the locally produced executables contain.
/// First link of toolkit content -&gt; toolkit-state.json -&gt; per-product build-fingerprint.json:
/// unchanged skips retranslation, any change forces a rebuild. Content-based so re-tags are free.
/// </summary>
internal static class ToolkitFingerprint
{
// v6/native-toolchain v2 forces one clean rebuild past a Ninja stale-object bug.
// translation v2 added runtime/src, which the translator regex-scans for native overrides.
private const string Version = "mkwc-toolkit-compile-v6";
private const string TranslationVersion = "mkwc-toolkit-translation-v2";
private const string NativeToolchainVersion = "mkwc-toolkit-native-toolchain-v2";
private const string PackageVersion = "mkwc-toolkit-package-v1";
private const string RuntimeAssetsVersion = "mkwc-product-runtime-assets-v3";
private const string RuntimeAssetsDescription = "The product runtime assets";
private static readonly string[] SourceExtensions =
[".c", ".cc", ".cpp", ".cxx", ".h", ".hh", ".hpp", ".inl", ".s", ".asm",
".cmake", ".txt", ".yml", ".yaml", ".toml", ".json", ".ps1", ".in", ".def", ".patch"];
/// <summary>
/// Inputs that decide translator output: translator, translation project, runtime sources it
/// scans for native override registrations/effect contracts, and the build script's translator
/// command lines. Unchanged means the completed base translation can be reused instead of re-run.
/// </summary>
private static readonly string[] TranslationPrefixes =
[
InstalledLayout.ToolkitEntryPrefix + "Translator/",
InstalledLayout.WorkspaceEntryPrefix + "projects/",
InstalledLayout.WorkspaceEntryPrefix + "runtime/src/"
];
private static readonly string[] TranslationFiles =
[InstalledLayout.WorkspaceEntryPrefix + "LocalBuild.ps1"];
/// <summary>
/// Inputs that make compiled objects unsafe to reuse: the compiler/CMake/Ninja toolchain, the
/// shipped runtime DLLs, and the scripts owning configure flags. Source/dependency changes are
/// deliberately excluded, since CMake/Ninja already rebuild exactly the affected objects.
/// </summary>
private static readonly string[] NativeToolchainPrefixes =
[
InstalledLayout.ToolkitEntryPrefix + "llvm-mingw/",
InstalledLayout.ToolkitEntryPrefix + "CMake/",
InstalledLayout.ToolkitEntryPrefix + "Ninja/",
InstalledLayout.ToolkitEntryPrefix + "Redist/"
];
private static readonly string[] NativeToolchainFiles =
[
InstalledLayout.WorkspaceEntryPrefix + "LocalBuild.ps1",
InstalledLayout.WorkspaceEntryPrefix + "NativeBuildFlags.ps1"
];
/// <summary>
/// Fingerprint of the toolkit at <paramref name="root"/>: the staged payload root at
/// release-build time, or an install directory when a compile path re-verifies it. The install
/// path itself never computes this, it compares manifest identity against toolkit-state.json.
/// </summary>
public static string Compute(string root, CancellationToken cancellationToken = default) =>
ComputeComponents(root, cancellationToken).Compile;
/// <summary>
/// One walk, three identities: the compile identity plus its translation and native-toolchain
/// subsets. The subsets never gate correctness, compile identity alone decides if products are
/// current, they only decide how much completed work carries into the next build.
/// </summary>
public static ToolkitFingerprintComponents ComputeComponents(string root,
CancellationToken cancellationToken = default)
{
root = Path.GetFullPath(root);
var toolkit = InstalledLayout.Toolkit(root);
var workspace = InstalledLayout.Workspace(root);
var entries = new SortedDictionary<string, string>(StringComparer.Ordinal);
// nodtool validates/extracts the user disc but does not influence generated products.
// Everything else in Toolkit can affect translation, compilation, linking, or copied
// runtime support and therefore belongs to the compile identity.
AddDirectory(entries, root, toolkit, null,
cancellationToken,
file => !Path.GetFileName(file).Equals("nodtool.exe", StringComparison.OrdinalIgnoreCase));
AddFile(entries, root, Path.Combine(workspace, "LocalBuild.ps1"), cancellationToken);
AddFile(entries, root, Path.Combine(workspace, "NativeBuildFlags.ps1"), cancellationToken);
AddDirectory(entries, root, Path.Combine(workspace, "projects"), null, cancellationToken);
var runtime = Path.Combine(workspace, "runtime");
var runtimeAssets = Path.Combine(runtime, "assets");
// runtime/assets is copied beside the executable; it is deliberately tracked by the
// independent runtime-assets identity below so changing those bytes does not recompile code.
AddDirectory(entries, root, runtime, SourceExtensions, cancellationToken,
file => !FileSystemUtilities.PathContains(runtimeAssets, file));
AddDirectory(entries, root, Path.Combine(workspace, "aurora-main"), SourceExtensions,
cancellationToken);
// The shipped precompiled aurora archives and the pinned Dawn runtime are linked into the
// product, so a change there changes the binary just as surely as a translator change does.
// native_prebuilt's provenance.json is excluded: its Contents list already identifies every
// shipped byte, while its BuiltUtc stamp would make a bit-identical re-harvest look like a
// toolkit change and force a global user-side rebuild for nothing.
AddDirectory(entries, root, Path.Combine(workspace, "Dependencies"), null, cancellationToken,
file => !file.EndsWith(
Path.Combine("native_prebuilt", "provenance.json"), StringComparison.OrdinalIgnoreCase));
if (entries.Count == 0)
throw new InvalidDataException($"No toolkit files were found under {root}; the installation is incomplete.");
return new ToolkitFingerprintComponents(
BuildIdentity(Version, entries),
BuildSubsetIdentity(TranslationVersion, entries, TranslationPrefixes, TranslationFiles),
BuildSubsetIdentity(NativeToolchainVersion, entries, NativeToolchainPrefixes,
NativeToolchainFiles));
}
private static string BuildSubsetIdentity(string version, SortedDictionary<string, string> entries,
string[] prefixes, string[] files)
{
var subset = new SortedDictionary<string, string>(StringComparer.Ordinal);
foreach (var (relative, hash) in entries)
{
if (prefixes.Any(prefix => relative.StartsWith(prefix, StringComparison.Ordinal)) ||
files.Any(file => relative.Equals(file, StringComparison.Ordinal)))
subset[relative] = hash;
}
return BuildIdentity(version, subset);
}
/// <summary>
/// Content identity of the source-owned files copied verbatim beside every product. This is
/// intentionally independent of <see cref="Compute"/>: an asset-only release is published
/// transactionally without translating or compiling unchanged code.
/// </summary>
public static string ComputeRuntimeAssets(string root, CancellationToken cancellationToken = default)
{
root = Path.GetFullPath(root);
var assets = Path.Combine(InstalledLayout.Workspace(root), "runtime", "assets");
var entries = new SortedDictionary<string, string>(StringComparer.Ordinal);
AddMappedDirectory(entries, Path.Combine(assets, ProductRuntimeAssets.SourceBootstrapDirectoryName),
ProductRuntimeAssets.ProductBootstrapDirectoryName, cancellationToken);
foreach (var (relativePath, productFileName) in ProductRuntimeAssets.Files)
AddRequiredMappedFile(entries, ProductRuntimeAssets.SourceFile(assets, relativePath),
productFileName, cancellationToken);
return BuildIdentity(RuntimeAssetsVersion, entries);
}
public static string? TryComputeRuntimeAssets(string root, CancellationToken cancellationToken = default,
Action<string>? diagnostic = null)
{
try { return ComputeRuntimeAssets(root, cancellationToken); }
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException)
{
diagnostic?.Invoke(
$"The workspace runtime assets under {root} could not be fingerprinted: {ex.Message}");
return null;
}
}
/// <summary>Checks the actual copied product files against a source runtime-assets identity.</summary>
public static bool ProductRuntimeAssetsMatch(string productDirectory, string expectedFingerprint,
CancellationToken cancellationToken = default, Action<string>? diagnostic = null)
{
try
{
var entries = new SortedDictionary<string, string>(StringComparer.Ordinal);
AddMappedDirectory(entries,
Path.Combine(productDirectory, ProductRuntimeAssets.ProductBootstrapDirectoryName),
ProductRuntimeAssets.ProductBootstrapDirectoryName, cancellationToken);
foreach (var (_, productFileName) in ProductRuntimeAssets.Files)
AddRequiredMappedFile(entries, Path.Combine(productDirectory, productFileName),
productFileName, cancellationToken);
return BuildIdentity(RuntimeAssetsVersion, entries).Equals(expectedFingerprint,
StringComparison.Ordinal);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException)
{
diagnostic?.Invoke(
$"The copied runtime assets under {productDirectory} could not be verified: {ex.Message}");
return false;
}
}
/// <summary>Identity of the shipped Toolkit directory, including installer-only tools.</summary>
public static string ComputePackage(string root, CancellationToken cancellationToken = default)
{
root = Path.GetFullPath(root);
var entries = new SortedDictionary<string, string>(StringComparer.Ordinal);
AddDirectory(entries, root, InstalledLayout.Toolkit(root), null, cancellationToken);
if (entries.Count == 0)
throw new InvalidDataException($"No toolkit package files were found under {root}.");
return BuildIdentity(PackageVersion, entries);
}
/// <summary>
/// The toolkit is ~26,000 mostly tiny files (llvm-mingw's headers/libraries dominate), so this
/// walk is bound by per-file open/read overhead, not hashing throughput, minutes rather than
/// seconds on a cold cache under on-access antivirus. Hashing concurrently keeps a compiling
/// repair from taking longer to prove toolkit integrity than to translate with it.
/// </summary>
private static void AddDirectory(SortedDictionary<string, string> entries, string root, string directory,
string[]? extensions, CancellationToken cancellationToken = default,
Func<string, bool>? include = null)
{
if (!Directory.Exists(directory)) return;
var selected = new List<string>();
foreach (var file in Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories))
{
cancellationToken.ThrowIfCancellationRequested();
if (include is not null && !include(file)) continue;
if (extensions is not null &&
!extensions.Contains(Path.GetExtension(file), StringComparer.OrdinalIgnoreCase))
continue;
selected.Add(file);
}
var hashed = new System.Collections.Concurrent.ConcurrentBag<(string Relative, string Hash)>();
Parallel.ForEach(selected,
new ParallelOptions
{
CancellationToken = cancellationToken,
MaxDegreeOfParallelism = Math.Max(2, Environment.ProcessorCount)
},
file =>
{
if (!File.Exists(file)) return;
var relative = Path.GetRelativePath(root, file).Replace('\\', '/');
hashed.Add((relative, InputValidation.Sha256File(file)));
});
foreach (var (relative, hash) in hashed) entries[relative] = hash;
}
private static void AddFile(SortedDictionary<string, string> entries, string root, string file,
CancellationToken cancellationToken = default)
{
if (!File.Exists(file)) return;
cancellationToken.ThrowIfCancellationRequested();
var relative = Path.GetRelativePath(root, file).Replace('\\', '/');
entries[relative] = InputValidation.Sha256File(file);
}
private static void AddMappedDirectory(SortedDictionary<string, string> entries, string directory,
string identityRoot, CancellationToken cancellationToken)
{
if (!Directory.Exists(directory))
throw new InvalidDataException($"Required product runtime asset directory is missing: {directory}");
var tree = FileSystemUtilities.EnumerateRegularTree(directory, cancellationToken,
RuntimeAssetsDescription);
if (tree.Count == 0)
throw new InvalidDataException($"Required product runtime asset directory is empty: {directory}");
foreach (var entry in tree)
{
if (entry.IsEmptyDirectory)
entries[$"empty-directory:{identityRoot}/{entry.RelativePath}"] = "";
else if (!entry.IsDirectory)
entries[$"{identityRoot}/{entry.RelativePath}"] = InputValidation.Sha256File(entry.FullPath);
}
}
private static void AddRequiredMappedFile(SortedDictionary<string, string> entries, string file,
string identityPath, CancellationToken cancellationToken)
{
if (!File.Exists(file))
throw new InvalidDataException($"Required product runtime asset is missing: {file}");
FileSystemUtilities.RejectReparsePoint(new FileInfo(file), RuntimeAssetsDescription);
cancellationToken.ThrowIfCancellationRequested();
entries[identityPath] = InputValidation.Sha256File(file);
}
private static string BuildIdentity(string version, SortedDictionary<string, string> entries)
{
var builder = new StringBuilder(version).Append('\n');
foreach (var (relative, hash) in entries)
builder.Append(relative).Append('|').Append(hash).Append('\n');
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString())))
.ToLowerInvariant();
}
}
/// <summary>
/// Compile identity plus the two subsets deciding cache reuse: <see cref="Compile"/> is the
/// authoritative freshness identity, <see cref="Translation"/> decides if the base translation is
/// still current, <see cref="NativeToolchain"/> decides if compiled objects may still be linked.
/// </summary>
internal sealed record ToolkitFingerprintComponents(string Compile, string Translation,
string NativeToolchain);