diff --git a/.github/workflows/package-validation.yml b/.github/workflows/package-validation.yml index 5c8fce5..ef07cd1 100644 --- a/.github/workflows/package-validation.yml +++ b/.github/workflows/package-validation.yml @@ -62,7 +62,7 @@ jobs: PACKAGE_VERSION: ${{ steps.version.outputs.value }} shell: pwsh run: | - dotnet build DesktopPlatform.slnx -c Release --no-restore "-p:PackageVersion=$env:PACKAGE_VERSION" + dotnet build DesktopPlatform.slnx -c Release --no-restore "-p:PackageVersion=$env:PACKAGE_VERSION" "-p:Version=$env:PACKAGE_VERSION" if ($LASTEXITCODE) { throw 'Platform build failed.' } dotnet test --project tests/ArchitectureTests/ArcForges.Tests.ArchitectureTests.csproj -c Release --no-build if ($LASTEXITCODE) { throw 'Platform ownership tests failed.' } diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml index 678df63..c6654c9 100644 --- a/.github/workflows/pr-gate.yml +++ b/.github/workflows/pr-gate.yml @@ -93,6 +93,8 @@ jobs: python-version-file: .python-version - name: Reject source, export, citation and dependency drift run: python -m unittest discover -s eng -p test_design_policy.py -v + - name: Reject aliased version axes and invalid build identities + run: python -m unittest discover -s eng -p test_build_identity.py -v - name: Verify exports against an isolated immutable Design checkout run: python eng/design_policy.py - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 diff --git a/README.md b/README.md index c3bc5aa..9723239 100644 --- a/README.md +++ b/README.md @@ -120,3 +120,6 @@ historical dispositions and planned directory owners to exact source and candida Python-running CI jobs select `.python-version`. Install the reviewed hook tooling with `python -m pip install --require-hashes -r eng/requirements-ci.txt`; all transitive tools are exact and hash-verified. After downloading that closure to a wheel directory, `pip install --no-index --find-links --require-hashes -r eng/requirements-ci.txt` verifies an offline repeat. Dependency updates review the complete closure and hashes. For ordinary local native compilation, reuse the already installed vcpkg and suitable installed dependencies. Pass the existing installed-root to the build or use ignored local configuration; do not reinstall vcpkg or rebuild working dependencies just for a patch-version difference. The admitted CI candidate still uses the committed producer baseline and provenance. Local compatibility results record the actual tools and do not attest arbitrary local binaries as the publishable candidate. + +[Build identity and independent version axes](docs/build-identity.md) documents package reports, compiled +metadata, runtime retrieval and the distinction between current ABI probes and future product schemas. diff --git a/docs/build-identity.md b/docs/build-identity.md new file mode 100644 index 0000000..8233dda --- /dev/null +++ b/docs/build-identity.md @@ -0,0 +1,32 @@ +# Independent versions and build identity + +WP02.04 follows the accepted [nine-owner profile](https://github.com/ArcForges/ArcForges-Design/blob/main/docs/assurance/wp02-04-version-identity-profile.md). +`eng/version-sources.json` declares all nine axes. NativeAbiVersion reads the actual C header constants; +PackageVersion reads committed NuGet locks and the audited native SBOM dependency closure. AppVersion +and business contracts are not applicable to these library foundations. Future capability descriptors, +portable formats, migrations, product policy and extensions remain explicitly not produced, with their +future producer named. Neither a package release nor the ABI probes imply those features exist. + +Each NuGet contains `build-identity.json` (`arcforges.build-identity.v1`): artifact coordinates, all nine +axes with source digests, and source/run identity. Verification independently resolves the axes from +the verifier checkout and sealed native SBOMs, compares the original candidate build record, and rejects +modified inner reports even after an archive's outer checksum is updated. Publication checks the real +GitHub source/run; consumer reruns may read the original producer attempt and never relabel its bytes. + +Build.Policy stamps every owned managed assembly, including tools. Packing passes the allocated version +to both Version and PackageVersion. The ArchitectureTests read actual PE metadata for every solution +assembly, comparing source and timestamp against Git and run identity against the CI execution. +CMake and the independent Visual Studio native path generate the same build-info suffix before compiling +owned code. The existing native exports, ABI1.0, exact buffer sizing and error behavior remain unchanged. +No third-party library is rewritten or rebuilt to add ArcForges identity. + +The source timestamp is commit time, not wall-clock compilation time. Native and managed jobs have +their own preserved build/run records; a later consumer retry does not manufacture a new identity. +The ordinary and Native AOT package-only C# consumers retrieve managed assembly and native export +metadata. The independent C17 caller checks the same native suffix using packaged headers/libraries. + +Run `python -m unittest discover -s eng -p test_build_identity.py -v` for synthetic nine-source mutation, +invalid/absent axes, duplicate subjects, aliases and real Git identity rejection tests. These mechanism +fixtures do not assert production implementations for absent axes. Run the README build/architecture +suite and the packaging guide for actual binary and package evidence. Build-local results and CI/public +package evidence remain distinct. diff --git a/docs/reconciliation-inventory.md b/docs/reconciliation-inventory.md index 523ec9e..0bee8a5 100644 --- a/docs/reconciliation-inventory.md +++ b/docs/reconciliation-inventory.md @@ -13,3 +13,7 @@ Run `python -m unittest discover -s eng -p test_reconciliation.py -v`, then `pyt The WP00 receipt supplies the already verified public NuGet/npm/Maven/native/desktop/Web/Android/Cloud/AI evidence. The checker validates its exact hash and source identities, not current registry availability or new runtime behavior. Changes to the inventory require review against actual Git trees and the producing step; do not edit a count to hide a missing entry. The existing runtime, licence and provenance checks remain required. Full contract assignment, shared content review, native surface work, test-family mapping and physical moves remain WP01.01 through WP01.05. Reviewed implementation changes to an existing DesktopPlatform project use `project-updates.json`: retain the original snapshot blob, record the exact reviewed replacement blob, producing step and merged Design authority. The checker still verifies every original snapshot and rejects other project content drift, new/missing projects and source escapes. WP01.03 records only the NativeAbiTests oracle relocation. + +WP02.04 records the reviewed native CMake changes for build-identity generation and explicit target +dependencies, under Design commit `257f77ce8d476a4efd8746fc0b7e4e6358c32a67`. Original snapshots and +all unrelated project blobs remain checked. diff --git a/eng/build_identity.py b/eng/build_identity.py new file mode 100644 index 0000000..0c5ee6d --- /dev/null +++ b/eng/build_identity.py @@ -0,0 +1,256 @@ +# SPDX-License-Identifier: AGPL-3.0-only +"""Resolve independent compatibility axes and immutable, non-secret build identity.""" +import copy +import hashlib +import json +import os +from pathlib import Path +import re +import subprocess + +AXES = ('AppVersion', 'ContractSet', 'CapabilityVersion', 'NativeFormatVersion', + 'StorageSchemaVersion', 'NativeAbiVersion', 'PolicySchemaVersion', + 'ExtensionProtocolVersion', 'PackageVersion') +KINDS = dict(zip(AXES, ('release', 'contracts', 'declarations', 'declarations', + 'migrations', 'native-abi', 'declarations', 'declarations', 'packages'), strict=True)) + + +def require(condition, message): + if not condition: + raise ValueError(message) + + +def canonical(value): + return (json.dumps(value, indent=2, sort_keys=True, ensure_ascii=True) + '\n').encode('utf-8') + + +def git(root, *args): + env = {k: v for k, v in os.environ.items() if not k.upper().startswith('GIT_')} + return subprocess.check_output(['git', *args], cwd=root, env=env, text=True, encoding='utf-8').strip() + + +def build_identity(root, environment=None): + env = os.environ if environment is None else environment + commit = git(root, 'rev-parse', 'HEAD') + require(re.fullmatch('[a-f0-9]{40}', commit), 'Expected a full source commit.') + identity = {'sourceCommit': commit, 'sourceDateEpoch': int(git(root, 'show', '-s', '--format=%ct', 'HEAD')), + 'dirty': bool(git(root, 'status', '--porcelain')), 'kind': 'local', + 'buildId': 'local.' + commit, 'pipelineRun': None, 'runId': None, 'runAttempt': None} + if env.get('GITHUB_ACTIONS') == 'true': + require(env.get('GITHUB_SHA') == commit, 'CI source differs from checked-out commit.') + number, attempt, repository = (env.get(k, '') for k in + ('GITHUB_RUN_ID', 'GITHUB_RUN_ATTEMPT', 'GITHUB_REPOSITORY')) + require(re.fullmatch('[1-9][0-9]*', number) and re.fullmatch('[1-9][0-9]*', attempt) + and re.fullmatch('[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+', repository), 'Incomplete CI identity.') + identity.update(kind='ci', buildId=number + '.' + attempt, runId=number, runAttempt=attempt, + pipelineRun=f'https://github.com/{repository}/actions/runs/{number}') + require(not identity['dirty'], 'CI candidate source is dirty.') + validate_identity(identity) + return identity + + +def validate_identity(identity, commit=None, publish=False): + require(set(identity) == {'sourceCommit', 'sourceDateEpoch', 'dirty', 'kind', 'buildId', + 'pipelineRun', 'runId', 'runAttempt'}, 'Unknown/missing identity fields.') + require(re.fullmatch('[a-f0-9]{40}', identity['sourceCommit']), 'Invalid source identity.') + require(commit is None or identity['sourceCommit'] == commit, 'Build source mismatch.') + require(type(identity['sourceDateEpoch']) is int and identity['sourceDateEpoch'] > 0 + and type(identity['dirty']) is bool, 'Invalid timestamp/dirty state.') + require(identity['kind'] in {'ci', 'local'}, 'Invalid build kind.') + if identity['kind'] == 'ci': + number, attempt = identity['runId'], identity['runAttempt'] + require(isinstance(number, str) and re.fullmatch('[1-9][0-9]*', number) + and isinstance(attempt, str) and re.fullmatch('[1-9][0-9]*', attempt), 'Invalid pipeline run/attempt.') + require(identity['buildId'] == number + '.' + attempt and not identity['dirty'], 'Invalid CI build identity.') + require(isinstance(identity['pipelineRun'], str) and re.fullmatch( + r'https://github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/actions/runs/' + number, + identity['pipelineRun']), 'Pipeline run mismatch.') + else: + require(identity['buildId'] == 'local.' + identity['sourceCommit'] + and all(identity[k] is None for k in ('pipelineRun', 'runId', 'runAttempt')), 'Invalid local identity.') + require(not publish or identity['kind'] == 'ci', 'Only a clean CI identity is publishable.') + + +def source(root, relative): + require(isinstance(relative, str) and relative and '\\' not in relative + and not Path(relative).is_absolute() and '..' not in Path(relative).parts, + 'Unsafe version source path.') + for part in [root / relative, *(root / relative).parents]: + if part == root: + break + require(not part.is_symlink(), 'Linked version sources are forbidden.') + path = (root / relative).resolve() + require(path.is_relative_to(root.resolve()) and path.is_file() and not path.is_symlink(), 'Missing/escaping version source.') + # Git text is LF-normalized so checkout newline conversion is not a version change. + text = path.read_text(encoding='utf-8').replace('\r\n', '\n') + return text, {'path': relative, 'sha256Lf': hashlib.sha256(text.encode('utf-8')).hexdigest()} + + +def resolve_axes(root, catalog, release=None, packages=()): + require(set(catalog) == {'schemaVersion', 'owner', 'axes'} and catalog['schemaVersion'] == 1, + 'Invalid version source catalog.') + require(set(catalog['axes']) == set(AXES), 'Exactly nine version axes are required.') + result = {} + for axis in AXES: + config = catalog['axes'][axis] + require(set(config) <= {'kind', 'sources', 'absence', 'reason', 'producer', 'subject'} + and config.get('kind') == KINDS[axis], 'Invalid/cross-axis source resolver: ' + axis) + values = [] + for relative in config.get('sources', []): + text, evidence = source(root, relative) + kind = config['kind'] + if kind == 'native-abi': + major = re.search(r'#define ARC_NATIVE_ABI_MAJOR UINT32_C\((\d+)\)', text) + minor = re.search(r'#define ARC_NATIVE_ABI_MINOR UINT32_C\((\d+)\)', text) + require(major and minor, 'Missing actual ABI constants.') + rows = [{'subject': 'arc-native', 'version': major[1] + '.' + minor[1]}] + elif kind == 'contracts': + namespaces = re.findall(r'^package ([a-z][a-z0-9_.]+)\.v([1-9][0-9]*);', text, re.MULTILINE) + require(namespaces, 'Missing authored contract namespace version.') + rows = [{'subject': name, 'version': version} for name, version in namespaces] + elif kind == 'migrations': + migrations = json.loads(text) + require(isinstance(migrations, list) and migrations, 'Empty declared migration set.') + require(all(set(m) == {'version', 'subject'} and type(m['version']) is int + and m['version'] > 0 for m in migrations), 'Invalid migration head source.') + rows = [{'subject': name, 'version': str(max(m['version'] for m in migrations if m['subject'] == name))} + for name in sorted({m['subject'] for m in migrations})] + elif kind == 'declarations': + rows = json.loads(text) + require(isinstance(rows, list) and rows, 'Empty version declaration file.') + else: + raise ValueError('Unexpected file source for ' + axis) + for row in rows: + require(set(row) == {'subject', 'version'}, 'Unknown declaration or cross-axis alias.') + values.append({**row, 'source': evidence}) + if config['kind'] == 'release' and config.get('subject'): + require(isinstance(release, str), 'Missing independent application release input.') + values.append({'subject': config['subject'], 'version': release, + 'source': {'input': 'allocated-application-release'}}) + if config['kind'] == 'packages': + values.extend(copy.deepcopy(packages)) + if values: + result[axis] = {'status': 'present', 'values': sorted(values, key=lambda row: row['subject'])} + else: + require(config.get('absence') in {'not-applicable', 'not-produced'} and config.get('reason'), + 'Absent axis requires explicit applicability: ' + axis) + require(config['absence'] != 'not-produced' or config.get('producer'), 'Missing future producer.') + result[axis] = {'status': config['absence'], 'values': [], 'reason': config['reason']} + if config.get('producer'): + result[axis]['producer'] = config['producer'] + validate_axes(result) + return result + + +def validate_axes(axes): + require(set(axes) == set(AXES), 'Exactly nine version axes are required.') + for name, axis in axes.items(): + require(axis.get('status') in {'present', 'not-applicable', 'not-produced'}, 'Invalid axis state.') + require(set(axis) == ({'status', 'values'} if axis['status'] == 'present' else + {'status', 'values', 'reason'} | ({'producer'} if 'producer' in axis else set())), 'Unknown axis fields.') + values = axis['values'] + require(isinstance(values, list) and (bool(values) == (axis['status'] == 'present')), 'Invalid axis values.') + require(len({v['subject'] for v in values}) == len(values), 'Duplicate version subject: ' + name) + if not values: + require(isinstance(axis['reason'], str) and axis['reason'].strip(), 'Empty absence reason.') + require(axis['status'] != 'not-produced' or bool(axis.get('producer')), 'Missing later producer.') + for row in values: + require(set(row) == {'subject', 'version', 'source'} and isinstance(row['subject'], str) + and 0 < len(row['subject']) <= 256, 'Malformed version subject.') + version = row['version'] + require(isinstance(version, str) and version not in AXES and re.fullmatch( + r'[0-9][A-Za-z0-9.+:-]{0,127}', version), 'Invalid version or cross-axis alias.') + evidence = row['source'] + require(isinstance(evidence, dict), 'Version source evidence is missing.') + if set(evidence) == {'input'}: + require(name == 'AppVersion' and evidence['input'] == 'allocated-application-release', + 'Invalid application release evidence.') + else: + keys = set(evidence) + require(keys in ({'path', 'sha256Lf'}, {'artifact', 'sha256'}), 'Unknown source evidence.') + path_key, hash_key = ('path', 'sha256Lf') if 'path' in evidence else ('artifact', 'sha256') + path = evidence[path_key] + require(isinstance(path, str) and path and not Path(path).is_absolute() + and '..' not in Path(path).parts and '\\' not in path, 'Unsafe evidence path.') + require(isinstance(evidence[hash_key], str) and re.fullmatch('[a-f0-9]{64}', evidence[hash_key]), + 'Invalid source digest.') + + +def dependency_versions(root, native_directory=None, native_documents=()): + result = {} + for relative in git(root, 'ls-files').splitlines(): + if not relative.endswith('packages.lock.json'): + continue + text, evidence = source(root, relative) + for target in json.loads(text)['dependencies'].values(): + for name, package in target.items(): + if package['type'] == 'Project': + continue + version = package['resolved'] + subject = 'nuget:' + name + '@' + version + result[subject] = {'subject': subject, 'version': version, 'source': evidence} + documents = list(native_documents) + if native_directory: + documents.extend((path.parent.name + '/sbom.json', path.read_bytes()) + for path in sorted(native_directory.glob('*/sbom.json'))) + for name, raw in sorted(documents): + evidence = {'artifact': name, 'sha256': hashlib.sha256(raw).hexdigest()} + for package in json.loads(raw)['buildDependencies']: + version = package['version'] + subject = 'vcpkg:' + package['name'] + ':' + package['triplet'] + '@' + version + result[subject] = {'subject': subject, 'version': version, 'source': evidence} + return [result[key] for key in sorted(result)] + + +def report(root, artifact, version, native_directory=None): + catalog = json.loads((root / 'eng/version-sources.json').read_text(encoding='utf-8')) + return {'schema': 'arcforges.build-identity.v1', 'owner': catalog['owner'], + 'artifact': {'id': artifact, 'version': version}, 'build': build_identity(root), + 'axes': resolve_axes(root, catalog, packages=dependency_versions(root, native_directory))} + + +def verify_report(value, artifact, version, commit, publish=False): + require(set(value) == {'schema', 'owner', 'artifact', 'build', 'axes'} + and value['schema'] == 'arcforges.build-identity.v1' and value['owner'] == 'DesktopPlatform', + 'Unknown build report schema/owner.') + require(value['artifact'] == {'id': artifact, 'version': version}, 'Build report artifact mismatch.') + validate_identity(value['build'], commit, publish) + validate_axes(value['axes']) + + +def verify_source_build(root, identity): + validate_identity(identity) + require(identity['sourceCommit'] == git(root, 'rev-parse', 'HEAD'), 'Verifier checkout source mismatch.') + require(identity['sourceDateEpoch'] == int(git(root, 'show', '-s', '--format=%ct', 'HEAD')), + 'Source timestamp mismatch.') + if os.environ.get('GITHUB_ACTIONS') == 'true': + validate_identity(identity, os.environ['GITHUB_SHA'], publish=True) + require(identity['runId'] == os.environ['GITHUB_RUN_ID'] + and int(identity['runAttempt']) <= int(os.environ['GITHUB_RUN_ATTEMPT']) + and identity['pipelineRun'] == 'https://github.com/' + os.environ['GITHUB_REPOSITORY'] + + '/actions/runs/' + os.environ['GITHUB_RUN_ID'], 'Unexpected producer pipeline identity.') + + +def native_suffix(identity): + validate_identity(identity) + return ''.join(';' + key + '=' + str(value) for key, value in ( + ('source', identity['sourceCommit']), ('build', identity['buildId']), + ('run', identity['pipelineRun'] or 'local'), ('sourceDateEpoch', identity['sourceDateEpoch']), + ('kind', identity['kind']), ('dirty', str(identity['dirty']).lower()))) + + +def write_native_header(root, output): + identity = build_identity(root) + content = ('// SPDX-License-Identifier: AGPL-3.0-only\n#pragma once\n' + 'inline constexpr char arc_build_identity[] = ' + json.dumps(native_suffix(identity)) + ';\n').encode('utf-8') + output.parent.mkdir(parents=True, exist_ok=True) + if not output.exists() or output.read_bytes() != content: + output.write_bytes(content) + + +if __name__ == '__main__': + import argparse + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--native-header', type=Path, required=True) + args = parser.parse_args() + write_native_header(Path(__file__).resolve().parents[1], args.native_header) diff --git a/eng/native_provenance.py b/eng/native_provenance.py index 2732d33..25c993f 100644 --- a/eng/native_provenance.py +++ b/eng/native_provenance.py @@ -434,7 +434,7 @@ def verify(package: str, read, names: set[str], root: Path = ROOT) -> dict: members = {r["path"]: r["sha256"] for r in receipt["files"]} require(len(members) == len(receipt["files"]), "Duplicate native receipt member") # NuGet adds the package metadata and root README/LICENSE; the native stage is otherwise closed. - metadata = names & {package + ".nuspec", "[Content_Types].xml", "_rels/.rels", "README.md", "LICENSE", ".signature.p7s", + metadata = names & {package + ".nuspec", "[Content_Types].xml", "_rels/.rels", "README.md", "LICENSE", ".signature.p7s", "build-identity.json", "package/services/metadata/core-properties/nuget.psmdcp"} require(names - metadata - {RECEIPT} == set(members), "Native candidate membership differs from sealed producer") for name, digest in members.items(): diff --git a/eng/packaging/native.py b/eng/packaging/native.py index 336d75c..a9429c5 100644 --- a/eng/packaging/native.py +++ b/eng/packaging/native.py @@ -15,6 +15,7 @@ ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT / "eng")) import native_provenance +import build_identity VCPKG_COMMIT = "36677bbd0b3bf11da7376e62e14bffcc54d2eaeb" @@ -251,7 +252,8 @@ def stage(directory, vcpkg, installed_root): available = {path.name.lower(): path for path in (binary_root / "native").glob("*.dll")} available.update({path.name.lower(): path for path in crt.glob("*.dll")}) entries = [p for p in json.loads((ROOT / "eng/packaging/packages.json").read_text())["packages"] if p["kind"] == "native"] - artifact = {"schemaVersion": 1, "sourceCommit": commit, "rid": "win-x64", "packages": []} + identity = build_identity.build_identity(ROOT) + artifact = {"schemaVersion": 1, "sourceCommit": commit, "rid": "win-x64", "packages": [], "build": identity} for entry in entries: destination = directory / entry["id"] runtime = destination / "runtimes/win-x64/native" @@ -277,6 +279,17 @@ def stage(directory, vcpkg, installed_root): if not system_dependency(dependency): pending.append(dependency) owned = selected[entry["library"].lower() + ".dll"] + library = ctypes.CDLL(str(runtime / owned['name']), winmode=0x900) + class Buffer(ctypes.Structure): + _fields_ = [('data', ctypes.c_void_p), ('capacity', ctypes.c_uint64), ('required', ctypes.c_uint64)] + probe = getattr(library, entry['prefix'] + '_get_build_info') + probe.argtypes = [ctypes.POINTER(Buffer)] + probe.restype = ctypes.c_int32 + output = ctypes.create_string_buffer(4096) + buffer = Buffer(ctypes.cast(output, ctypes.c_void_p), len(output), 0) + require(probe(ctypes.byref(buffer)) == 0 and buffer.required < len(output), 'Native build identity probe failed.') + require(output.raw[:buffer.required].decode('utf-8').endswith(build_identity.native_suffix(identity)), + 'Native binary build identity differs from the actual producer.') require(set(owned["exports"]) == {entry["prefix"] + suffix for suffix in ["_get_abi_version", "_get_build_info", "_get_last_error"]}, "Owned native export set differs from the admitted ABI.") for original, relative in [(ROOT / entry["header"], "include/arc/" + Path(entry["header"]).name), @@ -358,6 +371,7 @@ def verify_stage(directory, commit): def verify_identity(artifact, commit): require(artifact["schemaVersion"] == 1 and artifact["sourceCommit"] == commit and artifact["rid"] == "win-x64", "Native artifact source/RID mismatch.") + build_identity.verify_source_build(ROOT, artifact['build']) expected = {p["id"] for p in json.loads((ROOT / "eng/packaging/packages.json").read_text())["packages"] if p["kind"] == "native"} require(len(artifact["packages"]) == len(expected) and {p["id"] for p in artifact["packages"]} == expected, "Native artifact package set mismatch.") diff --git a/eng/packaging/native_consumer.py b/eng/packaging/native_consumer.py index 9ff6492..f1c40db 100644 --- a/eng/packaging/native_consumer.py +++ b/eng/packaging/native_consumer.py @@ -34,7 +34,7 @@ def consume(directory, version, commit): root = Path(tempfile.mkdtemp(prefix="arcforges-native-consumer-")).resolve() packages.require(not root.is_relative_to(packages.ROOT), "Consumer must not inherit producer build files.") print("Native consumer evidence: " + str(root), flush=True) - env = os.environ.copy() + env = {key: value for key, value in os.environ.items() if not key.startswith('GITHUB_')} env["NUGET_PACKAGES"] = str(root / ".packages") env["NUGET_HTTP_CACHE_PATH"] = str(root / ".http-cache") (root / "global.json").write_bytes((packages.ROOT / "global.json").read_bytes()) @@ -53,6 +53,8 @@ def consume(directory, version, commit): runtimes = [entry for entry in packages.catalogue() if entry["kind"] == "native"] cases = [(entry["library"], [entry]) for entry in runtimes] + [("All", runtimes)] evidence = {"sourceCommit": commit, "version": version, "rid": "win-x64", "packages": manifest["packages"], "cases": []} + native_build = json.loads((directory / 'native-artifact.json').read_text(encoding='utf-8'))['build'] + expected_suffix = packages.build_identity.native_suffix(native_build) evidence_root = packages.ROOT / "artifacts/native-consumer-evidence" evidence_root.mkdir(parents=True, exist_ok=True) published = None @@ -86,6 +88,10 @@ def consume(directory, version, commit): api = f"{name}.{family}Abi" program += f'''if ({api}.GetAbiVersion() != new NativeAbiVersion(1, 0)) throw new Exception("ABI mismatch"); Console.WriteLine({api}.GetBuildInfo()); +if (!{api}.GetBuildInfo().EndsWith({json.dumps(expected_suffix)}, StringComparison.Ordinal)) throw new Exception("Native build identity mismatch"); +var metadata{family} = System.Reflection.CustomAttributeExtensions.GetCustomAttributes(typeof({api}).Assembly).ToDictionary(a => a.Key, a => a.Value); +if (metadata{family}["ArcForges.SourceCommit"] != "{commit}" || metadata{family}["ArcForges.BuildId"] != "{manifest['build']['buildId']}" || metadata{family}["ArcForges.PipelineRun"] != "{manifest['build']['pipelineRun'] or 'local'}" || metadata{family}["ArcForges.SourceDateEpoch"] != "{manifest['build']['sourceDateEpoch']}") throw new Exception("Managed build identity mismatch"); +if (System.Reflection.CustomAttributeExtensions.GetCustomAttribute(typeof({api}).Assembly)?.InformationalVersion.Split('+')[0] != "{version}") throw new Exception("Managed release mismatch"); if ({api}.GetLastError().Status != NativeStatus.Ok) throw new Exception("Initial error state"); unsafe {{ uint minor; if (Faults.{family}(null, &minor) != -1) throw new Exception("Invalid argument accepted"); }} var error{family} = {api}.GetLastError(); @@ -156,7 +162,7 @@ def consume(directory, version, commit): finally: original.write_bytes(content) print("PASS: modified native DLL rejected before loading.", flush=True) - c_consumer(root, published, runtimes, version, env) + c_consumer(root, published, runtimes, version, env, expected_suffix) execute(published / "Consumer.exe", root, env) evidence["c17"] = {"result": "passed", "exeSha256": hashlib.sha256((published / "ConsumerC.exe").read_bytes()).hexdigest()} evidence["rejections"] = ["wrong-rid", "missing-owned-dll", "missing-transitive-dll", "changed-dll"] @@ -164,7 +170,7 @@ def consume(directory, version, commit): print("Complete packaged native consumer validation passed. Evidence: " + str(root), flush=True) -def c_consumer(root, published, entries, version, env): +def c_consumer(root, published, entries, version, env, expected_suffix): source = '#include \n#include \n#include \n' includes = [] libraries = [] @@ -180,9 +186,10 @@ def c_consumer(root, published, entries, version, env): if ({prefix}_get_abi_version(&major, &minor) != ARC_OK || major != 1 || minor != 0) return 1; arc_mut_buffer_t query = {{0}}; if ({prefix}_get_build_info(&query) != ARC_BUFFER_TOO_SMALL || query.required > 4096 || query.required == 0) return 2; - char text[4096]; + char text[4096] = {{0}}; arc_mut_buffer_t output = {{text, sizeof(text), 0}}; if ({prefix}_get_build_info(&output) != ARC_OK || output.required != query.required) return 3; + if (output.required >= sizeof(text) || strstr(text, {json.dumps(expected_suffix)}) == NULL) return 7; if ({prefix}_get_abi_version(NULL, &minor) != ARC_INVALID_ARGUMENT) return 4; arc_error_info_t error = {{0}}; error.struct_size = sizeof(error); error.struct_version = 1; diff --git a/eng/packaging/packages.json b/eng/packaging/packages.json index a649d6d..ef447bd 100644 --- a/eng/packaging/packages.json +++ b/eng/packaging/packages.json @@ -10,7 +10,9 @@ "build/ArcForges.Build.Policy.targets", "README.md", "LICENSE", - "NOTICE.md" + "NOTICE.md", + "build-identity.json", + "build/ArcForges.Build.Metadata.targets" ] }, { @@ -22,7 +24,8 @@ "lib/net10.0/ArcForges.Native.Abstractions.dll", "README.md", "LICENSE", - "NOTICE.md" + "NOTICE.md", + "build-identity.json" ] }, { @@ -36,7 +39,8 @@ "lib/net10.0/ArcForges.Native.Media.dll", "README.md", "LICENSE", - "NOTICE.md" + "NOTICE.md", + "build-identity.json" ] }, { @@ -50,7 +54,8 @@ "lib/net10.0/ArcForges.Native.Colour.dll", "README.md", "LICENSE", - "NOTICE.md" + "NOTICE.md", + "build-identity.json" ] }, { @@ -64,7 +69,8 @@ "lib/net10.0/ArcForges.Native.Image.dll", "README.md", "LICENSE", - "NOTICE.md" + "NOTICE.md", + "build-identity.json" ] }, { @@ -78,7 +84,8 @@ "lib/net10.0/ArcForges.Native.Otio.dll", "README.md", "LICENSE", - "NOTICE.md" + "NOTICE.md", + "build-identity.json" ] }, { @@ -109,7 +116,8 @@ "include/arc/arc_media_abi.h", "include/arc/arc_native_abi.h", "sdk/win-x64/lib/ArcMediaNative.lib", - "buildTransitive/ArcForges.Native.Media.Runtime.win-x64.targets" + "buildTransitive/ArcForges.Native.Media.Runtime.win-x64.targets", + "build-identity.json" ] }, { @@ -138,7 +146,8 @@ "include/arc/arc_slate_color_abi.h", "include/arc/arc_native_abi.h", "sdk/win-x64/lib/ArcSlateColorNative.lib", - "buildTransitive/ArcForges.Native.Colour.Runtime.win-x64.targets" + "buildTransitive/ArcForges.Native.Colour.Runtime.win-x64.targets", + "build-identity.json" ] }, { @@ -167,7 +176,8 @@ "include/arc/arc_slate_image_abi.h", "include/arc/arc_native_abi.h", "sdk/win-x64/lib/ArcSlateImageNative.lib", - "buildTransitive/ArcForges.Native.Image.Runtime.win-x64.targets" + "buildTransitive/ArcForges.Native.Image.Runtime.win-x64.targets", + "build-identity.json" ] }, { @@ -196,7 +206,8 @@ "include/arc/arc_slate_otio_abi.h", "include/arc/arc_native_abi.h", "sdk/win-x64/lib/ArcSlateOtioNative.lib", - "buildTransitive/ArcForges.Native.Otio.Runtime.win-x64.targets" + "buildTransitive/ArcForges.Native.Otio.Runtime.win-x64.targets", + "build-identity.json" ] } ] diff --git a/eng/packaging/packages.py b/eng/packaging/packages.py index 7df4c21..37ea3e7 100644 --- a/eng/packaging/packages.py +++ b/eng/packaging/packages.py @@ -12,6 +12,7 @@ import zipfile import native +import build_identity ROOT = Path(__file__).resolve().parents[2] REPOSITORY = "https://github.com/ArcForges/DesktopPlatform" @@ -135,10 +136,13 @@ def pack(directory, package_version, native_directory=ROOT / "artifacts/native-p require(not audit["dirty"], "Commit reviewed changes before producing source-bound NuGet candidates.") commit = source_commit() native.verify_stage(native_directory, commit) + identity = build_identity.build_identity(ROOT) + axes = build_identity.resolve_axes(ROOT, json.loads((ROOT / 'eng/version-sources.json').read_text(encoding='utf-8')), + packages=build_identity.dependency_versions(ROOT, native_directory)) packages = [] for entry in catalogue(): args = ["dotnet", "pack", entry["project"], "-c", "Release", "--no-restore", "-o", str(directory), - f"-p:PackageVersion={package_version}", f"-p:RepositoryCommit={commit}"] + f"-p:PackageVersion={package_version}", f"-p:Version={package_version}", f"-p:RepositoryCommit={commit}"] if entry["kind"] == "native": args.append(f"-p:NativePayloadRoot={native_directory / entry['id']}") run(*args) @@ -149,6 +153,12 @@ def pack(directory, package_version, native_directory=ROOT / "artifacts/native-p with zipfile.ZipFile(package_path) as original: contents = [(info, original.read(info.filename)) for info in original.infolist()] for index, (info, data) in enumerate(contents): + if info.filename == '[Content_Types].xml': + types = ET.fromstring(data) + if not any(node.get('Extension') == 'json' for node in types): + ET.SubElement(types, '{http://schemas.openxmlformats.org/package/2006/content-types}Default', + Extension='json', ContentType='application/json') + contents[index] = (info, ET.tostring(types, encoding='utf-8', xml_declaration=True)) if info.filename.endswith(".nuspec"): specification = ET.fromstring(data) namespace = specification.tag.split("}")[0][1:] @@ -169,13 +179,16 @@ def pack(directory, package_version, native_directory=ROOT / "artifacts/native-p with zipfile.ZipFile(package_path, "w", compression=zipfile.ZIP_DEFLATED) as archive: for info, data in contents: archive.writestr(info, data) + report = {'schema': 'arcforges.build-identity.v1', 'owner': 'DesktopPlatform', + 'artifact': {'id': entry['id'], 'version': package_version}, 'build': identity, 'axes': axes} + archive.writestr('build-identity.json', build_identity.canonical(report)) digest = inspect(directory / name, entry, package_version, commit) packages.append({"id": entry["id"], "version": package_version, "file": name, "sha256": digest}) native_artifact = (native_directory / "native-artifact.json").read_bytes() (directory / "native-artifact.json").write_bytes(native_artifact) manifest = {"schemaVersion": 1, "repository": REPOSITORY, "sourceCommit": commit, "version": package_version, "packages": packages, - "nativeArtifactSha256": hashlib.sha256(native_artifact).hexdigest()} + "nativeArtifactSha256": hashlib.sha256(native_artifact).hexdigest(), "build": identity} (directory / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") verify(directory, package_version, commit) @@ -190,6 +203,14 @@ def verify(directory, package_version, commit=None): native_artifact = json.loads(native_bytes) native.verify_identity(native_artifact, commit) entries = catalogue() + build_identity.verify_source_build(ROOT, manifest['build']) + documents = [] + for entry in entries: + if entry['kind'] == 'native': + with zipfile.ZipFile(directory / f"{entry['id']}.{package_version}.nupkg") as archive: + documents.append((entry['id'] + '/sbom.json', archive.read('sbom.json'))) + axes = build_identity.resolve_axes(ROOT, json.loads((ROOT / 'eng/version-sources.json').read_text(encoding='utf-8')), + packages=build_identity.dependency_versions(ROOT, native_documents=documents)) rows = manifest["packages"] require(len(rows) == len(entries) and {row["id"] for row in rows} == {entry["id"] for entry in entries}, "Manifest does not match publication allowlist.") @@ -202,6 +223,11 @@ def verify(directory, package_version, commit=None): require(row["file"] == name and row["version"] == package_version, "Manifest package name/version mismatch.") digest = inspect(directory / name, entry, package_version, commit) require(digest == row["sha256"], f"Package hash mismatch: {name}") + with zipfile.ZipFile(directory / name) as archive: + report = json.loads(archive.read('build-identity.json')) + build_identity.verify_report(report, entry['id'], package_version, commit) + require(report['build'] == manifest['build'], 'Packaged build identity differs from producer.') + require(report['axes'] == axes, 'Packaged version axes differ from independent sources.') if entry["kind"] == "native": producer = next(p for p in native_artifact["packages"] if p["id"] == entry["id"]) with zipfile.ZipFile(directory / name) as archive: @@ -214,11 +240,16 @@ def verify(directory, package_version, commit=None): def smoke(directory, package_version, commit=None): verify(directory, package_version, commit) + smoke_policy(directory, package_version) + + +def smoke_policy(directory, package_version): + """Exercise the build-only package; complete release acceptance also requires verify().""" # Outside every checkout, with no inherited Directory.Build files or shared package cache. temporary_root = Path(tempfile.mkdtemp(prefix="arcforges-package-consumer-")).resolve() require(not temporary_root.is_relative_to(ROOT), "Consumer must be outside the producer checkout.") print(f"Isolated consumer and evidence: {temporary_root}", flush=True) - env = os.environ.copy() + env = {key: value for key, value in os.environ.items() if not key.startswith('GITHUB_')} env["NUGET_PACKAGES"] = str(temporary_root / ".packages") env["NUGET_HTTP_CACHE_PATH"] = str(temporary_root / ".http-cache") env["CI"] = "false" # First fixture restore creates its lock; the next is explicitly locked. @@ -251,7 +282,11 @@ def smoke(directory, package_version, commit=None): ''' central.write_text(central_text, encoding="utf-8") - (consumer / "Program.cs").write_text('Console.WriteLine("package-consumer-ok");\n', encoding="utf-8") + (consumer / "Program.cs").write_text('''using System.Reflection; +var metadata = typeof(Program).Assembly.GetCustomAttributes().ToDictionary(a => a.Key, a => a.Value); +if (metadata["ArcForges.BuildKind"] != "local" || metadata["ArcForges.BuildId"] != "local.local" || metadata["ArcForges.SourceDateEpoch"] != "0") throw new Exception("Local fixture metadata mismatch"); +Console.WriteLine("package-consumer-ok"); +''', encoding="utf-8") run("dotnet", "restore", str(project), "--use-lock-file", "--configfile", str(temporary_root / "NuGet.config"), cwd=consumer, env=env) env["CI"] = "true" run("dotnet", "restore", str(project), "--locked-mode", "--configfile", str(temporary_root / "NuGet.config"), cwd=consumer, env=env) @@ -267,6 +302,8 @@ def smoke(directory, package_version, commit=None): central.write_text(central_text, encoding="utf-8") run("dotnet", "build", str(project), "-c", "Release", "--no-restore", "-p:LangVersion=preview", cwd=consumer, env=env, expected_error="AFP004") run("dotnet", "build", str(project), "-c", "Release", "--no-restore", "-p:RestoreLockedMode=false", cwd=consumer, env=env, expected_error="AFP005") + run("dotnet", "build", str(project), "-c", "Release", "--no-restore", "-p:ArcForgesBuildKind=ci", cwd=consumer, env=env, expected_error="AFP006") + run("dotnet", "build", str(project), "-c", "Release", "--no-restore", "-p:ArcForgesBuildKind=unknown", cwd=consumer, env=env, expected_error="AFP006") installed = temporary_root / ".packages" / entry["id"].lower() / package_version.lower() restored = installed / f"{entry['id'].lower()}.{package_version.lower()}.nupkg" original = directory / f"{entry['id']}.{package_version}.nupkg" diff --git a/eng/packaging/test_packages.py b/eng/packaging/test_packages.py index 2c7cee5..ca691f8 100644 --- a/eng/packaging/test_packages.py +++ b/eng/packaging/test_packages.py @@ -114,6 +114,24 @@ def test_missing_upstream_notice_is_rejected(self): with self.assertRaisesRegex(ValueError, "Missing upstream licence/source"): packages.verify(target, manifest["version"], manifest["sourceCommit"]) + def test_changed_axis_rejected_after_rehashing_archive(self): + def tamper(files, _): + report = json.loads(files['build-identity.json']) + report['axes']['NativeAbiVersion']['values'][0]['version'] = '9.0' + files['build-identity.json'] = json.dumps(report).encode() + target, manifest = self.mutate_native(tamper) + with self.assertRaisesRegex(ValueError, 'independent sources'): + packages.verify(target, manifest['version'], manifest['sourceCommit']) + + def test_changed_run_rejected_after_rehashing_archive(self): + def tamper(files, _): + report = json.loads(files['build-identity.json']) + report['build']['buildId'] = 'incorrect' + files['build-identity.json'] = json.dumps(report).encode() + target, manifest = self.mutate_native(tamper) + with self.assertRaisesRegex(ValueError, 'identity'): + packages.verify(target, manifest['version'], manifest['sourceCommit']) + if __name__ == "__main__": unittest.main() diff --git a/eng/policy/reconciliation/project-updates.json b/eng/policy/reconciliation/project-updates.json index 6caeebf..a5c643e 100644 --- a/eng/policy/reconciliation/project-updates.json +++ b/eng/policy/reconciliation/project-updates.json @@ -8,5 +8,65 @@ "authorityCommit": "7abe9bc4c1964a23d5dc3894dbe3a0357ec31a00", "authorityPath": "docs/assurance/wp01-03-native-reconciliation-policy.md", "reason": "Move the independent native oracle into its test assembly; remove the NativeInterop reference and enable unsafe ABI declarations in the test project." + }, + { + "repository": "DesktopPlatform", + "path": "native/CMakeLists.txt", + "originalBlob": "87000cb87754a69b5f62df1a30652c4212522b3a", + "reviewedBlob": "6e4184f8f28696855c0761d016f4f9ecc9b15964", + "producer": "WP02.04", + "authorityCommit": "257f77ce8d476a4efd8746fc0b7e4e6358c32a67", + "authorityPath": "docs/assurance/wp02-04-version-identity-profile.md", + "reason": "Generate the source/run identity header before compiling owned native ABI libraries; preserve target ownership, dependency closure and ABI signatures." + }, + { + "repository": "DesktopPlatform", + "path": "native/arcgraphics-metal-abi/CMakeLists.txt", + "originalBlob": "892ac82b793b6d2d8abacae3c44c49bf2879c40c", + "reviewedBlob": "bb1954369ace6bb336ccc9ec9e9b534036bbf4e3", + "producer": "WP02.04", + "authorityCommit": "257f77ce8d476a4efd8746fc0b7e4e6358c32a67", + "authorityPath": "docs/assurance/wp02-04-version-identity-profile.md", + "reason": "Generate the source/run identity header before compiling owned native ABI libraries; preserve target ownership, dependency closure and ABI signatures." + }, + { + "repository": "DesktopPlatform", + "path": "native/arcmedia-ffmpeg-abi/CMakeLists.txt", + "originalBlob": "c38d3134451034092dc2ea0b31931ae853543814", + "reviewedBlob": "2793175aab2a3be9be79dcec8de243fc4dc572ac", + "producer": "WP02.04", + "authorityCommit": "257f77ce8d476a4efd8746fc0b7e4e6358c32a67", + "authorityPath": "docs/assurance/wp02-04-version-identity-profile.md", + "reason": "Generate the source/run identity header before compiling owned native ABI libraries; preserve target ownership, dependency closure and ABI signatures." + }, + { + "repository": "DesktopPlatform", + "path": "native/arcslate-color-abi/CMakeLists.txt", + "originalBlob": "eff3fc8659b5e34a2af131d93602e50b297a9cdf", + "reviewedBlob": "16757d9aac9cbc7f2cfcc4a764790f6fb0c90b33", + "producer": "WP02.04", + "authorityCommit": "257f77ce8d476a4efd8746fc0b7e4e6358c32a67", + "authorityPath": "docs/assurance/wp02-04-version-identity-profile.md", + "reason": "Generate the source/run identity header before compiling owned native ABI libraries; preserve target ownership, dependency closure and ABI signatures." + }, + { + "repository": "DesktopPlatform", + "path": "native/arcslate-image-abi/CMakeLists.txt", + "originalBlob": "19fc63e8d6c563fb3ca86214dabd37be06507834", + "reviewedBlob": "bbc5c82721bc49bee518e6eaa9ebeb91f81513f6", + "producer": "WP02.04", + "authorityCommit": "257f77ce8d476a4efd8746fc0b7e4e6358c32a67", + "authorityPath": "docs/assurance/wp02-04-version-identity-profile.md", + "reason": "Generate the source/run identity header before compiling owned native ABI libraries; preserve target ownership, dependency closure and ABI signatures." + }, + { + "repository": "DesktopPlatform", + "path": "native/arcslate-otio-abi/CMakeLists.txt", + "originalBlob": "966e3702b40213f530e8dff8bf91eccd58e74578", + "reviewedBlob": "d0149927067c6183d7a709d688dbed3f3b35eaa7", + "producer": "WP02.04", + "authorityCommit": "257f77ce8d476a4efd8746fc0b7e4e6358c32a67", + "authorityPath": "docs/assurance/wp02-04-version-identity-profile.md", + "reason": "Generate the source/run identity header before compiling owned native ABI libraries; preserve target ownership, dependency closure and ABI signatures." } ] diff --git a/eng/provenance/files.json b/eng/provenance/files.json index 572d40a..1f0cb60 100644 --- a/eng/provenance/files.json +++ b/eng/provenance/files.json @@ -36,6 +36,7 @@ "SECURITY.md", "artifacts/evidence/traceability/feature-trace-bridge.json", "deploy/README.md", + "docs/build-identity.md", "docs/compliance/third-party-license-register.md", "docs/design-policy.md", "docs/dev-conventions.md", @@ -49,6 +50,7 @@ "docs/runtime-ownership.md", "eng/build/desktop-aot.props", "eng/build/desktop-rids.props", + "eng/build_identity.py", "eng/cmake/LicenceBoundary.cmake", "eng/design_corpus.py", "eng/design_graph.py", @@ -202,11 +204,13 @@ "eng/reference_baselines.py", "eng/requirements-ci.txt", "eng/runtime_ownership.py", + "eng/test_build_identity.py", "eng/test_design_policy.py", "eng/test_licence_boundary.py", "eng/test_reconciliation.py", "eng/test_reference_baselines.py", "eng/test_runtime_ownership.py", + "eng/version-sources.json", "global.json", "native/CMakeLists.txt", "native/arcgraphics-metal-abi/CMakeLists.txt", @@ -270,6 +274,7 @@ "native/windows/ArcForges.Native.props", "src/Build/ArcForges.Build.Policy/ArcForges.Build.Policy.csproj", "src/Build/ArcForges.Build.Policy/README.md", + "src/Build/ArcForges.Build.Policy/build/ArcForges.Build.Metadata.targets", "src/Build/ArcForges.Build.Policy/build/ArcForges.Build.Policy.props", "src/Build/ArcForges.Build.Policy/build/ArcForges.Build.Policy.targets", "src/Build/ArcForges.Build.Policy/packages.lock.json", @@ -355,6 +360,7 @@ "src/Native/ArcForges.Native.Otio/README.md", "src/Native/ArcForges.Native.Otio/packages.lock.json", "tests/ArchitectureTests/ArcForges.Tests.ArchitectureTests.csproj", + "tests/ArchitectureTests/BuildIdentityTests.cs", "tests/ArchitectureTests/RepositoryPolicyTests.cs", "tests/ArchitectureTests/packages.lock.json", "tests/NativeAbiTests/ArcForges.Tests.NativeAbiTests.csproj", diff --git a/eng/test_build_identity.py b/eng/test_build_identity.py new file mode 100644 index 0000000..e7df0ec --- /dev/null +++ b/eng/test_build_identity.py @@ -0,0 +1,135 @@ +# SPDX-License-Identifier: AGPL-3.0-only +"""Synthetic resolver independence and real Git identity rejection tests.""" +import copy +import json +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +import build_identity as identity + + +class BuildIdentityTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.catalog = {'schemaVersion': 1, 'owner': 'fixture', 'axes': {}} + for axis, kind in identity.KINDS.items(): + config = {'kind': kind} + if kind == 'release': + config['subject'] = 'fixture-app' + elif kind != 'packages': + config['sources'] = [axis + '.txt'] + self.write_axis(axis, 1) + self.catalog['axes'][axis] = config + self.write_lock('1.0.0') + + def write_lock(self, version): + (self.root / 'packages.lock.json').write_text(json.dumps({'dependencies': { + 'net10.0': {'Dependency': {'type': 'Direct', 'resolved': version}}}}), encoding='utf-8') + + def write_axis(self, axis, version): + kind = identity.KINDS[axis] + if kind == 'native-abi': + content = f'#define ARC_NATIVE_ABI_MAJOR UINT32_C({version})\n#define ARC_NATIVE_ABI_MINOR UINT32_C(0)\n' + elif kind == 'contracts': + content = f'package fixture.contract.v{version};\n' + else: + content = json.dumps([{'subject': axis + '-fixture', + 'version': version if kind == 'migrations' else str(version)}]) + (self.root / (axis + '.txt')).write_text(content, encoding='utf-8') + + def resolve(self, release='1.0.0'): + with patch.object(identity, 'git', return_value='packages.lock.json'): + packages = identity.dependency_versions(self.root) + return identity.resolve_axes(self.root, self.catalog, release, packages) + + def test_nine_sources_change_independently(self): + baseline = self.resolve() + for axis in identity.AXES: + with self.subTest(axis=axis): + if axis == 'AppVersion': + changed = self.resolve('2.0.0') + elif axis == 'PackageVersion': + self.write_lock('2.0.0') + changed = self.resolve() + self.write_lock('1.0.0') + else: + self.write_axis(axis, 2) + changed = self.resolve() + self.write_axis(axis, 1) + self.assertEqual([axis], [name for name in identity.AXES if baseline[name] != changed[name]]) + self.assertEqual(identity.canonical(baseline), identity.canonical(self.resolve())) + + def test_input_mutation_does_not_change_resolved_report(self): + with patch.object(identity, 'git', return_value='packages.lock.json'): + packages = identity.dependency_versions(self.root) + before = identity.resolve_axes(self.root, self.catalog, '1.0.0', packages) + expected = identity.canonical(before) + packages[0]['version'] = '2.0.0' + self.assertEqual(expected, identity.canonical(before)) + + def test_invalid_axes_and_evidence_rejected(self): + baseline = self.resolve() + mutations = [lambda x: x.pop('ContractSet'), lambda x: x.update(Unknown={}), + lambda x: x['NativeAbiVersion']['values'].append(copy.deepcopy(x['NativeAbiVersion']['values'][0])), + lambda x: x['NativeAbiVersion']['values'][0].update(version='AppVersion'), + lambda x: x['NativeAbiVersion']['values'][0].update(source={}), + lambda x: x['NativeAbiVersion']['values'][0]['source'].update(sha256Lf='bad'), + lambda x: x['NativeAbiVersion']['values'][0]['source'].update(path='../escape')] + for mutation in mutations: + altered = copy.deepcopy(baseline) + mutation(altered) + with self.assertRaises(ValueError): + identity.validate_axes(altered) + + def test_absence_requires_reason_and_future_owner(self): + self.catalog['axes']['CapabilityVersion'] = {'kind': 'declarations', 'absence': 'not-produced', + 'reason': 'No implementation', 'producer': 'WP09'} + self.assertEqual('not-produced', self.resolve()['CapabilityVersion']['status']) + self.catalog['axes']['CapabilityVersion'].pop('producer') + with self.assertRaisesRegex(ValueError, 'producer'): + self.resolve() + + def test_missing_and_cross_axis_sources_rejected(self): + self.catalog['axes']['NativeAbiVersion']['sources'] = ['missing.h'] + with self.assertRaises(ValueError): + self.resolve() + self.catalog['axes']['NativeAbiVersion']['sources'] = ['../outside.h'] + with self.assertRaises(ValueError): + self.resolve() + self.catalog['axes']['NativeAbiVersion']['kind'] = 'release' + with self.assertRaisesRegex(ValueError, 'cross-axis'): + self.resolve() + + def test_real_git_identity_and_publication_rejections(self): + def git(*args): + subprocess.run(['git', *args], cwd=self.root, check=True, capture_output=True) + git('init') + git('add', '.') + git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', 'commit', '-m', 'fixture') + local = identity.build_identity(self.root, {}) + with self.assertRaisesRegex(ValueError, 'publishable'): + identity.validate_identity(local, publish=True) + env = {'GITHUB_ACTIONS': 'true', 'GITHUB_SHA': local['sourceCommit'], 'GITHUB_RUN_ID': '123', + 'GITHUB_RUN_ATTEMPT': '2', 'GITHUB_REPOSITORY': 'ArcForges/DesktopPlatform'} + ci = identity.build_identity(self.root, env) + identity.validate_identity(ci, local['sourceCommit'], publish=True) + self.assertEqual(ci, identity.build_identity(self.root, env)) + for field, value in [('sourceCommit', 'b' * 40), ('buildId', '123.3'), ('pipelineRun', 'https://example.invalid'), + ('runAttempt', None), ('sourceDateEpoch', 0), ('dirty', True), ('kind', 'unknown')]: + altered = {**ci, field: value} + with self.subTest(field=field), self.assertRaises(ValueError): + identity.validate_identity(altered, local['sourceCommit'], publish=True) + with self.assertRaises(ValueError): + identity.build_identity(self.root, {**env, 'GITHUB_SHA': 'b' * 40}) + (self.root / 'untracked').write_text('dirty', encoding='utf-8') + with self.assertRaisesRegex(ValueError, 'dirty'): + identity.build_identity(self.root, env) + + +if __name__ == '__main__': + unittest.main() diff --git a/eng/test_reconciliation.py b/eng/test_reconciliation.py index d3d0521..a0ad0e0 100644 --- a/eng/test_reconciliation.py +++ b/eng/test_reconciliation.py @@ -82,8 +82,10 @@ def test_reviewed_project_update_preserves_snapshot_and_rejects_unreviewed_drift reviewed = policy.reviewed_projects(row, updates) self.assertEqual(row, original) expected = {p['path']:p['blob'] for p in reviewed['projects']} - self.assertEqual(expected[updates[0]['path']], updates[0]['reviewedBlob']) - self.assertEqual(sum(a != b for a,b in zip(row['projects'],reviewed['projects'])), 1) + for update in updates: + self.assertEqual(expected[update['path']], update['reviewedBlob']) + self.assertEqual({a['path'] for a,b in zip(row['projects'],reviewed['projects']) if a != b}, + {update['path'] for update in updates}) for field,value in [('path','src/Unknown/Unknown.csproj'),('originalBlob','0'*40), ('reviewedBlob',updates[0]['originalBlob']),('repository','Contracts'), ('producer',''),('authorityCommit','invalid')]: diff --git a/eng/version-sources.json b/eng/version-sources.json new file mode 100644 index 0000000..16adb0d --- /dev/null +++ b/eng/version-sources.json @@ -0,0 +1,55 @@ +{ + "schemaVersion": 1, + "owner": "DesktopPlatform", + "axes": { + "AppVersion": { + "kind": "release", + "absence": "not-applicable", + "reason": "DesktopPlatform publishes libraries, not an installed application." + }, + "ContractSet": { + "kind": "contracts", + "absence": "not-applicable", + "reason": "Business RPC schemas are owned by Contracts; this owner has no wire-contract set." + }, + "CapabilityVersion": { + "kind": "declarations", + "absence": "not-produced", + "reason": "Existing ABI probes are not production capability descriptors.", + "producer": "WP09 and the owning product capability work packages" + }, + "NativeFormatVersion": { + "kind": "declarations", + "absence": "not-produced", + "reason": "No portable product format is implemented by this foundation.", + "producer": "WP18/28, WP34 and WP38 product format owners" + }, + "StorageSchemaVersion": { + "kind": "migrations", + "absence": "not-produced", + "reason": "No owned migration set is implemented.", + "producer": "WP07 and product storage owners" + }, + "NativeAbiVersion": { + "kind": "native-abi", + "sources": [ + "native/shared/include/arc/arc_native_abi.h" + ] + }, + "PolicySchemaVersion": { + "kind": "declarations", + "absence": "not-produced", + "reason": "MSBuild policy is not a signed product policy schema.", + "producer": "WP03 and WP44" + }, + "ExtensionProtocolVersion": { + "kind": "declarations", + "absence": "not-produced", + "reason": "No extension protocol is implemented.", + "producer": "WP03 and WP41" + }, + "PackageVersion": { + "kind": "packages" + } + } +} diff --git a/native/CMakeLists.txt b/native/CMakeLists.txt index 87000cb..6e4184f 100644 --- a/native/CMakeLists.txt +++ b/native/CMakeLists.txt @@ -10,6 +10,14 @@ set(CMAKE_CXX_EXTENSIONS OFF) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) set(ARCFORGES_SHARED_ABI_SOURCE "${CMAKE_CURRENT_SOURCE_DIR}/shared/src/arc_native_abi.cpp") set(ARCFORGES_SHARED_ABI_PRIVATE_INCLUDE "${CMAKE_CURRENT_SOURCE_DIR}/shared/src") +find_package(Python3 3.11 REQUIRED COMPONENTS Interpreter) +set(_identity_header "${CMAKE_CURRENT_BINARY_DIR}/identity/arc_build_identity.hpp") +add_custom_target(arcforges_build_identity + COMMAND "${Python3_EXECUTABLE}" "${PROJECT_SOURCE_DIR}/eng/build_identity.py" --native-header "${_identity_header}" + BYPRODUCTS "${_identity_header}" + VERBATIM) +arcforges_declare_target_licence(arcforges_build_identity) +include_directories("${CMAKE_CURRENT_BINARY_DIR}/identity") function(arcforges_add_abi_fuzzer name library prefix) if(NOT ARCFORGES_BUILD_FUZZERS) diff --git a/native/arcgraphics-metal-abi/CMakeLists.txt b/native/arcgraphics-metal-abi/CMakeLists.txt index 892ac82..bb19543 100644 --- a/native/arcgraphics-metal-abi/CMakeLists.txt +++ b/native/arcgraphics-metal-abi/CMakeLists.txt @@ -12,6 +12,7 @@ file(GLOB_RECURSE ARCFORGES_SHIM_HEADERS CONFIGURE_DEPENDS add_library(arcgraphics_metal_abi SHARED ${ARCFORGES_SHIM_SOURCES} ${ARCFORGES_SHIM_HEADERS} ${ARCFORGES_SHARED_ABI_SOURCE}) arcforges_declare_target_licence(arcgraphics_metal_abi) +add_dependencies(arcgraphics_metal_abi arcforges_build_identity) target_compile_features(arcgraphics_metal_abi PRIVATE cxx_std_20) set_target_properties(arcgraphics_metal_abi PROPERTIES CXX_EXTENSIONS OFF diff --git a/native/arcmedia-ffmpeg-abi/CMakeLists.txt b/native/arcmedia-ffmpeg-abi/CMakeLists.txt index c38d313..2793175 100644 --- a/native/arcmedia-ffmpeg-abi/CMakeLists.txt +++ b/native/arcmedia-ffmpeg-abi/CMakeLists.txt @@ -8,6 +8,7 @@ file(GLOB_RECURSE ARCFORGES_SHIM_HEADERS CONFIGURE_DEPENDS add_library(arcmedia_ffmpeg_abi SHARED ${ARCFORGES_SHIM_SOURCES} ${ARCFORGES_SHIM_HEADERS} ${ARCFORGES_SHARED_ABI_SOURCE}) arcforges_declare_target_licence(arcmedia_ffmpeg_abi) +add_dependencies(arcmedia_ffmpeg_abi arcforges_build_identity) target_compile_features(arcmedia_ffmpeg_abi PRIVATE cxx_std_20) set_target_properties(arcmedia_ffmpeg_abi PROPERTIES CXX_EXTENSIONS OFF diff --git a/native/arcslate-color-abi/CMakeLists.txt b/native/arcslate-color-abi/CMakeLists.txt index eff3fc8..16757d9 100644 --- a/native/arcslate-color-abi/CMakeLists.txt +++ b/native/arcslate-color-abi/CMakeLists.txt @@ -8,6 +8,7 @@ file(GLOB_RECURSE ARCFORGES_SHIM_HEADERS CONFIGURE_DEPENDS add_library(arcslate_color_abi SHARED ${ARCFORGES_SHIM_SOURCES} ${ARCFORGES_SHIM_HEADERS} ${ARCFORGES_SHARED_ABI_SOURCE}) arcforges_declare_target_licence(arcslate_color_abi) +add_dependencies(arcslate_color_abi arcforges_build_identity) target_compile_features(arcslate_color_abi PRIVATE cxx_std_20) set_target_properties(arcslate_color_abi PROPERTIES CXX_EXTENSIONS OFF diff --git a/native/arcslate-image-abi/CMakeLists.txt b/native/arcslate-image-abi/CMakeLists.txt index 19fc63e..bbc5c82 100644 --- a/native/arcslate-image-abi/CMakeLists.txt +++ b/native/arcslate-image-abi/CMakeLists.txt @@ -8,6 +8,7 @@ file(GLOB_RECURSE ARCFORGES_SHIM_HEADERS CONFIGURE_DEPENDS add_library(arcslate_image_abi SHARED ${ARCFORGES_SHIM_SOURCES} ${ARCFORGES_SHIM_HEADERS} ${ARCFORGES_SHARED_ABI_SOURCE}) arcforges_declare_target_licence(arcslate_image_abi) +add_dependencies(arcslate_image_abi arcforges_build_identity) target_compile_features(arcslate_image_abi PRIVATE cxx_std_20) set_target_properties(arcslate_image_abi PROPERTIES CXX_EXTENSIONS OFF diff --git a/native/arcslate-otio-abi/CMakeLists.txt b/native/arcslate-otio-abi/CMakeLists.txt index 966e370..d014992 100644 --- a/native/arcslate-otio-abi/CMakeLists.txt +++ b/native/arcslate-otio-abi/CMakeLists.txt @@ -8,6 +8,7 @@ file(GLOB_RECURSE ARCFORGES_SHIM_HEADERS CONFIGURE_DEPENDS add_library(arcslate_otio_abi SHARED ${ARCFORGES_SHIM_SOURCES} ${ARCFORGES_SHIM_HEADERS} ${ARCFORGES_SHARED_ABI_SOURCE}) arcforges_declare_target_licence(arcslate_otio_abi) +add_dependencies(arcslate_otio_abi arcforges_build_identity) target_compile_features(arcslate_otio_abi PRIVATE cxx_std_20) set_target_properties(arcslate_otio_abi PROPERTIES CXX_EXTENSIONS OFF diff --git a/native/shared/src/arc_native_abi.cpp b/native/shared/src/arc_native_abi.cpp index acb80a9..d096edd 100644 --- a/native/shared/src/arc_native_abi.cpp +++ b/native/shared/src/arc_native_abi.cpp @@ -1,4 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-only +#include "arc_build_identity.hpp" #include "arc_native_abi_internal.hpp" #include @@ -76,11 +77,25 @@ arc_status_t arc::abi::get_abi_version(uint32_t* out_major, uint32_t* out_minor) arc_status_t arc::abi::write_build_info(std::string_view value, arc_mut_buffer_t* out_utf8) noexcept { - const arc_status_t status = copy_utf8(value, out_utf8); - if (status != ARC_OK && status != ARC_BUFFER_TOO_SMALL) { - return fail(status, "Build-info output buffer is invalid", 0); + // Preserve exact sizing and error semantics without allocating across the C ABI. + constexpr std::string_view identity(arc_build_identity); + if (out_utf8 == nullptr) { + return fail(ARC_INVALID_ARGUMENT, "Build-info output buffer is invalid", 0); } - return status; + out_utf8->required = static_cast(value.size() + identity.size()); + if (out_utf8->data == nullptr && out_utf8->capacity != 0) { + return fail(ARC_INVALID_ARGUMENT, "Build-info output buffer is invalid", 0); + } + if (out_utf8->capacity < out_utf8->required) { + return ARC_BUFFER_TOO_SMALL; + } + if (out_utf8->data == nullptr) { + return fail(ARC_INVALID_ARGUMENT, "Build-info output buffer is invalid", 0); + } + auto* const data = static_cast(out_utf8->data); + std::memcpy(data, value.data(), value.size()); + std::memcpy(data + value.size(), identity.data(), identity.size()); + return ARC_OK; } arc_status_t arc::abi::get_last_error(arc_error_info_t* out_error) noexcept diff --git a/native/windows/ArcForges.Native.props b/native/windows/ArcForges.Native.props index b1fefbb..4ed99e4 100644 --- a/native/windows/ArcForges.Native.props +++ b/native/windows/ArcForges.Native.props @@ -12,7 +12,7 @@ - $(ArcForgesRepositoryRoot)native\shared\src;%(AdditionalIncludeDirectories) + $(IntDir)identity;$(ArcForgesRepositoryRoot)native\shared\src;%(AdditionalIncludeDirectories) true TurnOffAllWarnings /utf-8 %(AdditionalOptions) @@ -23,4 +23,7 @@ bcrypt.lib;%(AdditionalDependencies) + + + diff --git a/src/Build/ArcForges.Build.Policy/README.md b/src/Build/ArcForges.Build.Policy/README.md index 3da24f4..73fb3de 100644 --- a/src/Build/ArcForges.Build.Policy/README.md +++ b/src/Build/ArcForges.Build.Policy/README.md @@ -12,3 +12,11 @@ Normal NuGet `build/` imports activate after restore; it intentionally does not Diagnostics AFP001–AFP003 reject missing central management, inline overrides and floating versions; AFP004–AFP005 reject compiler-policy or lock-policy overrides. Invalid package graphs may additionally be rejected directly by NuGet. Check compiler settings with `dotnet msbuild -getProperty:LangVersion`. + +Owned assemblies also expose `AssemblyMetadataAttribute` keys `ArcForges.SourceCommit`, +`ArcForges.BuildId`, `ArcForges.PipelineRun`, `ArcForges.SourceDateEpoch` and `ArcForges.BuildKind`. +CI identity uses the full Git SHA and actual GitHub run ID/attempt, with the commit timestamp in +Unix seconds; this deterministic source time is not wall-clock compilation time. AFP006 rejects +incomplete or mismatched CI identity. Non-repository local consumer fixtures remain explicitly +local (source `local`, epoch `0` when Git metadata is unavailable) and cannot establish publication. +The owner's candidate tooling separately seals dirty state and rejects dirty/local publication. diff --git a/src/Build/ArcForges.Build.Policy/build/ArcForges.Build.Metadata.targets b/src/Build/ArcForges.Build.Policy/build/ArcForges.Build.Metadata.targets new file mode 100644 index 0000000..36e92ae --- /dev/null +++ b/src/Build/ArcForges.Build.Policy/build/ArcForges.Build.Metadata.targets @@ -0,0 +1,44 @@ + + + + + $(GITHUB_SHA) + $(SourceRevisionId) + local + ci + local + $(GITHUB_RUN_ID).$(GITHUB_RUN_ATTEMPT) + local.$(ArcForgesSourceCommit) + https://github.com/$(GITHUB_REPOSITORY)/actions/runs/$(GITHUB_RUN_ID) + local + <_ArcForgesGitTimeFormat>%ct + <_ArcForgesGitTimeFormat Condition="$([MSBuild]::IsOSPlatform('Windows'))">%%ct + + + + + + <_ArcForgesGitEpochText>@(_ArcForgesGitEpoch) + $(_ArcForgesGitEpochText) + 0 + + + + + + + + + + + + + diff --git a/src/Build/ArcForges.Build.Policy/build/ArcForges.Build.Policy.targets b/src/Build/ArcForges.Build.Policy/build/ArcForges.Build.Policy.targets index 2b85447..0fe3fac 100644 --- a/src/Build/ArcForges.Build.Policy/build/ArcForges.Build.Policy.targets +++ b/src/Build/ArcForges.Build.Policy/build/ArcForges.Build.Policy.targets @@ -1,5 +1,6 @@ + diff --git a/tests/ArchitectureTests/BuildIdentityTests.cs b/tests/ArchitectureTests/BuildIdentityTests.cs new file mode 100644 index 0000000..d74d7d3 --- /dev/null +++ b/tests/ArchitectureTests/BuildIdentityTests.cs @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +using System.Diagnostics; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using System.Xml.Linq; + +namespace ArcForges.Tests.ArchitectureTests; + +public sealed class BuildIdentityTests +{ + [Xunit.Fact] + public void EveryOwnedAssemblyContainsTheActualSourceAndBuildIdentity() + { + var root = new DirectoryInfo(AppContext.BaseDirectory); + while (root is not null && !File.Exists(Path.Combine(root.FullName, "DesktopPlatform.slnx"))) + { + root = root.Parent; + } + + Xunit.Assert.NotNull(root); + string commit = Git(root.FullName, "rev-parse", "HEAD"); + string epoch = Git(root.FullName, "show", "-s", "--format=%ct", "HEAD"); + bool ci = Environment.GetEnvironmentVariable("GITHUB_ACTIONS") == "true"; + string build = ci ? Environment.GetEnvironmentVariable("GITHUB_RUN_ID") + "." + Environment.GetEnvironmentVariable("GITHUB_RUN_ATTEMPT") : "local." + commit; + string pipeline = ci ? "https://github.com/" + Environment.GetEnvironmentVariable("GITHUB_REPOSITORY") + "/actions/runs/" + Environment.GetEnvironmentVariable("GITHUB_RUN_ID") : "local"; + string host = OperatingSystem.IsWindows() ? "windows" : OperatingSystem.IsMacOS() ? "macos" : "linux"; + var solution = XDocument.Load(Path.Combine(root.FullName, "DesktopPlatform.slnx")); + foreach (var project in solution.Descendants("Project")) + { + string name = Path.GetFileNameWithoutExtension(project.Attribute("Path")!.Value); + string path = Path.Combine(root.FullName, "artifacts", "bin", "dotnet", host, name, "Release", "net10.0", name + ".dll"); + using var stream = File.OpenRead(path); + using var pe = new PEReader(stream); + var reader = pe.GetMetadataReader(); + var values = new Dictionary(StringComparer.Ordinal); + foreach (var handle in reader.GetAssemblyDefinition().GetCustomAttributes()) + { + var attribute = reader.GetCustomAttribute(handle); + if (attribute.Constructor.Kind != HandleKind.MemberReference) + { + continue; + } + + var member = reader.GetMemberReference((MemberReferenceHandle)attribute.Constructor); + if (member.Parent.Kind != HandleKind.TypeReference) + { + continue; + } + + var type = reader.GetTypeReference((TypeReferenceHandle)member.Parent); + if (reader.GetString(type.Name) != "AssemblyMetadataAttribute" || reader.GetString(type.Namespace) != "System.Reflection") + { + continue; + } + + var blob = reader.GetBlobReader(attribute.Value); + Xunit.Assert.Equal(1, blob.ReadUInt16()); + string key = blob.ReadSerializedString()!; + if (key.StartsWith("ArcForges.", StringComparison.Ordinal)) + { + values.Add(key, blob.ReadSerializedString()); + } + } + + Xunit.Assert.Equal(commit, values["ArcForges.SourceCommit"]); + Xunit.Assert.Equal(epoch, values["ArcForges.SourceDateEpoch"]); + Xunit.Assert.Equal(build, values["ArcForges.BuildId"]); + Xunit.Assert.Equal(pipeline, values["ArcForges.PipelineRun"]); + Xunit.Assert.Equal(ci ? "ci" : "local", values["ArcForges.BuildKind"]); + } + } + + private static string Git(string directory, params string[] arguments) + { + var start = new ProcessStartInfo("git") { WorkingDirectory = directory, RedirectStandardOutput = true, UseShellExecute = false }; + foreach (string argument in arguments) + { + start.ArgumentList.Add(argument); + } + + using var process = Process.Start(start)!; + string result = process.StandardOutput.ReadToEnd().Trim(); + process.WaitForExit(); + Xunit.Assert.Equal(0, process.ExitCode); + return result; + } +}