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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,5 @@ classification, immutable reuse records, full notices and portable-archive check
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).

The [dependency admission gate](docs/dependency-policy.md) checks the complete locked package closure, immutable inputs, public publisher boundaries and upgrade evidence.
13 changes: 13 additions & 0 deletions docs/dependency-policy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Dependency admission (WP02.05)

`eng/policy/dependency-policy.json` admits the complete 91 package-version closure from all committed NuGet locks, including build tools and platform assets not distributed by current CI. `DependencyPolicy.Validate` runs inside the existing repository `check`; its offline fixtures run inside the existing Linux unit-test pass. No new workflow or runtime test is introduced.

Each admission retains the exact NuGet SHA-512 content identity, package licence declaration, cached published nuspec SHA-256, immutable upstream source commit, dependency class and maintenance assessment. The ANGLE package uses a licence file: its three-clause BSD terms were reviewed directly from the already restored package and its hash is recorded. Package SPDX metadata describes the package's declaration, not a relicensing of bundled native material. Existing source provenance, complete distributed package notices and locked restore continue to govern that material. No package was downloaded or upgraded to build this inventory.

Only the existing public Contracts package and private build-time Build.Policy are admitted first-party inputs. Repository identity is checked against their owning publisher; internal generated Contracts cannot enter the public consumer. `NuGet.Config` retains nuget.org as its only feed. Restore verifies committed package hashes; the static gate does not pretend to authenticate new registry bytes without restore. Publisher credentials stay solely with producer workflows; this consumer has none.

The current channel is `foundation-candidate`. Only exact reviewed first-party CI versions may use that channel exception. Stable admission rejects every prerelease in the closure. Locked macOS/WebAssembly transitive assets do not imply macOS CI or corresponding distributed artifacts. Native admission covers the unchanged Avalonia/Skia/HarfBuzz framework closure; adding a DesktopPlatform capability requires its own reviewed native admission and later integration evidence.

`eng/policy/dependency-review.json` seals all dependency/build/feed/workflow inputs with LF-normalized SHA-256 hashes and records the unchanged framework baseline. A changed input must receive a reviewed record and all relevant evidence. The checker also reads all prior admission snapshots from Git history: an existing coordinate can never acquire a different content hash, even after removal, reintroduction or a new review. CI checks out full history. Dependency updates require compilation, applicable Windows/Linux AOT, compatibility, licence, security and SBOM assessment. Framework major upgrades additionally require an explicit runtime-posture assessment. The recorded baseline must be an ancestor of accepted origin/main, and its declared framework versions must equal the actual Git source at that commit; a successor review cannot reset the baseline to evade this obligation. Runtime/performance/migration checks are local opt-in only for affected behavior in an existing environment; missing coverage is stated. Never provision toolchains or create empty caches solely to increase validation, and never repeat public-download/install/hash cycles.

The initial maintenance assessment retains known pinned inputs and the existing security/update-review process. It makes no unverified promise of current upstream support or vulnerability absence. Product owners review maintenance, replacement options and vulnerabilities when admitting an update. Historical WP02 stage evidence is reusable; actual tooling publication belongs to DesktopPlatform and consumer functional acceptance remains WP03/WP06/WP50.
174 changes: 174 additions & 0 deletions eng/ArcForges.Repository/DependencyPolicy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
// SPDX-License-Identifier: AGPL-3.0-only
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Xml.Linq;

namespace ArcForges.Repository;

/// <summary>Offline admission of the owner's complete locked dependency closure.</summary>
public static partial class DependencyPolicy
{
private const string Feed = "https://api.nuget.org/v3/index.json";
private static readonly Dictionary<string, string> Publishers = new(StringComparer.OrdinalIgnoreCase)
{
["ArcForges.Build.Policy"] = "https://github.com/ArcForges/DesktopPlatform",
["ArcForges.Contracts.PublicApi"] = "https://github.com/ArcForges/Contracts"
};

// Historical Git snapshots retain every admitted coordinate, including removed dependencies.
// A new review may admit a new version but cannot rewrite bytes under a previous version.
public static void ValidateHistory(string currentPolicy, IEnumerable<string> priorPolicies)
{
using var current = JsonDocument.Parse(currentPolicy);
var hashes = current.RootElement.GetProperty("packages").EnumerateArray()
.ToDictionary(p => Text(p, "id") + "/" + Text(p, "version"), p => Text(p, "contentHash"), StringComparer.OrdinalIgnoreCase);
foreach (var snapshot in priorPolicies)
{
using var prior = JsonDocument.Parse(snapshot);
foreach (var package in prior.RootElement.GetProperty("packages").EnumerateArray())
{
var coordinate = Text(package, "id") + "/" + Text(package, "version");
if (hashes.TryGetValue(coordinate, out var hash))
Require(hash == Text(package, "contentHash"), "Immutable historical coordinate changed: " + coordinate);
else hashes.Add(coordinate, Text(package, "contentHash"));
}
}
}

public static void ValidateBaseline(string reviewJson, string baselineGlobalJson, string baselinePackagesXml)
{
using var review = JsonDocument.Parse(reviewJson);
using var sdk = JsonDocument.Parse(baselineGlobalJson);
var declared = review.RootElement.GetProperty("baselineFrameworkVersions");
var avalonia = XDocument.Parse(baselinePackagesXml).Descendants("PackageVersion")
.Single(p => (string?)p.Attribute("Include") == "Avalonia.Desktop").Attribute("Version")!.Value;
Require(Text(declared, "dotnet") == Text(sdk.RootElement.GetProperty("sdk"), "version") && Text(declared, "avalonia") == avalonia,
"Framework baseline reset does not match accepted Git source.");
}

public static void Validate(string root, IEnumerable<string> inventory)
{
var files = inventory.Distinct(StringComparer.Ordinal).ToArray();
string Read(string path)
{
var full = Path.GetFullPath(Path.Combine(root, path));
var relative = Path.GetRelativePath(root, full);
Require(!Path.IsPathRooted(relative) && relative != ".." && !relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal), "Dependency input escapes owner.");
return File.ReadAllText(full);
}
using var document = JsonDocument.Parse(Read("eng/policy/dependency-policy.json"));
var policy = document.RootElement;
Require(policy.GetProperty("schemaVersion").GetInt32() == 1 && Text(policy, "repository") == "ArcNotes" && Text(policy, "licenceBoundary") == "AGPL", "Invalid dependency owner.");
Require(Text(policy, "channel") is "foundation-candidate" or "stable", "Invalid dependency channel.");
Require(Text(policy, "feed") == Feed, "Wrong dependency feed.");
Require(Text(policy, "nativeAdmission") == "existing-framework-closure-only; new native slots require DesktopPlatform admission", "Unreviewed native admission.");
var admitted = new Dictionary<string, JsonElement>(StringComparer.OrdinalIgnoreCase);
foreach (var entry in policy.GetProperty("packages").EnumerateArray())
{
var id = Text(entry, "id");
var version = Text(entry, "version");
Require(ExactVersion().IsMatch(version), "Floating dependency version.");
Require(admitted.TryAdd(id + "/" + version, entry), "Duplicate dependency admission.");
var licence = Text(entry, "licence");
Require(licence is "MIT" or "Apache-2.0" or "BSD-3-Clause" || licence == "AGPL-3.0-only" && id == "ArcForges.Build.Policy", "Forbidden dependency licence.");
Require(Convert.FromBase64String(Text(entry, "contentHash")).Length == 64, "Invalid lock integrity.");
Require(Sha40().IsMatch(Text(entry, "sourceCommit")) && Sha256().IsMatch(Text(entry, "nuspecSha256")), "Floating source tag or missing exact source evidence.");
Require(Uri.TryCreate(Text(entry, "sourceRepository"), UriKind.Absolute, out var source) && source.Scheme == "https", "Untrusted source location.");
Require(Text(entry, "maintenanceAssessment").Length >= 30 && Text(entry, "licenceEvidence").Length >= 20, "Missing dependency review.");
Require(Text(entry, "dependencyClass") is "framework" or "transport" or "tooling" or "library", "Missing dependency class.");
if (id.StartsWith("ArcForges.", StringComparison.OrdinalIgnoreCase))
Require(Publishers.TryGetValue(id, out var publisher) && publisher == Text(entry, "sourceRepository"), "Wrong publisher or internal contract import.");
if (version.Contains('-', StringComparison.Ordinal))
Require(Text(policy, "channel") == "foundation-candidate" && Publishers.ContainsKey(id) && Text(entry, "previewAdmission") == "exact-foundation-candidate", "Preview dependency on stable core path.");
}

var observed = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var path in files.Where(p => Path.GetFileName(p) == "packages.lock.json"))
{
using var locked = JsonDocument.Parse(Read(path));
foreach (var framework in locked.RootElement.GetProperty("dependencies").EnumerateObject())
foreach (var package in framework.Value.EnumerateObject())
{
if (Text(package.Value, "type") == "Project") continue;
var identity = package.Name + "/" + Text(package.Value, "resolved");
Require(admitted.TryGetValue(identity, out var admission), "Unadmitted dependency: " + identity);
Require(Text(package.Value, "contentHash") == Text(admission, "contentHash"), "Mutable version: locked package bytes changed.");
observed.Add(identity);
if (package.Value.TryGetProperty("dependencies", out var edges))
foreach (var edge in edges.EnumerateObject())
Require(framework.Value.EnumerateObject().Any(p => p.Name.Equals(edge.Name, StringComparison.OrdinalIgnoreCase)) ||
framework.Name.Contains('/', StringComparison.Ordinal) &&
locked.RootElement.GetProperty("dependencies").GetProperty(framework.Name.Split('/')[0]).EnumerateObject()
.Any(p => p.Name.Equals(edge.Name, StringComparison.OrdinalIgnoreCase)), "Incomplete transitive closure.");
}
}
Require(observed.Count > 0 && observed.SetEquals(admitted.Keys), "Dependency closure drift.");
foreach (var path in files.Where(p => Path.GetExtension(p) is ".csproj" or ".props" or ".targets"))
foreach (var element in XDocument.Parse(Read(path)).Descendants())
{
if (element.Name.LocalName == "PackageVersion")
{
var id = (string?)element.Attribute("Include") ?? "";
var version = (string?)element.Attribute("Version") ?? "";
Require(ExactVersion().IsMatch(version) && admitted.ContainsKey(id + "/" + version), "Floating or unadmitted central dependency.");
}
if (element.Name.LocalName == "PackageReference")
Require(element.Attribute("Version") is null && element.Attribute("VersionOverride") is null && !element.Elements().Any(e => e.Name.LocalName is "Version" or "VersionOverride"), "Noncentral dependency override.");
Require(element.Name.LocalName is not "RestoreSources" and not "RestoreAdditionalProjectSources", "Untrusted restore source override.");
}
var config = XDocument.Parse(Read("NuGet.Config"));
var sources = config.Descendants("packageSources").Elements("add").ToArray();
Require(sources.Length == 1 && (string?)sources[0].Attribute("key") == "nuget.org" && (string?)sources[0].Attribute("value") == Feed, "Wrong package publisher feed.");
Require(!files.Any(p => Path.GetFileName(p).Equals("nuget.config", StringComparison.OrdinalIgnoreCase) && p != "NuGet.Config"), "Unreviewed nested feed configuration.");
foreach (var path in files.Where(p => p.StartsWith(".github/workflows/", StringComparison.Ordinal) && Path.GetExtension(p) is ".yml" or ".yaml"))
foreach (Match action in ActionReference().Matches(Read(path)))
Require(action.Groups[1].Value.StartsWith("./", StringComparison.Ordinal) || ImmutableAction().IsMatch(action.Groups[1].Value), "Floating workflow action tag.");

using var reviewDocument = JsonDocument.Parse(Read(Text(policy, "reviewRecord")));
var review = reviewDocument.RootElement;
Require(Text(review, "owner") == "ArcNotes" && Text(review, "decision") == "approved" && Sha40().IsMatch(Text(review, "baselineCommit")), "Missing upgrade review.");
Require(DateOnly.TryParseExact(Text(review, "reviewedOn"), "yyyy-MM-dd", out _), "Invalid review date.");
var requiredInputs = files.Where(IsDependencyInput).Append("eng/policy/dependency-policy.json").Order(StringComparer.Ordinal).ToArray();
var reviewedInputs = review.GetProperty("inputs").EnumerateObject().Select(p => p.Name).Order(StringComparer.Ordinal).ToArray();
Require(requiredInputs.SequenceEqual(reviewedInputs), "Missing dependency input review.");
foreach (var path in requiredInputs)
Require(HashText(Read(path)) == Text(review.GetProperty("inputs"), path), "Dependency inputs changed without matching upgrade evidence: " + path);
foreach (var check in new[] { "compilation", "aot", "compatibility", "licence", "security", "sbom", "localRuntime", "performance", "migration" })
Require(Text(review.GetProperty("checks"), check).Length >= 20, "Missing upgrade evidence: " + check);
var frameworkVersions = new Dictionary<string, string>(StringComparer.Ordinal);
using var sdk = JsonDocument.Parse(Read("global.json"));
frameworkVersions.Add("dotnet", Text(sdk.RootElement.GetProperty("sdk"), "version"));
frameworkVersions.Add("avalonia", admitted.Values.First(p => Text(p, "id") == "Avalonia").GetProperty("version").GetString()!);
foreach (var framework in frameworkVersions)
{
var baseline = Text(review.GetProperty("baselineFrameworkVersions"), framework.Key);
Require(ExactVersion().IsMatch(baseline), "Missing framework baseline.");
if (baseline.Split('.')[0] != framework.Value.Split('.')[0])
Require(Text(review, "runtimePostureAssessment").Length >= 40 && review.GetProperty("frameworkMajorUpgrade").GetBoolean(), "Framework major upgrade requires runtime posture evidence.");
}
}

public static bool IsDependencyInput(string path) => Path.GetFileName(path) is "packages.lock.json" or "global.json" or "NuGet.Config" ||
Path.GetExtension(path) is ".csproj" or ".props" or ".targets" || path is "third-party/sources.json" or "eng/policy/reuse-policy.json" or "eng/policy/licence-boundary.json" ||
path.StartsWith(".github/workflows/", StringComparison.Ordinal) && Path.GetExtension(path) is ".yml" or ".yaml";

public static string HashText(string text) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(text.Replace("\r\n", "\n", StringComparison.Ordinal))));
private static string Text(JsonElement value, string property) => value.GetProperty(property).GetString() ?? "";
private static void Require(bool condition, string message)
{
if (!condition) throw new InvalidOperationException(message);
}

[GeneratedRegex(@"^\d+\.\d+\.\d+(?:\.\d+)*(?:-[0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*)?$")]
private static partial Regex ExactVersion();
[GeneratedRegex("^[a-f0-9]{40}$")]
private static partial Regex Sha40();
[GeneratedRegex("^[a-f0-9]{64}$")]
private static partial Regex Sha256();
[GeneratedRegex(@"(?m)^\s*(?:-\s*)?uses:\s*([^\s#]+)")]
private static partial Regex ActionReference();
[GeneratedRegex("^[A-Za-z0-9_.-]+/[A-Za-z0-9_./-]+@[a-f0-9]{40}$")]
private static partial Regex ImmutableAction();
}
27 changes: 27 additions & 0 deletions eng/ArcForges.Repository/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,33 @@ private static async Task Check()
}
}
Console.WriteLine("Repository text, structured inputs and whitespace checks passed.");
DependencyPolicy.Validate(Directory.GetCurrentDirectory(), files);
using var dependencyReview = JsonDocument.Parse(File.ReadAllText("eng/policy/dependency-review.json"));
var dependencyBaseline = dependencyReview.RootElement.GetProperty("baselineCommit").GetString()!;
await Run("git", ["merge-base", "--is-ancestor", dependencyBaseline, "origin/main"]);
DependencyPolicy.ValidateBaseline(File.ReadAllText("eng/policy/dependency-review.json"),
await Capture("git", ["show", dependencyBaseline + ":global.json"]),
await Capture("git", ["show", dependencyBaseline + ":Directory.Packages.props"]));
if ((await Capture("git", ["rev-parse", "--is-shallow-repository"])).Trim() != "false")
throw new InvalidOperationException("Full Git admission history is required.");
var dependencyHistory = new List<string>();
foreach (var revision in (await Capture("git", ["log", "--format=%H", "--", "eng/policy/dependency-policy.json"])).Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if ((await Capture("git", ["ls-tree", "--name-only", revision, "--", "eng/policy/dependency-policy.json"])).Length > 0)
dependencyHistory.Add(await Capture("git", ["show", revision + ":eng/policy/dependency-policy.json"]));
}
DependencyPolicy.ValidateHistory(File.ReadAllText("eng/policy/dependency-policy.json"), dependencyHistory);
Directory.CreateDirectory("artifacts/evidence");
await File.WriteAllTextAsync("artifacts/evidence/dependency-policy.json", JsonSerializer.Serialize(new
{
repository = "ArcNotes",
result = "passed",
sourceCommit = (await Capture("git", ["rev-parse", "HEAD"])).Trim(),
policySha256 = DependencyPolicy.HashText(File.ReadAllText("eng/policy/dependency-policy.json")),
reviewSha256 = DependencyPolicy.HashText(File.ReadAllText("eng/policy/dependency-review.json")),
scope = "offline dependency admission; no new runtime or public-artifact validation"
}, Json) + "\n");
Console.WriteLine("Dependency admission, immutable inputs and upgrade review passed.");
var projects = LicencePolicy.Validate(Directory.GetCurrentDirectory(), files);
var evaluated = new List<object>();
foreach (var project in projects)
Expand Down
Loading
Loading