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",