diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f002742..939ce62 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,6 +57,7 @@ jobs: - run: dotnet restore ArcNotes.slnx --locked-mode - run: dotnet format ArcNotes.slnx --verify-no-changes --no-restore - run: dotnet build ArcNotes.slnx -c Release --no-restore + - run: dotnet run --project eng/ArcForges.Repository -c Release --no-build -- verify-assemblies - run: dotnet run --project eng/ArcForges.Repository -c Release --no-build -- check - name: Set immutable version id: version diff --git a/Directory.Build.props b/Directory.Build.props index d528f5d..c741839 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -20,7 +20,6 @@ ArcForges https://github.com/ArcForges/ArcNotes AGPL-3.0-only - local diff --git a/Directory.Build.targets b/Directory.Build.targets index 295c75c..4c53ad7 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -1,5 +1,24 @@ + + + + + + + + + + $(_ArcNotesCommit) + $(_ArcNotesCommit) + <_ArcNotesDirty>false + <_ArcNotesDirty Condition="'$(_ArcNotesChanges)' != ''">true + + + + + + <_ArcForgesLicenceDeclaration Include="$(MSBuildProjectFullPath)"> diff --git a/Directory.Packages.props b/Directory.Packages.props index ddb1eaa..4a77128 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,7 +5,7 @@ true - + diff --git a/README.md b/README.md index bc60e70..b131521 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ dotnet run --project src/ArcForges.ArcNotes dotnet run --project eng/ArcForges.Repository -- hooks ``` -The UI, application state and repository tool are C#. Avalonia/Skia supply packaged native UI/rendering dependencies. The application consumes `ArcForges.Contracts.PublicApi` **1.0.0-ci.36.1** and the private build-time `ArcForges.Build.Policy` **1.0.0-ci.7.1**. It does not ship unused DesktopPlatform media engines. +The UI, application state and repository tool are C#. Avalonia/Skia supply packaged native UI/rendering dependencies. The application consumes `ArcForges.Contracts.PublicApi` **1.0.0-ci.36.1** and the private build-time `ArcForges.Build.Policy` **1.0.0-ci.20.1**. It does not ship unused DesktopPlatform media engines. ## Downloads and automation @@ -42,3 +42,5 @@ The same tool enforces [source provenance](docs/provenance.md): complete file classification, immutable reuse records, full notices and portable-archive checks. Read [CONTRIBUTING](CONTRIBUTING.md), [security reporting](SECURITY.md) and the [code of conduct](CODE_OF_CONDUCT.md). ArcNotes remains **AGPL-3.0-only**; see [LICENSE](LICENSE) and [third-party notices](THIRD_PARTY_NOTICES.md). + +`--build-info --evidence ` writes offline support metadata from the compiled application. See [build identity](docs/build-identity.md). diff --git a/docs/build-identity.md b/docs/build-identity.md new file mode 100644 index 0000000..be64463 --- /dev/null +++ b/docs/build-identity.md @@ -0,0 +1,35 @@ +# WP02.04 offline build identity + +`ArcNotes --build-info --evidence /absolute/path/build.json` runs before UI/host +initialization and performs no network request. The compiled assembly metadata and +embedded version sources produce `arcforges.build-identity.v1`; runtime environment +variables cannot replace the producer identity. Normal startup remains offline. + +All four owned assemblies use published Build.Policy `1.0.0-ci.20.1` for the full +source commit, build kind, build ID, pipeline URL and deterministic source commit +timestamp. Owner build targets add the actual dirty state. CI requires a clean +checkout and actual GitHub run identity. Local builds identify themselves as local; +they cannot pass CI candidate verification. The timestamp is not wall-clock build +time. Third-party assemblies retain their own metadata. + +The nine-axis source catalog is `eng/version-sources.json`. AppVersion reads the +application informational version. ContractSet reads the restored Contracts +`source.json` namespace/descriptor, independently of its pinned package release +`1.0.0-ci.36.1`. PackageVersion lists exact resolved dependency coordinates from +the embedded committed application lock, including framework/RID scope. No product +format, storage, capability, policy or extension version is invented for this +foundation. Explicit absent states name their later responsible work packages. +There is no first-party native ABI in this application. + +Preparation executes the actual Native AOT candidate offline and compares its +report with independently obtained Git/run/release and restored dependency inputs. +The report is included in each portable archive. Archive verification reads it +and rejects even rehashed metadata changes. Existing native UI/live Cloud checks +remain required on all five native CI hosts. The `verify-assemblies` command reads +the actual PE metadata of the app, core, tests and repository tool after compilation. + +Tests mutate each of the nine distinct source kinds, check deterministic output, +and reject missing/unknown axes, aliases, duplicate subjects, malformed versions, +unsafe paths, dirty CI identities and source/run/report tampering. Synthetic future +source declarations are mechanism fixtures, not implemented application support. +This metadata does not establish commercial readiness or later compatibility gates. diff --git a/eng/ArcForges.Repository/ArcForges.Repository.csproj b/eng/ArcForges.Repository/ArcForges.Repository.csproj index 20a51da..61c4300 100644 --- a/eng/ArcForges.Repository/ArcForges.Repository.csproj +++ b/eng/ArcForges.Repository/ArcForges.Repository.csproj @@ -1,5 +1,8 @@ Exe + + + AGPL AGPL-3.0-only diff --git a/eng/ArcForges.Repository/IdentityEvidence.cs b/eng/ArcForges.Repository/IdentityEvidence.cs new file mode 100644 index 0000000..fec4e58 --- /dev/null +++ b/eng/ArcForges.Repository/IdentityEvidence.cs @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: AGPL-3.0-only +using System.Diagnostics; +using System.Globalization; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using System.Text.Json.Nodes; +using System.Xml.Linq; +using ArcForges.ArcNotes.Core; + +namespace ArcForges.Repository; + +public static class IdentityEvidence +{ + public static string Git(string root, params string[] arguments) + { + var info = new ProcessStartInfo("git") { WorkingDirectory = root, UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true }; + foreach (var argument in arguments) info.ArgumentList.Add(argument); + using var process = Process.Start(info)!; + var output = process.StandardOutput.ReadToEndAsync(); + var error = process.StandardError.ReadToEndAsync(); + if (!process.WaitForExit(30_000)) { process.Kill(true); throw new InvalidOperationException("Git identity lookup timed out."); } + if (process.ExitCode != 0) throw new InvalidOperationException(error.GetAwaiter().GetResult()); + return output.GetAwaiter().GetResult().Trim(); + } + + public static JsonObject ExpectedBuild(string root, string commit, bool dirty = false) + { + if (Git(root, "rev-parse", "HEAD") != commit) throw new InvalidOperationException("Source checkout does not match candidate."); + var ci = Environment.GetEnvironmentVariable("GITHUB_ACTIONS") == "true"; + var run = ci ? Environment.GetEnvironmentVariable("GITHUB_RUN_ID") : null; + var attempt = ci ? int.Parse(Environment.GetEnvironmentVariable("GITHUB_RUN_ATTEMPT")!, CultureInfo.InvariantCulture) : (int?)null; + if (ci && (Environment.GetEnvironmentVariable("GITHUB_SHA") != commit || Environment.GetEnvironmentVariable("GITHUB_REPOSITORY") != "ArcForges/ArcNotes")) + throw new InvalidOperationException("CI identity differs from source owner."); + var result = new JsonObject + { + ["sourceCommit"] = commit, + ["dirty"] = dirty, + ["kind"] = ci ? "ci" : "local", + ["buildId"] = ci ? run + "." + attempt!.Value.ToString(CultureInfo.InvariantCulture) : "local." + commit, + ["runId"] = run, + ["runAttempt"] = attempt, + ["pipelineRun"] = ci ? "https://github.com/ArcForges/ArcNotes/actions/runs/" + run : null, + ["sourceDateEpoch"] = long.Parse(Git(root, "show", "-s", "--format=%ct", commit), CultureInfo.InvariantCulture) + }; + BuildIdentity.ValidateBuild(result); + return result; + } + + public static JsonObject ExpectedReport(string root, string version, string commit) + { + var catalog = JsonNode.Parse(File.ReadAllText(Path.Combine(root, "eng/version-sources.json")))!.AsObject(); + var pins = XDocument.Load(Path.Combine(root, "Directory.Packages.props")); + var contractsVersion = pins.Descendants("PackageVersion").Single(e => (string?)e.Attribute("Include") == "ArcForges.Contracts.PublicApi").Attribute("Version")!.Value; + var assets = JsonNode.Parse(File.ReadAllText(Path.Combine(root, "eng/ArcForges.Repository/obj/project.assets.json")))!; + var contractsPath = assets["packageFolders"]!.AsObject().Select(p => Path.Combine(p.Key, "arcforges.contracts.publicapi", contractsVersion, "source.json")).Single(File.Exists); + var contracts = File.ReadAllText(contractsPath); + if (JsonNode.Parse(contracts)!["version"]!.GetValue() != contractsVersion) throw new InvalidOperationException("Restored Contracts identity differs from central pin."); + string Read(string path) => path switch + { + "assembly/release.json" => new JsonObject { ["versions"] = new JsonArray(new JsonObject { ["subject"] = "ArcNotes", ["version"] = version }) }.ToJsonString(), + "packages/contracts/source.json" => contracts, + _ => File.ReadAllText(Path.Combine(root, path)) + }; + return new JsonObject + { + ["schema"] = "arcforges.build-identity.v1", + ["owner"] = "ArcNotes", + ["artifact"] = new JsonObject { ["id"] = "ArcNotes", ["version"] = version }, + ["build"] = ExpectedBuild(root, commit), + ["axes"] = BuildIdentity.Resolve(catalog, Read) + }; + } + + public static void Verify(byte[] actual, string root, string version, string commit) + { + var report = JsonNode.Parse(actual)!.AsObject(); + BuildIdentity.ValidateBuild(report["build"]!.AsObject()); + if (!JsonNode.DeepEquals(report, ExpectedReport(root, version, commit))) + throw new InvalidOperationException("Runtime build identity or independent version sources differ from expected candidate."); + } + + public static void VerifyAssemblies(string root) + { + var commit = Git(root, "rev-parse", "HEAD"); + var build = ExpectedBuild(root, commit, Git(root, "status", "--porcelain").Length != 0); + var expected = new Dictionary(StringComparer.Ordinal) + { + ["ArcForges.SourceCommit"] = commit, + ["ArcForges.Dirty"] = build["dirty"]!.GetValue() ? "true" : "false", + ["ArcForges.BuildId"] = build["buildId"]!.GetValue(), + ["ArcForges.BuildKind"] = build["kind"]!.GetValue(), + ["ArcForges.PipelineRun"] = build["pipelineRun"]?.GetValue() ?? "local", + ["ArcForges.SourceDateEpoch"] = build["sourceDateEpoch"]!.GetValue().ToString(CultureInfo.InvariantCulture) + }; + string[] paths = ["src/ArcForges.ArcNotes/bin/Release/net10.0/ArcNotes.dll", "src/ArcForges.ArcNotes.Core/bin/Release/net10.0/ArcForges.ArcNotes.Core.dll", + "eng/ArcForges.Repository/bin/Release/net10.0/ArcForges.Repository.dll", "tests/ArcForges.ArcNotes.Tests/bin/Release/net10.0/ArcForges.ArcNotes.Tests.dll"]; + foreach (var path in paths) + { + using var file = File.OpenRead(Path.Combine(root, path)); + using var pe = new PEReader(file); + var reader = pe.GetMetadataReader(); + var found = new Dictionary(StringComparer.Ordinal); + foreach (var handle in reader.GetAssemblyDefinition().GetCustomAttributes()) + { + var attribute = reader.GetCustomAttribute(handle); + if (attribute.Constructor.Kind != HandleKind.MemberReference) continue; + var parent = reader.GetMemberReference((MemberReferenceHandle)attribute.Constructor).Parent; + if (parent.Kind != HandleKind.TypeReference) continue; + var type = reader.GetTypeReference((TypeReferenceHandle)parent); + if (reader.GetString(type.Namespace) != "System.Reflection" || reader.GetString(type.Name) != "AssemblyMetadataAttribute") continue; + var blob = reader.GetBlobReader(attribute.Value); + if (blob.ReadUInt16() != 1) throw new InvalidOperationException("Malformed assembly attribute."); + found.Add(blob.ReadSerializedString()!, blob.ReadSerializedString()); + } + foreach (var entry in expected) + if (found.GetValueOrDefault(entry.Key) != entry.Value) throw new InvalidOperationException("Compiled assembly identity differs: " + path + " / " + entry.Key); + } + Console.WriteLine("Verified actual PE build identity of all four owned assemblies."); + } +} diff --git a/eng/ArcForges.Repository/Program.cs b/eng/ArcForges.Repository/Program.cs index 5a6c41d..ddcd953 100644 --- a/eng/ArcForges.Repository/Program.cs +++ b/eng/ArcForges.Repository/Program.cs @@ -26,6 +26,7 @@ public static async Task Main(string[] args) await Run("git", ["config", "--worktree", "core.hooksPath", ".githooks"]); break; case ["check"]: await Check(); break; + case ["verify-assemblies"]: IdentityEvidence.VerifyAssemblies(Directory.GetCurrentDirectory()); break; case ["provenance-notice"]: await ProvenancePolicy.Check(Directory.GetCurrentDirectory(), writeNotice: true); break; case ["version"]: var generatedVersion = Version(Environment.GetEnvironmentVariable("GITHUB_RUN_NUMBER") ?? "0", Environment.GetEnvironmentVariable("GITHUB_RUN_ATTEMPT") ?? "1"); @@ -107,6 +108,9 @@ private static async Task Prepare(string rid, string version) ProvenancePolicy.VerifyPackageNotices(requiredNotices, path => File.Exists(Path.Combine(stage, path)) ? File.ReadAllBytes(Path.Combine(stage, path)) : null); File.Copy("artifacts/evidence/provenance.json", Path.Combine(stage, "notices/provenance-source.json")); if (!File.Exists(Executable(rid))) throw new InvalidOperationException("Published executable is missing."); + await Run(Executable(rid), ["--build-info", "--evidence", Path.Combine(stage, "build-identity.json")]); + IdentityEvidence.Verify(File.ReadAllBytes(Path.Combine(stage, "build-identity.json")), Directory.GetCurrentDirectory(), version, + (await Capture("git", ["rev-parse", "HEAD"])).Trim()); if (rid.StartsWith("osx-", StringComparison.Ordinal)) { var parts = version.Split('.'); @@ -191,7 +195,7 @@ private static async Task Pack(string rid, string version, string commit) using var gzip = new GZipStream(file, CompressionLevel.Optimal); TarFile.CreateFromDirectory(Stage(rid), gzip, false); } - VerifyArchiveNotices(archive, commit, Directory.GetCurrentDirectory()); + VerifyArchiveNotices(archive, commit, Directory.GetCurrentDirectory(), version); File.Copy(evidence, Path.Combine(folder, "smoke.json")); File.Copy(Path.ChangeExtension(evidence, ".png"), Path.Combine(folder, "screen.png")); var manifest = new Candidate(rid, version, commit, name, Hash(archive), Hash(evidence)); @@ -217,11 +221,11 @@ public static string VerifyCandidate(string manifestPath, string version, string throw new InvalidOperationException("Download checksum mismatch."); using var evidence = JsonDocument.Parse(File.ReadAllText(smoke)); ValidateSmoke(evidence.RootElement, manifest.Rid, version, commit); - VerifyArchiveNotices(archive, commit, sourceRoot ?? Directory.GetCurrentDirectory()); + VerifyArchiveNotices(archive, commit, sourceRoot ?? Directory.GetCurrentDirectory(), version); return manifest.Rid; } - private static void VerifyArchiveNotices(string archive, string commit, string sourceRoot) + private static void VerifyArchiveNotices(string archive, string commit, string sourceRoot, string version) { var expected = ProvenancePolicy.PackageNotices(sourceRoot); const string receiptName = "notices/provenance-source.json"; @@ -235,7 +239,7 @@ void Retain(string path, Stream? stream) if (path.StartsWith('/') || path.IndexOfAny(['\\', ':', '\0']) >= 0 || path.Split('/').Any(p => p is "" or "." or "..")) throw new InvalidOperationException("Escaping archive member: " + path); if (!members.Add(path)) throw new InvalidOperationException("Duplicate or case-colliding archive member: " + path); - if (!expected.ContainsKey(path) && path != receiptName) return; + if (!expected.ContainsKey(path) && path != receiptName && path != "build-identity.json") return; if (stream is null) throw new InvalidOperationException("Required notice is not a regular archive file: " + path); using var memory = new MemoryStream(); stream.CopyTo(memory); @@ -265,6 +269,8 @@ void Retain(string path, Stream? stream) } } ProvenancePolicy.VerifyPackageNotices(expected, path => selected.GetValueOrDefault(path)); + if (!selected.TryGetValue("build-identity.json", out var identity)) throw new InvalidOperationException("Missing packaged runtime build identity."); + IdentityEvidence.Verify(identity, sourceRoot, version, commit); if (!selected.TryGetValue(receiptName, out var receipt)) throw new InvalidOperationException("Missing candidate source provenance."); using var report = JsonDocument.Parse(receipt); if (report.RootElement.GetProperty("sourceCommit").GetString() != commit || diff --git a/eng/ArcForges.Repository/packages.lock.json b/eng/ArcForges.Repository/packages.lock.json index ba9dcbd..a898e84 100644 --- a/eng/ArcForges.Repository/packages.lock.json +++ b/eng/ArcForges.Repository/packages.lock.json @@ -4,9 +4,74 @@ "net10.0": { "ArcForges.Build.Policy": { "type": "Direct", - "requested": "[1.0.0-ci.7.1, )", - "resolved": "1.0.0-ci.7.1", - "contentHash": "GWH92jk3DiL470LAjYA0O4TZzh26cmqIk6KROuXdbY03zz2FaUui6YC0ty7515RC8dreP6wLBief5sC+XoGwvQ==" + "requested": "[1.0.0-ci.20.1, )", + "resolved": "1.0.0-ci.20.1", + "contentHash": "rGn/WwyV9sq38y9rlQGX9gLBqy93XOgHmUSKJ2AupR6DDfVHTHgZVwnhfGL1K9XJkRKV972GV/dFcLersJ5iVw==" + }, + "Google.Protobuf": { + "type": "Transitive", + "resolved": "3.36.1", + "contentHash": "77AqPEoaY1ODE+syYBHti0jXiwQq0J/fUr/fRyYhNlc9oKtH5dZZEr/OLKtdKNVG83PRnCYB2r8B80ZrObzOGQ==" + }, + "Grpc.Core.Api": { + "type": "Transitive", + "resolved": "2.83.0", + "contentHash": "xU8BT2bCw4Ct+/qIFJhhjkDEilKUbBOfp4US7l3Xa3hnlkN9l2LgISCbOw4PFYpW5jJFcK7FUAyWOFIQ94Hiqg==" + }, + "Grpc.Net.Common": { + "type": "Transitive", + "resolved": "2.83.0", + "contentHash": "9w52xxORdIGu4Mm8lff3psMIlarenu+7TC4uIfiiW9AyaHZ6iPE/18gIkG1DD9byGDuhjuhnN9hiviBUAmCuZw==", + "dependencies": { + "Grpc.Core.Api": "2.83.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "cjWrLkJXK0rs4zofsK4bSdg+jhDLTaxrkXu4gS6Y7MAlCvRyNNgwY/lJi5RDlQOnSZweHqoyvgvbdvQsRIW+hg==" + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "arDBqTgFCyS0EvRV7O3MZturChstm50OJ0y9bDJvAcmEPJm0FFpFyjU/JLYyStNGGey081DvnQYlncNX5SJJGA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0" + } + }, + "arcforges.arcnotes.core": { + "type": "Project", + "dependencies": { + "ArcForges.Contracts.PublicApi": "[1.0.0-ci.36.1, )", + "Grpc.Net.Client": "[2.83.0, )", + "Grpc.Net.Client.Web": "[2.83.0, )" + } + }, + "ArcForges.Contracts.PublicApi": { + "type": "CentralTransitive", + "requested": "[1.0.0-ci.36.1, )", + "resolved": "1.0.0-ci.36.1", + "contentHash": "4+pUDeDcOScTV5rEei5yri8CLvVb9309Zdl5TBcVKXBvFrowcOoPx5pkZmpqzcLQZAZ2LH6LVv8JCGURpf42zQ==", + "dependencies": { + "Google.Protobuf": "3.36.1", + "Grpc.Core.Api": "2.83.0" + } + }, + "Grpc.Net.Client": { + "type": "CentralTransitive", + "requested": "[2.83.0, )", + "resolved": "2.83.0", + "contentHash": "dHYtx7hW4muttsCrWNG4/Ysb1iAIhNPEq28eo7vF166CdXn/RXOr3SGvSreIGIjvmdrADrBNJ2cUeJBto/T0XA==", + "dependencies": { + "Grpc.Net.Common": "2.83.0", + "Microsoft.Extensions.Logging.Abstractions": "8.0.0" + } + }, + "Grpc.Net.Client.Web": { + "type": "CentralTransitive", + "requested": "[2.83.0, )", + "resolved": "2.83.0", + "contentHash": "+VsLfmWH7Lv/8sH5k9QjrccR+FQapMvFkBkoTHLdk6EFqBMmBFsddy6A9vvilHZf1AL1GVPaSMBJyGiPcxp7+w==" } } } diff --git a/eng/provenance/files.json b/eng/provenance/files.json index 5df44e0..651aad3 100644 --- a/eng/provenance/files.json +++ b/eng/provenance/files.json @@ -26,12 +26,14 @@ "SECURITY.md", "THIRD_PARTY_NOTICES.md", "docs/bootstrap-plan.md", + "docs/build-identity.md", "docs/development.md", "docs/licence-boundary.md", "docs/provenance.md", "docs/release-notes.md", "docs/releasing.md", "eng/ArcForges.Repository/ArcForges.Repository.csproj", + "eng/ArcForges.Repository/IdentityEvidence.cs", "eng/ArcForges.Repository/LicencePolicy.cs", "eng/ArcForges.Repository/Program.cs", "eng/ArcForges.Repository/packages.lock.json", @@ -50,8 +52,10 @@ "eng/provenance/records/upstream-protobuf-legal-r1.json", "eng/provenance/records/upstream-tmds-dbus-legal-r1.json", "eng/provenance/template.json", + "eng/version-sources.json", "global.json", "src/ArcForges.ArcNotes.Core/ArcForges.ArcNotes.Core.csproj", + "src/ArcForges.ArcNotes.Core/BuildIdentity.cs", "src/ArcForges.ArcNotes.Core/CloudHelloClient.cs", "src/ArcForges.ArcNotes.Core/HelloViewModel.cs", "src/ArcForges.ArcNotes.Core/packages.lock.json", @@ -63,6 +67,7 @@ "src/ArcForges.ArcNotes/app.manifest", "src/ArcForges.ArcNotes/packages.lock.json", "tests/ArcForges.ArcNotes.Tests/ArcForges.ArcNotes.Tests.csproj", + "tests/ArcForges.ArcNotes.Tests/BuildIdentityTests.cs", "tests/ArcForges.ArcNotes.Tests/CandidateTests.cs", "tests/ArcForges.ArcNotes.Tests/HelloStateTests.cs", "tests/ArcForges.ArcNotes.Tests/LicencePolicyTests.cs", diff --git a/eng/version-sources.json b/eng/version-sources.json new file mode 100644 index 0000000..343587a --- /dev/null +++ b/eng/version-sources.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": 1, + "owner": "ArcNotes", + "axes": { + "AppVersion": { "kind": "release", "sources": ["assembly/release.json"] }, + "ContractSet": { "kind": "contracts", "sources": ["packages/contracts/source.json"] }, + "CapabilityVersion": { "kind": "declarations", "absence": "not-produced", "reason": "Hello is not a production capability descriptor.", "producer": "WP09 and the product capability owner" }, + "NativeFormatVersion": { "kind": "declarations", "absence": "not-produced", "reason": "No portable knowledge format is implemented.", "producer": "WP18/28" }, + "StorageSchemaVersion": { "kind": "migrations", "absence": "not-produced", "reason": "No application migration set is implemented.", "producer": "WP07/21" }, + "NativeAbiVersion": { "kind": "native-abi", "absence": "not-applicable", "reason": "This application has no first-party C ABI boundary; third-party native packages remain dependency inventory." }, + "PolicySchemaVersion": { "kind": "declarations", "absence": "not-produced", "reason": "MSBuild policy is not a signed application policy schema.", "producer": "WP03/44" }, + "ExtensionProtocolVersion": { "kind": "declarations", "absence": "not-produced", "reason": "No extension protocol is implemented.", "producer": "WP03/41" }, + "PackageVersion": { "kind": "packages", "sources": ["src/ArcForges.ArcNotes/packages.lock.json"] } + } +} diff --git a/src/ArcForges.ArcNotes.Core/BuildIdentity.cs b/src/ArcForges.ArcNotes.Core/BuildIdentity.cs new file mode 100644 index 0000000..7851627 --- /dev/null +++ b/src/ArcForges.ArcNotes.Core/BuildIdentity.cs @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: AGPL-3.0-only +using System.Globalization; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; + +namespace ArcForges.ArcNotes.Core; + +/// Offline support information from compiled metadata and embedded, source-bound inputs. +public static partial class BuildIdentity +{ + public static readonly string[] AxisNames = ["AppVersion", "ContractSet", "CapabilityVersion", "NativeFormatVersion", + "StorageSchemaVersion", "NativeAbiVersion", "PolicySchemaVersion", "ExtensionProtocolVersion", "PackageVersion"]; + private static readonly string[] Kinds = ["release", "contracts", "declarations", "declarations", "migrations", + "native-abi", "declarations", "declarations", "packages"]; + + public static JsonObject FromAssembly(Assembly assembly) + { + var metadata = assembly.GetCustomAttributes().ToDictionary(a => a.Key, a => a.Value, StringComparer.Ordinal); + string Field(string key) => metadata["ArcForges." + key] ?? throw new InvalidOperationException("Missing compiled build identity."); + var version = assembly.GetCustomAttribute()!.InformationalVersion.Split('+')[0]; + var kind = Field("BuildKind"); + var id = Field("BuildId"); + var build = new JsonObject + { + ["sourceCommit"] = Field("SourceCommit"), + ["dirty"] = bool.Parse(Field("Dirty")), + ["kind"] = kind, + ["buildId"] = id, + ["runId"] = kind == "ci" ? id.Split('.')[0] : null, + ["runAttempt"] = kind == "ci" ? int.Parse(id.Split('.')[1], CultureInfo.InvariantCulture) : (int?)null, + ["pipelineRun"] = kind == "ci" ? Field("PipelineRun") : null, + ["sourceDateEpoch"] = long.Parse(Field("SourceDateEpoch"), CultureInfo.InvariantCulture) + }; + string Resource(string name) + { + using var stream = assembly.GetManifestResourceStream("ArcNotes." + name) ?? throw new InvalidOperationException("Missing embedded source: " + name); + using var reader = new StreamReader(stream, Encoding.UTF8); + return reader.ReadToEnd(); + } + var inputs = new Dictionary(StringComparer.Ordinal) + { + ["assembly/release.json"] = new JsonObject { ["versions"] = new JsonArray(new JsonObject { ["subject"] = "ArcNotes", ["version"] = version }) }.ToJsonString(), + ["packages/contracts/source.json"] = Resource("ContractsSource"), + ["src/ArcForges.ArcNotes/packages.lock.json"] = Resource("PackageLock") + }; + ValidateBuild(build); + return new JsonObject + { + ["schema"] = "arcforges.build-identity.v1", + ["owner"] = "ArcNotes", + ["artifact"] = new JsonObject { ["id"] = "ArcNotes", ["version"] = version }, + ["build"] = build, + ["axes"] = Resolve(JsonNode.Parse(Resource("VersionSources"))!.AsObject(), path => inputs[path]) + }; + } + + public static JsonObject Resolve(JsonObject catalog, Func read) + { + Require(catalog["schemaVersion"]!.GetValue() == 1 && catalog["owner"]!.GetValue() == "ArcNotes", "Wrong source catalog."); + var definitions = catalog["axes"]!.AsObject(); + Require(definitions.Select(p => p.Key).ToHashSet(StringComparer.Ordinal).SetEquals(AxisNames), "Exactly nine independent axes are required."); + var result = new JsonObject(); + for (var index = 0; index < AxisNames.Length; index++) + { + var name = AxisNames[index]; + var definition = definitions[name]!.AsObject(); + var kind = definition["kind"]!.GetValue(); + Require(kind == Kinds[index], "Wrong source kind for " + name); + if (definition["absence"] is { } absence) + { + Require(definition.All(p => p.Key is "kind" or "absence" or "reason" or "producer"), "Unknown absent-axis field."); + var status = absence.GetValue(); + Require(status is "not-applicable" or "not-produced" && !string.IsNullOrWhiteSpace(definition["reason"]?.GetValue()), "Invalid absence."); + Require(status != "not-produced" || !string.IsNullOrWhiteSpace(definition["producer"]?.GetValue()), "Missing future producer."); + var absent = new JsonObject { ["status"] = status, ["reason"] = definition["reason"]!.DeepClone() }; + if (definition["producer"] is { } producer) absent["producer"] = producer.DeepClone(); + result[name] = absent; + continue; + } + Require(definition.All(p => p.Key is "kind" or "sources"), "Aliases and unknown source properties are forbidden."); + var values = new SortedDictionary(StringComparer.Ordinal); + foreach (var item in definition["sources"]!.AsArray()) + { + var path = item!.GetValue(); + Require(!Path.IsPathRooted(path) && path.IndexOfAny(['\\', ':']) < 0 && !path.Split('/').Any(p => p is "" or "." or ".."), "Unsafe source path."); + var content = read(path).Replace("\r\n", "\n", StringComparison.Ordinal); + var source = new JsonObject { ["path"] = path, ["sha256"] = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(content))) }; + void Add(string subject, string version, JsonNode? descriptor = null) + { + Require(!string.IsNullOrWhiteSpace(subject) && VersionPattern().IsMatch(version), "Malformed subject or version."); + var value = new JsonObject { ["subject"] = subject, ["version"] = version, ["source"] = source.DeepClone() }; + if (descriptor is not null) value["descriptorSha256"] = descriptor.DeepClone(); + Require(values.TryAdd(subject, value), "Duplicate version subject."); + } + if (kind == "native-abi") + { + var major = AbiMajor().Match(content); + var minor = AbiMinor().Match(content); + Require(major.Success && minor.Success, "Missing native ABI constants."); + Add(path, major.Groups[1].Value + "." + minor.Groups[1].Value); + continue; + } + var document = JsonNode.Parse(content)!.AsObject(); + if (kind == "contracts") + { + var schema = ContractPattern().Match(document["schema"]!.GetValue()); + Require(schema.Success && HashPattern().IsMatch(document["descriptorSha256"]!.GetValue()) && !document["dirty"]!.GetValue(), "Invalid restored contract provenance."); + Add(schema.Groups[1].Value, schema.Groups[2].Value, document["descriptorSha256"]); + } + else if (kind == "packages") + { + foreach (var framework in document["dependencies"]!.AsObject()) + foreach (var dependency in framework.Value!.AsObject()) + { + if (dependency.Value!["type"]!.GetValue() == "Project") continue; + Add("pkg:nuget/" + dependency.Key + "?target=" + framework.Key, dependency.Value["resolved"]!.GetValue()); + } + } + else + { + var declarations = document[kind == "migrations" ? "migrations" : "versions"]!.AsArray(); + var selected = kind == "migrations" ? declarations.TakeLast(1) : declarations; + foreach (var value in selected) Add(value!["subject"]!.GetValue(), value["version"]!.GetValue()); + } + } + Require(kind == "packages" || values.Count > 0, "No implemented source for " + name); + result[name] = new JsonObject { ["status"] = "present", ["values"] = new JsonArray(values.Values.Select(v => (JsonNode)v).ToArray()) }; + } + return result; + } + + public static void ValidateBuild(JsonObject build) + { + Require(build.Select(p => p.Key).ToHashSet(StringComparer.Ordinal).SetEquals(["sourceCommit", "dirty", "kind", "buildId", "runId", "runAttempt", "pipelineRun", "sourceDateEpoch"]), "Unknown or missing build fields."); + var commit = build["sourceCommit"]!.GetValue(); + Require(CommitPattern().IsMatch(commit) && build["sourceDateEpoch"]!.GetValue() > 0, "Invalid source identity."); + var dirty = build["dirty"]!.GetValue(); + var id = build["buildId"]!.GetValue(); + var kind = build["kind"]!.GetValue(); + if (kind == "ci") + { + var run = build["runId"]!.GetValue(); + var attempt = build["runAttempt"]!.GetValue(); + Require(!dirty && RunPattern().IsMatch(run) && attempt > 0 && id == run + "." + attempt.ToString(CultureInfo.InvariantCulture) + && build["pipelineRun"]!.GetValue() == "https://github.com/ArcForges/ArcNotes/actions/runs/" + run, "Invalid CI identity."); + } + else Require(kind == "local" && id == "local." + commit && build["runId"] is null && build["runAttempt"] is null && build["pipelineRun"] is null, "Invalid local identity."); + } + + private static void Require(bool condition, string message) + { + if (!condition) throw new InvalidOperationException(message); + } + [GeneratedRegex(@"^[0-9]+(?:\.[0-9]+)*(?:[-+][A-Za-z0-9.-]+)?$")] + private static partial Regex VersionPattern(); + [GeneratedRegex(@"^([a-zA-Z0-9_.]+)\.v([1-9][0-9]*)$")] + private static partial Regex ContractPattern(); + [GeneratedRegex(@"^[a-f0-9]{64}$")] + private static partial Regex HashPattern(); + [GeneratedRegex(@"^[a-f0-9]{40}$")] + private static partial Regex CommitPattern(); + [GeneratedRegex(@"^[1-9][0-9]*$")] + private static partial Regex RunPattern(); + [GeneratedRegex(@"#define\s+ARC_ABI_MAJOR\s+(\d+)")] + private static partial Regex AbiMajor(); + [GeneratedRegex(@"#define\s+ARC_ABI_MINOR\s+(\d+)")] + private static partial Regex AbiMinor(); +} diff --git a/src/ArcForges.ArcNotes.Core/packages.lock.json b/src/ArcForges.ArcNotes.Core/packages.lock.json index e5712a8..d510d1b 100644 --- a/src/ArcForges.ArcNotes.Core/packages.lock.json +++ b/src/ArcForges.ArcNotes.Core/packages.lock.json @@ -4,9 +4,9 @@ "net10.0": { "ArcForges.Build.Policy": { "type": "Direct", - "requested": "[1.0.0-ci.7.1, )", - "resolved": "1.0.0-ci.7.1", - "contentHash": "GWH92jk3DiL470LAjYA0O4TZzh26cmqIk6KROuXdbY03zz2FaUui6YC0ty7515RC8dreP6wLBief5sC+XoGwvQ==" + "requested": "[1.0.0-ci.20.1, )", + "resolved": "1.0.0-ci.20.1", + "contentHash": "rGn/WwyV9sq38y9rlQGX9gLBqy93XOgHmUSKJ2AupR6DDfVHTHgZVwnhfGL1K9XJkRKV972GV/dFcLersJ5iVw==" }, "ArcForges.Contracts.PublicApi": { "type": "Direct", diff --git a/src/ArcForges.ArcNotes/ArcForges.ArcNotes.csproj b/src/ArcForges.ArcNotes/ArcForges.ArcNotes.csproj index 47c6a55..e6237cd 100644 --- a/src/ArcForges.ArcNotes/ArcForges.ArcNotes.csproj +++ b/src/ArcForges.ArcNotes/ArcForges.ArcNotes.csproj @@ -15,7 +15,10 @@ - + + + + AGPL diff --git a/src/ArcForges.ArcNotes/LiveSmoke.cs b/src/ArcForges.ArcNotes/LiveSmoke.cs index e332c77..1f62a87 100644 --- a/src/ArcForges.ArcNotes/LiveSmoke.cs +++ b/src/ArcForges.ArcNotes/LiveSmoke.cs @@ -23,7 +23,7 @@ public static async Task RunAsync(MainWindow window, HelloViewModel state, NativeAot = !RuntimeFeature.IsDynamicCodeSupported, Rid = RuntimeInformation.RuntimeIdentifier, SourceRevision = typeof(Program).Assembly.GetCustomAttributes() - .Single(a => a.Key == "SourceRevision").Value ?? "unknown", + .Single(a => a.Key == "ArcForges.SourceCommit").Value ?? "unknown", Version = typeof(Program).Assembly.GetCustomAttribute()!.InformationalVersion.Split('+')[0] }; Directory.CreateDirectory(Path.GetDirectoryName(evidence)!); diff --git a/src/ArcForges.ArcNotes/Program.cs b/src/ArcForges.ArcNotes/Program.cs index bd9f0c9..47f296e 100644 --- a/src/ArcForges.ArcNotes/Program.cs +++ b/src/ArcForges.ArcNotes/Program.cs @@ -11,6 +11,13 @@ internal static class Program [STAThread] public static int Main(string[] args) { + if (args is ["--build-info", "--evidence", var output]) + { + var path = Path.GetFullPath(output); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, BuildIdentity.FromAssembly(typeof(Program).Assembly).ToJsonString() + "\n"); + return 0; + } string? evidence = null; if (args.Length != 0) { diff --git a/src/ArcForges.ArcNotes/packages.lock.json b/src/ArcForges.ArcNotes/packages.lock.json index a5862d6..db549d0 100644 --- a/src/ArcForges.ArcNotes/packages.lock.json +++ b/src/ArcForges.ArcNotes/packages.lock.json @@ -4,9 +4,19 @@ "net10.0": { "ArcForges.Build.Policy": { "type": "Direct", - "requested": "[1.0.0-ci.7.1, )", - "resolved": "1.0.0-ci.7.1", - "contentHash": "GWH92jk3DiL470LAjYA0O4TZzh26cmqIk6KROuXdbY03zz2FaUui6YC0ty7515RC8dreP6wLBief5sC+XoGwvQ==" + "requested": "[1.0.0-ci.20.1, )", + "resolved": "1.0.0-ci.20.1", + "contentHash": "rGn/WwyV9sq38y9rlQGX9gLBqy93XOgHmUSKJ2AupR6DDfVHTHgZVwnhfGL1K9XJkRKV972GV/dFcLersJ5iVw==" + }, + "ArcForges.Contracts.PublicApi": { + "type": "Direct", + "requested": "[1.0.0-ci.36.1, )", + "resolved": "1.0.0-ci.36.1", + "contentHash": "4+pUDeDcOScTV5rEei5yri8CLvVb9309Zdl5TBcVKXBvFrowcOoPx5pkZmpqzcLQZAZ2LH6LVv8JCGURpf42zQ==", + "dependencies": { + "Google.Protobuf": "3.36.1", + "Grpc.Core.Api": "2.83.0" + } }, "Avalonia.Desktop": { "type": "Direct", @@ -517,16 +527,6 @@ "Grpc.Net.Client.Web": "[2.83.0, )" } }, - "ArcForges.Contracts.PublicApi": { - "type": "CentralTransitive", - "requested": "[1.0.0-ci.36.1, )", - "resolved": "1.0.0-ci.36.1", - "contentHash": "4+pUDeDcOScTV5rEei5yri8CLvVb9309Zdl5TBcVKXBvFrowcOoPx5pkZmpqzcLQZAZ2LH6LVv8JCGURpf42zQ==", - "dependencies": { - "Google.Protobuf": "3.36.1", - "Grpc.Core.Api": "2.83.0" - } - }, "Grpc.Net.Client": { "type": "CentralTransitive", "requested": "[2.83.0, )", diff --git a/tests/ArcForges.ArcNotes.Tests/BuildIdentityTests.cs b/tests/ArcForges.ArcNotes.Tests/BuildIdentityTests.cs new file mode 100644 index 0000000..96a9a60 --- /dev/null +++ b/tests/ArcForges.ArcNotes.Tests/BuildIdentityTests.cs @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: AGPL-3.0-only +using System.Text; +using System.Text.Json.Nodes; +using ArcForges.ArcNotes.Core; +using ArcForges.Repository; +using Xunit; + +namespace ArcForges.ArcNotes.Tests; + +public sealed class BuildIdentityTests +{ + private static string Root() + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (!File.Exists(Path.Combine(current.FullName, "eng/version-sources.json"))) current = current.Parent!; + return current.FullName; + } + + [Fact] + public void NineRealSourceKindsAreIndependentAndDeterministic() + { + // Mechanism fixture: these declarations do not claim production format/storage support. + var catalog = JsonNode.Parse(File.ReadAllText(Path.Combine(Root(), "eng/version-sources.json")))!.AsObject(); + var sources = new Dictionary(StringComparer.Ordinal); + foreach (var name in BuildIdentity.AxisNames) + { + var kind = catalog["axes"]![name]!["kind"]!.GetValue(); + catalog["axes"]![name] = new JsonObject { ["kind"] = kind, ["sources"] = new JsonArray(name + ".json") }; + sources[name + ".json"] = kind switch + { + "contracts" => "{\"schema\":\"fixture.rpc.v1\",\"descriptorSha256\":\"" + new string('a', 64) + "\",\"dirty\":false}", + "packages" => "{\"dependencies\":{\"net10.0\":{\"fixture.package\":{\"type\":\"Direct\",\"resolved\":\"1.0\"}}}}", + "native-abi" => "#define ARC_ABI_MAJOR 1\n#define ARC_ABI_MINOR 0\n", + "migrations" => "{\"migrations\":[{\"subject\":\"fixture.store\",\"version\":\"0.9\"},{\"subject\":\"fixture.store\",\"version\":\"1.0\"}]}", + _ => "{\"versions\":[{\"subject\":\"fixture.owned\",\"version\":\"1.0\"}]}" + }; + } + var first = BuildIdentity.Resolve(catalog, path => sources[path]); + Assert.Equal(9, first.Count); + foreach (var name in BuildIdentity.AxisNames) + { + Assert.Equal(name == "ContractSet" ? "1" : "1.0", first[name]!["values"]![0]!["version"]!.GetValue()); + var changed = new Dictionary(sources, StringComparer.Ordinal); + changed[name + ".json"] = name switch + { + "ContractSet" => sources[name + ".json"].Replace("rpc.v1", "rpc.v2", StringComparison.Ordinal), + "NativeAbiVersion" => sources[name + ".json"].Replace("MAJOR 1", "MAJOR 2", StringComparison.Ordinal), + _ => sources[name + ".json"].Replace("1.0", "2.0", StringComparison.Ordinal) + }; + var next = BuildIdentity.Resolve(catalog, path => changed[path]); + foreach (var axis in BuildIdentity.AxisNames) Assert.Equal(axis != name, JsonNode.DeepEquals(first[axis], next[axis])); + } + Assert.Equal(first.ToJsonString(), BuildIdentity.Resolve(catalog, path => sources[path]).ToJsonString()); + } + + [Theory] + [InlineData("missing")] + [InlineData("unknown")] + [InlineData("alias")] + [InlineData("producer")] + [InlineData("kind")] + [InlineData("duplicate")] + [InlineData("version")] + [InlineData("path")] + public void InvalidSourcesCannotProduceAReport(string mode) + { + var catalog = JsonNode.Parse(File.ReadAllText(Path.Combine(Root(), "eng/version-sources.json")))!.AsObject(); + switch (mode) + { + case "missing": catalog["axes"]!.AsObject().Remove("AppVersion"); break; + case "unknown": catalog["axes"]!["OtherVersion"] = new JsonObject(); break; + case "alias": catalog["axes"]!["AppVersion"]!["alias"] = "PackageVersion"; break; + case "producer": catalog["axes"]!["CapabilityVersion"]!.AsObject().Remove("producer"); break; + case "kind": catalog["axes"]!["AppVersion"]!["kind"] = "packages"; break; + case "path": catalog["axes"]!["AppVersion"]!["sources"]![0] = "../outside.json"; break; + } + string Read(string path) => path switch + { + "assembly/release.json" when mode == "duplicate" => "{\"versions\":[{\"subject\":\"app\",\"version\":\"1\"},{\"subject\":\"app\",\"version\":\"2\"}]}", + "assembly/release.json" when mode == "version" => "{\"versions\":[{\"subject\":\"app\",\"version\":\"AppVersion\"}]}", + "assembly/release.json" => "{\"versions\":[{\"subject\":\"app\",\"version\":\"1\"}]}", + "packages/contracts/source.json" => "{\"schema\":\"fixture.rpc.v1\",\"descriptorSha256\":\"" + new string('a', 64) + "\",\"dirty\":false}", + "src/ArcForges.ArcNotes/packages.lock.json" => "{\"dependencies\":{}}", + _ => "{\"versions\":[{\"subject\":\"app\",\"version\":\"1\"}]}" + }; + Assert.Throws(() => BuildIdentity.Resolve(catalog, Read)); + } + + [Theory] + [InlineData("sourceCommit")] + [InlineData("sourceDateEpoch")] + [InlineData("dirty")] + [InlineData("buildId")] + [InlineData("kind")] + [InlineData("axis")] + public void RehashedSupportReportsCannotChangeSourceOrRun(string field) + { + var root = Root(); + var commit = IdentityEvidence.Git(root, "rev-parse", "HEAD"); + var report = IdentityEvidence.ExpectedReport(root, "0.1.0-ci.7.1", commit); + IdentityEvidence.Verify(Encoding.UTF8.GetBytes(report.ToJsonString()), root, "0.1.0-ci.7.1", commit); + switch (field) + { + case "sourceCommit": report["build"]![field] = new string('b', 40); break; + case "sourceDateEpoch": report["build"]![field] = 1; break; + case "dirty": report["build"]![field] = true; break; + case "buildId": report["build"]![field] = "another-run"; break; + case "kind": report["build"]![field] = "unknown"; break; + case "axis": report["axes"]!["ContractSet"]!["values"]![0]!["version"] = "36"; break; + } + Assert.Throws(() => IdentityEvidence.Verify(Encoding.UTF8.GetBytes(report.ToJsonString()), root, "0.1.0-ci.7.1", commit)); + } + + [Fact] + public void DirtyOrIncompleteCiIdentityIsRejected() + { + var local = new JsonObject + { + ["sourceCommit"] = new string('a', 40), + ["sourceDateEpoch"] = 1L, + ["dirty"] = true, + ["kind"] = "local", + ["buildId"] = "local." + new string('a', 40), + ["runId"] = null, + ["runAttempt"] = null, + ["pipelineRun"] = null + }; + BuildIdentity.ValidateBuild(local); + local["kind"] = "ci"; + local["runId"] = "123"; + local["runAttempt"] = 1; + local["buildId"] = "123.1"; + local["pipelineRun"] = "https://github.com/ArcForges/ArcNotes/actions/runs/123"; + Assert.Throws(() => BuildIdentity.ValidateBuild(local)); + local["dirty"] = false; + BuildIdentity.ValidateBuild(local); + local["runAttempt"] = 0; + Assert.Throws(() => BuildIdentity.ValidateBuild(local)); + } +} diff --git a/tests/ArcForges.ArcNotes.Tests/CandidateTests.cs b/tests/ArcForges.ArcNotes.Tests/CandidateTests.cs index 9dcbae8..fd3df14 100644 --- a/tests/ArcForges.ArcNotes.Tests/CandidateTests.cs +++ b/tests/ArcForges.ArcNotes.Tests/CandidateTests.cs @@ -55,6 +55,8 @@ public void ZipAndTarNoticesAreActuallyRead(string rid) [InlineData("win-x64", "dirty")] [InlineData("win-x64", "case-collision")] [InlineData("win-x64", "escape")] + [InlineData("win-x64", "missing-identity")] + [InlineData("win-x64", "changed-identity")] [InlineData("linux-x64", "missing")] [InlineData("linux-x64", "changed")] [InlineData("linux-x64", "duplicate")] @@ -62,6 +64,8 @@ public void ZipAndTarNoticesAreActuallyRead(string rid) [InlineData("linux-x64", "dirty")] [InlineData("linux-x64", "case-collision")] [InlineData("linux-x64", "escape")] + [InlineData("linux-x64", "missing-identity")] + [InlineData("linux-x64", "changed-identity")] public void MatchingOuterHashesCannotHideMissingLegalTextOrWrongSource(string rid, string mode) { using var fixture = new CandidateFixture(rid: rid, mode: mode); @@ -72,7 +76,7 @@ private sealed class CandidateFixture : IDisposable { private readonly string _folder = Path.Combine(Path.GetTempPath(), "arcnotes-test-" + Guid.NewGuid()); public string Version => "0.1.0-ci.23.1"; - public string Commit => new('a', 40); + public string Commit => IdentityEvidence.Git(SourceRoot, "rev-parse", "HEAD"); public string SourceRoot { get; } public string Manifest => Path.Combine(_folder, "manifest.json"); public string Archive { get; } @@ -86,6 +90,14 @@ public CandidateFixture(string? unsafeArchive = null, bool success = true, strin Directory.CreateDirectory(_folder); var entries = ProvenancePolicy.PackageNotices(SourceRoot).Select(n => (n.Key, n.Value)).ToList(); entries.Add(("notices/provenance-source.json", Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new { result = "passed", sourceCommit = mode == "wrong-source" ? new string('b', 40) : Commit, dirty = mode == "dirty" })))); + entries.Add(("build-identity.json", Encoding.UTF8.GetBytes(IdentityEvidence.ExpectedReport(SourceRoot, Version, Commit).ToJsonString()))); + if (mode == "missing-identity") entries.RemoveAll(e => e.Key == "build-identity.json"); + if (mode == "changed-identity") + { + var changed = IdentityEvidence.ExpectedReport(SourceRoot, Version, Commit); + changed["axes"]!["ContractSet"]!["values"]![0]!["version"] = "36"; + entries[entries.FindIndex(e => e.Key == "build-identity.json")] = ("build-identity.json", Encoding.UTF8.GetBytes(changed.ToJsonString())); + } if (mode == "missing") entries.RemoveAll(e => e.Key == "LICENSE"); if (mode == "changed") entries[entries.FindIndex(e => e.Key == "LICENSE")] = ("LICENSE", Encoding.UTF8.GetBytes("abbreviated licence\n")); if (mode == "duplicate") entries.Add(entries.Single(e => e.Key == "LICENSE")); diff --git a/tests/ArcForges.ArcNotes.Tests/packages.lock.json b/tests/ArcForges.ArcNotes.Tests/packages.lock.json index 1c461af..059ffc9 100644 --- a/tests/ArcForges.ArcNotes.Tests/packages.lock.json +++ b/tests/ArcForges.ArcNotes.Tests/packages.lock.json @@ -4,9 +4,9 @@ "net10.0": { "ArcForges.Build.Policy": { "type": "Direct", - "requested": "[1.0.0-ci.7.1, )", - "resolved": "1.0.0-ci.7.1", - "contentHash": "GWH92jk3DiL470LAjYA0O4TZzh26cmqIk6KROuXdbY03zz2FaUui6YC0ty7515RC8dreP6wLBief5sC+XoGwvQ==" + "requested": "[1.0.0-ci.20.1, )", + "resolved": "1.0.0-ci.20.1", + "contentHash": "rGn/WwyV9sq38y9rlQGX9gLBqy93XOgHmUSKJ2AupR6DDfVHTHgZVwnhfGL1K9XJkRKV972GV/dFcLersJ5iVw==" }, "Grpc.AspNetCore": { "type": "Direct", @@ -201,7 +201,10 @@ } }, "arcforges.repository": { - "type": "Project" + "type": "Project", + "dependencies": { + "ArcForges.ArcNotes.Core": "[1.0.0, )" + } }, "ArcForges.Contracts.PublicApi": { "type": "CentralTransitive",