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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
<Authors>ArcForges</Authors>
<RepositoryUrl>https://github.com/ArcForges/ArcNotes</RepositoryUrl>
<PackageLicenseExpression>AGPL-3.0-only</PackageLicenseExpression>
<SourceRevisionId Condition="'$(SourceRevisionId)' == ''">local</SourceRevisionId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ArcForges.Build.Policy" PrivateAssets="all" />
Expand Down
19 changes: 19 additions & 0 deletions Directory.Build.targets
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
<Project>
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
<Target Name="ArcNotesResolveSourceIdentity" BeforeTargets="ArcForgesStampBuildIdentity">
<Exec Command="git rev-parse HEAD" WorkingDirectory="$(MSBuildThisFileDirectory)" ConsoleToMSBuild="true" StandardOutputImportance="Low">
<Output TaskParameter="ConsoleOutput" PropertyName="_ArcNotesCommit" />
</Exec>
<Exec Command="git status --porcelain" WorkingDirectory="$(MSBuildThisFileDirectory)" ConsoleToMSBuild="true" StandardOutputImportance="Low">
<Output TaskParameter="ConsoleOutput" PropertyName="_ArcNotesChanges" />
</Exec>
<Error Condition="'$(SourceRevisionId)' != '' and '$(SourceRevisionId)' != '$(_ArcNotesCommit)'" Text="AFB001: Declared source differs from the actual checkout." />
<PropertyGroup>
<SourceRevisionId>$(_ArcNotesCommit)</SourceRevisionId>
<ArcForgesSourceCommit>$(_ArcNotesCommit)</ArcForgesSourceCommit>
<_ArcNotesDirty>false</_ArcNotesDirty>
<_ArcNotesDirty Condition="'$(_ArcNotesChanges)' != ''">true</_ArcNotesDirty>
</PropertyGroup>
<Error Condition="'$(GITHUB_ACTIONS)' == 'true' and '$(_ArcNotesDirty)' != 'false'" Text="AFB001: CI source must be clean." />
<ItemGroup>
<AssemblyMetadata Include="ArcForges.Dirty" Value="$(_ArcNotesDirty)" />
</ItemGroup>
</Target>
<Target Name="ArcForgesGetLicenceBoundary" Returns="@(_ArcForgesLicenceDeclaration)">
<ItemGroup>
<_ArcForgesLicenceDeclaration Include="$(MSBuildProjectFullPath)">
Expand Down
2 changes: 1 addition & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="ArcForges.Build.Policy" Version="1.0.0-ci.7.1" />
<PackageVersion Include="ArcForges.Build.Policy" Version="1.0.0-ci.20.1" />
<PackageVersion Include="ArcForges.Contracts.PublicApi" Version="1.0.0-ci.36.1" />
<PackageVersion Include="Avalonia.Desktop" Version="12.1.2" />
<PackageVersion Include="Avalonia.Themes.Fluent" Version="12.1.2" />
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 <absolute-path.json>` writes offline support metadata from the compiled application. See [build identity](docs/build-identity.md).
35 changes: 35 additions & 0 deletions docs/build-identity.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions eng/ArcForges.Repository/ArcForges.Repository.csproj
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><OutputType>Exe</OutputType></PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../src/ArcForges.ArcNotes.Core/ArcForges.ArcNotes.Core.csproj" />
</ItemGroup>
<PropertyGroup>
<LicenceBoundary>AGPL</LicenceBoundary>
<PackageLicenseExpression>AGPL-3.0-only</PackageLicenseExpression>
Expand Down
120 changes: 120 additions & 0 deletions eng/ArcForges.Repository/IdentityEvidence.cs
Original file line number Diff line number Diff line change
@@ -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<string>() != 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<string, string>(StringComparer.Ordinal)
{
["ArcForges.SourceCommit"] = commit,
["ArcForges.Dirty"] = build["dirty"]!.GetValue<bool>() ? "true" : "false",
["ArcForges.BuildId"] = build["buildId"]!.GetValue<string>(),
["ArcForges.BuildKind"] = build["kind"]!.GetValue<string>(),
["ArcForges.PipelineRun"] = build["pipelineRun"]?.GetValue<string>() ?? "local",
["ArcForges.SourceDateEpoch"] = build["sourceDateEpoch"]!.GetValue<long>().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<string, string?>(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.");
}
}
14 changes: 10 additions & 4 deletions eng/ArcForges.Repository/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public static async Task<int> 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");
Expand Down Expand Up @@ -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('.');
Expand Down Expand Up @@ -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));
Expand All @@ -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";
Expand All @@ -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);
Expand Down Expand Up @@ -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 ||
Expand Down
Loading
Loading