From c07863751cf93acf3afff9984a8d9d228953739e Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 4 Oct 2026 11:52:32 -0500 Subject: [PATCH 1/4] fix(release): publish complete contract bundle --- tools/contract_release_bundle.py | 178 ++++++++++++++++++++ tools/package-release.sh | 11 +- tools/test_release_checksums.py | 272 +++++++++++++++++++++++++++---- 3 files changed, 425 insertions(+), 36 deletions(-) create mode 100644 tools/contract_release_bundle.py diff --git a/tools/contract_release_bundle.py b/tools/contract_release_bundle.py new file mode 100644 index 0000000..31248f4 --- /dev/null +++ b/tools/contract_release_bundle.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +"""Build the deterministic release bundle for downloadable contract assets.""" + +import gzip +import hashlib +import io +import os +from pathlib import Path, PurePosixPath +import re +import sys +import tarfile + + +TOP_LEVEL_FILES = ( + "LICENSE", + "THIRD_PARTY_NOTICES.md", + "access-auth-v1.bin", + "access-client-hello-v1.tsv", + "access-resolve-v1.json", + "access-resolve-v1.schema.json", + "access-route-bounds-v1.json", + "access-route-state-v1.json", + "access-route-v1.schema.json", + "access-routes-v1.json", + "access-tokens-v1.json", + "access-tokens.md", + "atrinik-game-v1.binpb", + "framing.json", + "metaserver-classic-publisher-v2.json", + "metaserver-classic-publisher-v2.schema.json", + "metaserver-classic-publisher-v3.json", + "metaserver-classic-publisher-v3.schema.json", + "metaserver-directory-v1.json", + "metaserver-directory-v1.schema.json", + "metaserver-directory-v2.json", + "metaserver-directory-v2.schema.json", + "metaserver-directory.md", + "metaserver-game-publisher-v1.json", + "metaserver-game-publisher-v1.schema.json", + "metaserver-game-publisher-v2.json", + "metaserver-game-publisher-v2.schema.json", + "metaserver-publisher-v1.json", + "metaserver-publisher.md", +) +NESTED_ROOTS = ("metaserver-directory-v1", "metaserver-directory-v2") +NESTED_FILES = ( + "canonical.json", + "negative-duplicate-server.json", + "negative-expired-at-generation.json", + "negative-identity-mismatch.json", + "negative-invalid-alabel.json", + "negative-noncanonical-whitespace.json", + "negative-numeric-endpoint.json", + "negative-private-field.json", + "negative-status-count.json", + "negative-unordered-servers.json", + "negative-unsupported-schema.json", + "negative-xml-noncharacter.json", + "negative-zero-generation.json", + "projection-semantics.json", + "projection.xml", +) +PAYLOAD_PATHS = frozenset(TOP_LEVEL_FILES) | frozenset( + f"{root}/{name}" for root in NESTED_ROOTS for name in NESTED_FILES +) +MAX_FILES = 128 +MAX_TOTAL_BYTES = 64 * 1024 * 1024 +VERSION_PATTERN = re.compile(r"[0-9A-Za-z][0-9A-Za-z.+-]*") + + +def _read_payloads(output: Path) -> dict[str, bytes]: + if output.is_symlink() or not output.is_dir(): + raise ValueError("release output must be a real directory") + if len(PAYLOAD_PATHS) > MAX_FILES: + raise ValueError("contract bundle exceeds its file-count bound") + payloads = {} + total = 0 + for relative in sorted(PAYLOAD_PATHS): + path = output / relative + if path.is_symlink() or not path.is_file(): + raise ValueError(f"missing or unsafe contract asset: {relative}") + size = path.stat().st_size + total += size + if total > MAX_TOTAL_BYTES: + raise ValueError("contract bundle exceeds its byte bound") + data = path.read_bytes() + if len(data) != size: + raise ValueError(f"contract asset changed while reading: {relative}") + payloads[relative] = data + + for root_name in NESTED_ROOTS: + root = output / root_name + discovered = set() + for path in root.rglob("*"): + if path.is_symlink() or not path.is_file(): + if path.is_dir() and not path.is_symlink(): + continue + raise ValueError(f"unsafe nested contract asset: {path}") + discovered.add(path.relative_to(output).as_posix()) + expected = {name for name in PAYLOAD_PATHS if name.startswith(root_name + "/")} + if discovered != expected: + raise ValueError(f"unexpected {root_name} contract inventory") + return payloads + + +def _tar_info(name: str, *, directory: bool, size: int = 0) -> tarfile.TarInfo: + info = tarfile.TarInfo(name + ("/" if directory else "")) + info.type = tarfile.DIRTYPE if directory else tarfile.REGTYPE + info.mode = 0o755 if directory else 0o644 + info.uid = 0 + info.gid = 0 + info.uname = "" + info.gname = "" + info.mtime = 0 + info.size = size + return info + + +def build_bundle(output: Path, version: str) -> Path: + if VERSION_PATTERN.fullmatch(version) is None: + raise ValueError("invalid release version") + payloads = _read_payloads(output) + checksums = "".join( + f"{hashlib.sha256(payloads[name]).hexdigest()} {name}\n" + for name in sorted(payloads) + ).encode("ascii") + archive_root = f"atrinik-protocol-contracts-{version}" + members = dict(payloads) + members["SHA256SUMS"] = checksums + + directories = {archive_root} + for relative in members: + parent = PurePosixPath(archive_root, relative).parent + while parent.as_posix() != ".": + directories.add(parent.as_posix()) + if parent.as_posix() == archive_root: + break + parent = parent.parent + + destination = output / f"{archive_root}.tar.gz" + temporary = output / f".{archive_root}.tar.gz.tmp" + if destination.exists() or temporary.exists(): + raise ValueError("contract bundle output already exists") + try: + with temporary.open("xb") as raw: + with gzip.GzipFile(fileobj=raw, mode="wb", filename="", mtime=0) as compressed: + with tarfile.open( + fileobj=compressed, mode="w", format=tarfile.GNU_FORMAT + ) as archive: + for name in sorted(directories): + archive.addfile(_tar_info(name, directory=True)) + for relative in sorted(members): + data = members[relative] + archive.addfile( + _tar_info( + f"{archive_root}/{relative}", + directory=False, + size=len(data), + ), + io.BytesIO(data), + ) + os.replace(temporary, destination) + finally: + temporary.unlink(missing_ok=True) + return destination + + +def main() -> None: + if len(sys.argv) != 3: + raise SystemExit(f"usage: {sys.argv[0]} OUTPUT VERSION") + try: + build_bundle(Path(sys.argv[1]), sys.argv[2]) + except (OSError, ValueError) as error: + raise SystemExit(f"contract bundle failed: {error}") from error + + +if __name__ == "__main__": + main() diff --git a/tools/package-release.sh b/tools/package-release.sh index 6170aa1..9d6de5a 100755 --- a/tools/package-release.sh +++ b/tools/package-release.sh @@ -93,6 +93,10 @@ cp fixtures/metaserver-directory-v2.json fixtures/metaserver-game-publisher-v2.j schema/metaserver-classic-publisher-v3.schema.json \ spec/access-tokens.md "${output}/" +python3 tools/contract_release_bundle.py "${output}" "${version}" +rm -rf -- "${output}/metaserver-directory-v1" \ + "${output}/metaserver-directory-v2" + SYFT_CHECK_FOR_APP_UPDATE=false syft dir:. \ --source-name atrinik-protocol --source-version "${version}" \ --output "cyclonedx-json=${output}/sbom.cdx.json" @@ -124,8 +128,9 @@ jq -n \ ( cd "${output}" - # Cover every shipped artifact, including nested current-version fixtures. - # NUL separators preserve exact file names; SHA256SUMS cannot cover itself. - find . -type f ! -name SHA256SUMS -printf '%P\0' \ + # Semantic-release uploads build/release/*, so the downloadable output and + # its checksum inventory must both be flat. SHA256SUMS cannot cover itself. + test -z "$(find . -mindepth 1 -type d -print -quit)" + find . -mindepth 1 -maxdepth 1 -type f ! -name SHA256SUMS -printf '%P\0' \ | LC_ALL=C sort -z | xargs -0 sha256sum >SHA256SUMS ) diff --git a/tools/test_release_checksums.py b/tools/test_release_checksums.py index 8da02f9..147bb0d 100644 --- a/tools/test_release_checksums.py +++ b/tools/test_release_checksums.py @@ -1,65 +1,271 @@ #!/usr/bin/env python3 -"""Check complete release coverage and reject damaged current-version artifacts.""" +"""Check flat release discovery and both levels of release checksums.""" + +import copy +import gzip import hashlib -from pathlib import Path +import io +from pathlib import Path, PurePosixPath +import re import shutil import sys +import tarfile import tempfile +import contract_release_bundle as bundle_spec -def verify(root): + +CHECKSUM_LINE = re.compile(r"([0-9a-f]{64}) ([A-Za-z0-9_.+/-]+)") +MAX_BUNDLE_BYTES = bundle_spec.MAX_TOTAL_BYTES + 1024 * 1024 + + +def file_sha256(path): + digest = hashlib.sha256() + with path.open("rb") as stream: + while chunk := stream.read(1024 * 1024): + digest.update(chunk) + return digest.hexdigest() + + +def parse_checksums(data, *, nested): listed = {} - for line in (root / 'SHA256SUMS').read_text().splitlines(): - digest, name = line.split(' ', 1) - if name in listed: - raise ValueError('duplicate checksum entry') + try: + lines = data.decode("ascii").splitlines() + except UnicodeDecodeError as error: + raise ValueError("checksum inventory is not ASCII") from error + if not lines: + raise ValueError("empty checksum inventory") + for line in lines: + match = CHECKSUM_LINE.fullmatch(line) + if match is None: + raise ValueError("malformed checksum entry") + digest, name = match.groups() + path = PurePosixPath(name) + if path.is_absolute() or ".." in path.parts or "\\" in name: + raise ValueError("unsafe checksum path") + if not nested and len(path.parts) != 1: + raise ValueError("outer checksum path is not flat") + if name == "SHA256SUMS" or name in listed: + raise ValueError("recursive or duplicate checksum entry") listed[name] = digest - files = {str(path.relative_to(root)) for path in root.rglob('*') - if path.is_file() and path.name != 'SHA256SUMS'} - if files != set(listed): - raise ValueError('checksum inventory differs from release artifacts') + return listed + + +def verify_bundle(path): + if path.stat().st_size > MAX_BUNDLE_BYTES: + raise ValueError("contract bundle exceeds its compressed-size bound") + with path.open("rb") as stream: + header = stream.read(10) + if (len(header) < 10 or header[:2] != b"\x1f\x8b" or + header[4:8] != b"\0\0\0\0"): + raise ValueError("contract bundle has a nondeterministic gzip header") + if header[3] & 0x08: + raise ValueError("contract bundle records a gzip file name") + + archive_root = path.name.removesuffix(".tar.gz") + prefix = archive_root + "/" + payloads = {} + names = set() + directories = set() + total = 0 + with tarfile.open(path, mode="r:gz") as archive: + maximum_members = ( + bundle_spec.MAX_FILES + len(bundle_spec.NESTED_ROOTS) + 2 + ) + for index, member in enumerate(archive): + if index >= maximum_members: + raise ValueError("contract bundle exceeds its member-count bound") + if member.name in names: + raise ValueError("duplicate contract bundle member") + names.add(member.name) + pure = PurePosixPath(member.name) + if pure.is_absolute() or ".." in pure.parts or "\\" in member.name: + raise ValueError("unsafe contract bundle path") + if not (member.name == archive_root or member.name.startswith(prefix)): + raise ValueError("contract bundle has multiple roots") + if (member.mtime != 0 or member.uid != 0 or member.gid != 0 or + member.uname or member.gname): + raise ValueError("contract bundle metadata is not normalized") + if member.isdir(): + if member.mode != 0o755: + raise ValueError("contract bundle directory mode differs") + directories.add(member.name.rstrip("/")) + continue + if not member.isfile() or member.mode != 0o644: + raise ValueError("unsafe contract bundle member type") + total += member.size + if total > MAX_BUNDLE_BYTES: + raise ValueError("contract bundle exceeds its expanded-size bound") + relative = member.name[len(prefix):] + stream = archive.extractfile(member) + if stream is None: + raise ValueError("contract bundle member is unreadable") + data = stream.read(member.size + 1) + if len(data) != member.size: + raise ValueError("contract bundle member size differs") + payloads[relative] = data + + expected = set(bundle_spec.PAYLOAD_PATHS) | {"SHA256SUMS"} + if set(payloads) != expected: + raise ValueError("contract bundle inventory differs") + expected_directories = {archive_root} | { + f"{archive_root}/{name}" for name in bundle_spec.NESTED_ROOTS + } + if directories != expected_directories: + raise ValueError("contract bundle directory inventory differs") + listed = parse_checksums(payloads["SHA256SUMS"], nested=True) + if set(listed) != set(bundle_spec.PAYLOAD_PATHS): + raise ValueError("internal checksum inventory differs") for name, digest in listed.items(): - if hashlib.sha256((root / name).read_bytes()).hexdigest() != digest: - raise ValueError('checksum mismatch') + if hashlib.sha256(payloads[name]).hexdigest() != digest: + raise ValueError("internal checksum mismatch") + + +def verify(root): + entries = list(root.iterdir()) + if any(not path.is_file() or path.is_symlink() for path in entries): + raise ValueError("release contains a non-downloadable or unsafe entry") + discovered = {path.name for path in entries} + semantic_release_discovery = { + path.name for path in root.glob("*") if path.is_file() + } + if semantic_release_discovery != discovered: + raise ValueError("release contains an asset missed by the publish glob") + if "SHA256SUMS" not in discovered: + raise ValueError("release checksum inventory is missing") + required = set(bundle_spec.TOP_LEVEL_FILES) | { + "SHA256SUMS", "provenance.json", "sbom.cdx.json" + } + if not required.issubset(discovered): + raise ValueError("release is missing a required flat asset") + source_archives = { + name for name in discovered + if name.startswith("atrinik-protocol-") and name.endswith(".tar.gz") + and not name.startswith("atrinik-protocol-contracts-") + } + if len(source_archives) != 1: + raise ValueError("release must contain one source archive") + listed = parse_checksums((root / "SHA256SUMS").read_bytes(), nested=False) + if set(listed) != discovered - {"SHA256SUMS"}: + raise ValueError("checksum inventory differs from downloadable assets") + for name, digest in listed.items(): + if file_sha256(root / name) != digest: + raise ValueError("outer checksum mismatch") + + bundles = sorted(root.glob("atrinik-protocol-contracts-*.tar.gz")) + if len(bundles) != 1 or bundles[0].name not in listed: + raise ValueError("release must contain one checksummed contract bundle") + verify_bundle(bundles[0]) def rejected(root): try: verify(root) - except (ValueError, OSError): + except (ValueError, OSError, tarfile.TarError): return - raise AssertionError('damaged release passed verification') + raise AssertionError("damaged release passed verification") + + +def refresh_outer_bundle_checksum(root, bundle): + sums = root / "SHA256SUMS" + replacement = file_sha256(bundle) + lines = sums.read_text().splitlines() + matches = [index for index, line in enumerate(lines) + if line.endswith(" " + bundle.name)] + if len(matches) != 1: + raise AssertionError("test fixture has no unique bundle checksum") + lines[matches[0]] = f"{replacement} {bundle.name}" + sums.write_text("\n".join(lines) + "\n") + + +def rewrite_bundle_member(bundle, relative, replacement): + archive_root = bundle.name.removesuffix(".tar.gz") + target = f"{archive_root}/{relative}" + records = [] + found = False + with tarfile.open(bundle, mode="r:gz") as source: + for member in source.getmembers(): + data = source.extractfile(member).read() if member.isfile() else None + if member.name == target: + found = True + if replacement is None: + continue + data = replacement + member = copy.copy(member) + member.size = len(data) + records.append((copy.copy(member), data)) + if not found: + raise AssertionError("test bundle member is missing") + with bundle.open("wb") as raw: + with gzip.GzipFile(fileobj=raw, mode="wb", filename="", mtime=0) as compressed: + with tarfile.open(fileobj=compressed, mode="w", + format=tarfile.GNU_FORMAT) as destination: + for member, data in records: + destination.addfile(member, None if data is None else io.BytesIO(data)) def main(): source = Path(sys.argv[1]) verify(source) - required = ('access-auth-v1.bin', 'access-tokens.md', - 'access-route-v1.schema.json', 'metaserver-directory-v2.json', - 'metaserver-directory-v2/projection.xml', - 'metaserver-game-publisher-v2.json', - 'metaserver-classic-publisher-v3.json') - with tempfile.TemporaryDirectory(prefix='atrinik-checksum-regression-') as temporary: - root = Path(temporary) / 'release' - shutil.copytree(source, root) + required = ( + "access-auth-v1.bin", + "access-tokens.md", + "access-route-v1.schema.json", + "metaserver-directory-v2.json", + "metaserver-game-publisher-v2.json", + "metaserver-classic-publisher-v3.json", + "provenance.json", + "sbom.cdx.json", + ) + with tempfile.TemporaryDirectory(prefix="atrinik-checksum-regression-") as temporary: + temporary = Path(temporary) + for name in required: + root = temporary / ("missing-" + name.replace(".", "-")) + shutil.copytree(source, root) path = root / name - original = path.read_bytes() - path.write_bytes(original + b'corruption') + path.write_bytes(path.read_bytes() + b"corruption") rejected(root) path.unlink() rejected(root) - path.write_bytes(original) - sums = root / 'SHA256SUMS' + + root = temporary / "missing-bundle" + shutil.copytree(source, root) + next(root.glob("atrinik-protocol-contracts-*.tar.gz")).unlink() + rejected(root) + + root = temporary / "omitted-checksum" + shutil.copytree(source, root) + sums = root / "SHA256SUMS" original = sums.read_text() - sums.write_text('\n'.join(line for line in original.splitlines() - if not line.endswith(' access-auth-v1.bin')) + '\n') + sums.write_text("\n".join( + line for line in original.splitlines() + if not line.endswith(" access-auth-v1.bin")) + "\n") rejected(root) - sums.write_text(original) - (root / 'unlisted-artifact').write_text('unexpected') + + root = temporary / "extra-asset" + shutil.copytree(source, root) + (root / "unlisted-artifact").write_text("unexpected") rejected(root) - print('Release checksum inventory, tamper, missing-file and omitted-entry checks passed') + + root = temporary / "nested-output" + shutil.copytree(source, root) + (root / "not-uploaded").mkdir() + (root / "not-uploaded" / "fixture").write_text("missed by flat glob") + rejected(root) + + nested = "metaserver-directory-v2/projection.xml" + for label, replacement in (("missing-nested", None), + ("tampered-nested", b"corruption")): + root = temporary / label + shutil.copytree(source, root) + bundle = next(root.glob("atrinik-protocol-contracts-*.tar.gz")) + rewrite_bundle_member(bundle, nested, replacement) + refresh_outer_bundle_checksum(root, bundle) + rejected(root) + + print("Flat release discovery and outer/internal checksum regressions passed") -if __name__ == '__main__': +if __name__ == "__main__": main() From 4bc455afd95c10601cb1ea9b016b88d51e5dd095 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 4 Oct 2026 11:58:34 -0500 Subject: [PATCH 2/4] feat(release): prepare reproducible Rust crate activation --- .github/workflows/publish-crate.yml | 51 ++++++ AGENTS.md | 12 +- README.md | 59 ++++++- crates/atrinik-protocol/Cargo.toml | 2 +- policy/rust-crate-candidate.json | 2 +- policy/rust-crate-next.json | 7 + policy/rust-crate-publishing.json | 2 +- tools/check-crate-release-policy.py | 26 ++- tools/crate_publication.py | 249 ++++++++++++++++++++++++++++ tools/package-release.sh | 2 +- tools/test_crate_publication.py | 117 +++++++++++++ tools/test_crate_release_policy.py | 23 ++- tools/validate.sh | 17 +- 13 files changed, 532 insertions(+), 37 deletions(-) create mode 100644 .github/workflows/publish-crate.yml create mode 100644 policy/rust-crate-next.json create mode 100644 tools/crate_publication.py create mode 100644 tools/test_crate_publication.py diff --git a/.github/workflows/publish-crate.yml b/.github/workflows/publish-crate.yml new file mode 100644 index 0000000..73d9256 --- /dev/null +++ b/.github/workflows/publish-crate.yml @@ -0,0 +1,51 @@ +name: Prepare Rust crate + +on: + workflow_dispatch: + inputs: + source_tag: + description: Published source tag + type: string + required: true + source_revision: + description: Exact published source revision + type: string + required: true + +permissions: + contents: read + +concurrency: + group: protocol-crate-publication + cancel-in-progress: false + +jobs: + prepare: + if: github.repository == 'atrinik/protocol' && github.ref == 'refs/heads/main' + runs-on: ubuntu-24.04 + timeout-minutes: 20 + env: + CARGO_HOME: /tmp/atrinik-crate-prepare-cargo + CARGO_TARGET_DIR: /tmp/atrinik-crate-prepare-target + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - run: rustup toolchain install 1.97.1 --profile minimal + - id: prepare + env: + SOURCE_TAG: ${{ inputs.source_tag }} + SOURCE_REVISION: ${{ inputs.source_revision }} + run: | + set -euo pipefail + python3 tools/crate_publication.py prepare --source-tag "$SOURCE_TAG" \ + --source-revision "$SOURCE_REVISION" --output "$RUNNER_TEMP/crate-prepared" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: reviewed-crate-inputs + path: | + ${{ runner.temp }}/crate-prepared/atrinik-protocol-0.2.0.crate + ${{ runner.temp }}/crate-prepared/artifact.json + if-no-files-found: error + retention-days: 7 diff --git a/AGENTS.md b/AGENTS.md index 68f393d..9b532b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,8 +10,11 @@ v2 schemas/adapters and GP1 1.1 access negotiation. Retained v1 schemas and adapters are immutable baseline history, never a current runtime fallback. - A newer Rust source version requires `policy/rust-crate-candidate.json`; it - stays unpublished and registry publication disabled until a separate reviewed - release policy. Local candidate packaging cannot rewrite the published digest. + stays unpublished and registry upload disabled until a separate reviewed + release policy. Prepared source may allow only crates.io in Cargo; the manual + preparation workflow must remain credential-free with no OIDC or upload job. + Actual source-release artifact pins are recorded only after reproducible + preparation, never predicted from a future merge or substituted from HEAD. Local candidate packaging cannot rewrite the published digest. - GP1 is not the numeric C/Python registry in `atrinik/classic/protocol`. Do not add classic IDs, MAP2/ADS compatibility, C/Python bindings, or a dual protocol path here. @@ -99,7 +102,10 @@ short-lived token only to the upload step; and verify the public checksum. Never publish from a pull request, moving branch, dirty tree, unreviewed artifact, long-lived registry secret, or automatic semantic-release side - effect. Keep publication disabled until a separate policy activation review. + effect. Keep registry upload disabled until a separate policy activation review. + The current preparation-only workflow cannot request credentials or upload. + Preserve a flat downloadable release inventory and a deterministic nested + contract bundle, each with complete checksum coverage. - Run the aggregate contract now present: ```sh diff --git a/README.md b/README.md index d0b33dc..1779092 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,12 @@ and evaluates resolved Cargo license expressions against the SPDX policy. The aggregate required check is `Protocol validation`. Release tags create a source/bindings/schema archive, descriptor, fixtures, checksums, CycloneDX -SBOM, build provenance, notices, and MIT license. A Rust crate version has +SBOM, build provenance, notices, and MIT license. Every release asset is a flat +file covered by the outer `SHA256SUMS`. The deterministic +`atrinik-protocol-contracts-VERSION.tar.gz` additionally contains the complete +contract layout, both nested directory fixture trees, and its own checksum +manifest. Extract that bundle to validate nested fixtures; the GitHub asset +glob cannot upload directories. A Rust crate version has exactly one policy-owned repository release, revision, asset name, and digest in `policy/rust-crate-release.json`. Only that owning release may include the self-contained registry-ready `.crate`; later repository releases omit it @@ -107,9 +112,12 @@ Git revision. ## Rust registry publication -The unpublished 0.2.0 candidate sets `package.publish = false`; both Cargo and -the policy checker reject publication. Enabling publication requires a separate -reviewed source and policy activation, with a new immutable package checksum. +The unpublished 0.2.0 candidate is prepared for a future registry release. Its +manifest allows only crates.io so the next reviewed source release can produce +its final publishable bytes. This is not an upload authorization: the manual +workflow has no OIDC permission, registry token, or upload command. +`policy/rust-crate-next.json` contains no invented revision or checksum; it +remains `awaiting-source-release` until actual artifacts receive separate review. Crate `atrinik-protocol` version `0.1.0` is registered on crates.io with SHA-256 @@ -127,7 +135,7 @@ policy digest. It is retained as immutable release history; ordinary future repository releases omit crate `0.1.0` rather than regenerating it. `policy/rust-crate-publishing.json` records the registered coordinates and -keeps future publication `disabled-until-reviewed-activation`. Publishing is +keeps future publication `prepared-awaiting-reviewed-artifact`. Publishing is permanent and is never implied by merging ordinary protocol changes or by the semantic-release workflow. @@ -149,10 +157,49 @@ before proposing contract material. The cross-repository roadmap is ## Unpublished access-token crate candidate `policy/rust-crate-candidate.json` records source version 0.2.0 as explicitly -unpublished with publication disabled. Aggregate validation packages and builds +unpublished with registry upload disabled. Aggregate validation packages and builds that candidate locally; source releases omit a registry crate until a separately reviewed immutable release policy assigns its version, revision and digest. The existing published 0.1.0 release policy and checksum are unchanged. A temporary task-owned dependency override may validate coordinated consumers, but consumers must pin an actual immutable release before readiness; no permanent sibling path or fabricated release is accepted. + + +## Preparing the next Rust crate + +The manual `publish-crate.yml` workflow currently prepares only. Run it on main +with an actual published source tag and its full revision after separately +approving that dispatch. It checks local and public tag identity, release +provenance, source ancestry and cleanliness, packages twice with Rust 1.97.1, +checks package inventory/VCS metadata/registry-only dependencies, and emits the +actual `.crate` and `artifact.json`. It never requests a registry token or +uploads to crates.io. Release v2.6.0 has `publish = false` and is deliberately +rejected: changing its manifest would create different, unreviewed bytes. + +After a separately approved source merge and release, use the resulting exact +artifact evidence in a small activation PR. That PR must replace the pending +artifact policy with the real repository release, source revision, asset name +and SHA-256; retain the immutable published 0.1.0 record; and add the reviewed +Trusted Publishing upload job. Do not package activation HEAD: reproduce the +pinned prepared-source release. Attach the exact reviewed crate to its owning +release only with explicit authorization, then verify the public asset digest. +GitHub currently reports v2.6.0 as `immutable: false`; a pinned content digest +and repeated tag/asset checks detect drift but do not enable release immutability. + +The activation must bind crates.io Trusted Publishing to `atrinik/protocol`, +`publish-crate.yml`, and `crates-io-release`. The proposed GitHub environment is +main-only with owner review and no secrets or variables; setup is a separate +owner action. Grant `id-token: write` only to the future upload job. Reproduce +and verify the reviewed crate before token exchange, including again after +environment review. Pass the short-lived token only to `cargo publish --locked +--no-verify`; retain the action's token revocation. Recheck both the public API +and sparse-index checksum after upload. Matching existing bytes are idempotent +success, different bytes are terminal, and an indeterminate result requires +public-state inspection before retry. No long-lived registry token is allowed. + +The credential-free verifier already tests these artifact and registry checks, +but adding pins or upload capability still requires a separate policy review. +The current policy checker rejects that activation until reviewed code changes +make it explicit. See the [official token action](https://github.com/rust-lang/crates-io-auth-action) +and [Cargo publication contract](https://doc.rust-lang.org/cargo/commands/cargo-publish.html). diff --git a/crates/atrinik-protocol/Cargo.toml b/crates/atrinik-protocol/Cargo.toml index 84fe951..9d5f7d8 100644 --- a/crates/atrinik-protocol/Cargo.toml +++ b/crates/atrinik-protocol/Cargo.toml @@ -10,7 +10,7 @@ readme = "README.md" repository.workspace = true rust-version.workspace = true version.workspace = true -publish = false +publish = ["crates-io"] [dependencies] bytes.workspace = true diff --git a/policy/rust-crate-candidate.json b/policy/rust-crate-candidate.json index 02d8dd6..ec02e95 100644 --- a/policy/rust-crate-candidate.json +++ b/policy/rust-crate-candidate.json @@ -4,5 +4,5 @@ "version": "0.2.0", "base_published_version": "0.1.0", "status": "unpublished", - "publication": "disabled" + "publication": "prepared-without-upload" } diff --git a/policy/rust-crate-next.json b/policy/rust-crate-next.json new file mode 100644 index 0000000..3e217e9 --- /dev/null +++ b/policy/rust-crate-next.json @@ -0,0 +1,7 @@ +{ + "schema_version": 1, + "name": "atrinik-protocol", + "version": "0.2.0", + "status": "awaiting-source-release", + "artifact": null +} diff --git a/policy/rust-crate-publishing.json b/policy/rust-crate-publishing.json index 9fd0ee6..b82c679 100644 --- a/policy/rust-crate-publishing.json +++ b/policy/rust-crate-publishing.json @@ -10,7 +10,7 @@ "sha256": "413c4da6c1b304d4a622065efe0d36c3f591041972f1a5ee76c538926f3c0b6b" }, "future": { - "status": "disabled-until-reviewed-activation", + "status": "prepared-awaiting-reviewed-artifact", "authentication": "trusted-publishing", "repository_owner": "atrinik", "repository": "protocol", diff --git a/tools/check-crate-release-policy.py b/tools/check-crate-release-policy.py index 3a0e53c..351ce07 100755 --- a/tools/check-crate-release-policy.py +++ b/tools/check-crate-release-policy.py @@ -15,10 +15,10 @@ WORKFLOWS = ROOT / ".github" / "workflows" PUBLISH_WORKFLOW = WORKFLOWS / "publish-crate.yml" BOOTSTRAP_CHECK = ROOT / "tools" / "check-crate-publication.py" -EXPECTED_WORKFLOWS = {"pr-title.yml", "release.yml", "validate.yml"} +EXPECTED_WORKFLOWS = {"pr-title.yml", "release.yml", "validate.yml", "publish-crate.yml"} EXPECTED_FUTURE_POLICY = { - "status": "disabled-until-reviewed-activation", + "status": "prepared-awaiting-reviewed-artifact", "authentication": "trusted-publishing", "repository_owner": "atrinik", "repository": "protocol", @@ -50,13 +50,13 @@ def main() -> None: if not candidate_path.is_file(): raise SystemExit("unpublished crate requires explicit candidate policy") manifest = tomllib.loads((ROOT / "crates/atrinik-protocol/Cargo.toml").read_text()) - if manifest["package"].get("publish") is not False: - raise SystemExit("unpublished crate manifest must set publish = false") + if manifest["package"].get("publish") != ["crates-io"]: + raise SystemExit("prepared crate manifest must restrict publish to crates-io") candidate = load_json(candidate_path) expected_candidate = { "schema_version": 1, "name": release["name"], "version": workspace_version, "base_published_version": release["version"], - "status": "unpublished", "publication": "disabled", + "status": "unpublished", "publication": "prepared-without-upload", } if candidate != expected_candidate or not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", workspace_version): raise SystemExit("unpublished crate candidate policy changed") @@ -85,8 +85,18 @@ def main() -> None: if publishing != expected: raise SystemExit("reviewed Rust registry policy changed") - if PUBLISH_WORKFLOW.exists(): - raise SystemExit("Rust registry publication is not activated") + next_policy = load_json(ROOT / "policy/rust-crate-next.json") + if next_policy != {"schema_version": 1, "name": release["name"], + "version": workspace_version, "status": "awaiting-source-release", + "artifact": None}: + raise SystemExit("actual artifact pins require a separate activation review") + if not PUBLISH_WORKFLOW.is_file(): + raise SystemExit("credential-free preparation workflow is required") + preparation = PUBLISH_WORKFLOW.read_text() + for forbidden in ("id-token:", "contents: write", "push:", "pull_request:", + "workflow_call:", "secrets.", "environment:"): + if forbidden in preparation: + raise SystemExit("preparation workflow must remain credential-free and manual") if BOOTSTRAP_CHECK.exists(): raise SystemExit("one-time bootstrap checker must remain removed") @@ -111,7 +121,7 @@ def main() -> None: required_readme = ( "The one-use bootstrap workflow", "has been removed", - "disabled-until-reviewed-activation", + "prepared-awaiting-reviewed-artifact", "crates-io-release", "Trusted Publishing", ) diff --git a/tools/crate_publication.py b/tools/crate_publication.py new file mode 100644 index 0000000..a2e1c8b --- /dev/null +++ b/tools/crate_publication.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python3 +"""Credential-free preparation and verification; this program never uploads.""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import tarfile +import time +import tomllib +import urllib.error +import urllib.request + +ROOT = Path(__file__).resolve().parent.parent +NAME = 'atrinik-protocol' +VERSION = '0.2.0' +ASSET = f'{NAME}-{VERSION}.crate' +MAX_BYTES = 16 * 1024 * 1024 +API = 'https://api.github.com/repos/atrinik/protocol' + + +def require(condition, message): + if not condition: + raise ValueError(message) + + +def load_policy(path): + policy = json.loads(Path(path).read_text()) + require(set(policy) == {'schema_version', 'name', 'version', 'status', 'artifact'}, 'unexpected policy fields') + require(policy['schema_version'] == 1 and policy['name'] == NAME and policy['version'] == VERSION, 'unexpected crate identity') + if policy['status'] == 'awaiting-source-release': + require(policy['artifact'] is None, 'pending policy must not invent artifact pins') + else: + require(policy['status'] == 'ready-for-publication', 'unknown publication state') + validate_artifact(policy['artifact']) + return policy + + +def validate_artifact(value): + require(isinstance(value, dict) and set(value) == {'repository_release', 'revision', 'asset', 'sha256'}, 'incomplete artifact pins') + require(re.fullmatch(r'[0-9]+\.[0-9]+\.[0-9]+', value['repository_release']) is not None, 'invalid release version') + require(re.fullmatch(r'[0-9a-f]{40}', value['revision']) is not None, 'invalid source revision') + require(value['asset'] == ASSET and re.fullmatch(r'[0-9a-f]{64}', value['sha256']) is not None, 'invalid artifact identity') + + +def execution_boundary(): + require(not any(os.environ.get(key) for key in ('CARGO_REGISTRY_TOKEN', 'CARGO_REGISTRIES_CRATES_IO_TOKEN', 'CARGO_REGISTRY_BOOTSTRAP_TOKEN')), 'preparation must run without registry credentials') + if os.environ.get('GITHUB_ACTIONS') == 'true': + expected = {'GITHUB_REPOSITORY': 'atrinik/protocol', 'GITHUB_REPOSITORY_ID': '1327106950', + 'GITHUB_REF': 'refs/heads/main', 'GITHUB_EVENT_NAME': 'workflow_dispatch', + 'GITHUB_WORKFLOW_REF': 'atrinik/protocol/.github/workflows/publish-crate.yml@refs/heads/main'} + require(all(os.environ.get(k) == v for k, v in expected.items()), 'unexpected publication workflow identity') + + +class SafeRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + from urllib.parse import urlparse + parsed = urlparse(newurl) + require(parsed.scheme == 'https' and parsed.hostname in {'github.com', 'release-assets.githubusercontent.com', 'objects.githubusercontent.com', 'crates.io', 'index.crates.io'}, 'unexpected download redirect') + return super().redirect_request(req, fp, code, msg, headers, newurl) + + +def download(url, absent=False): + request = urllib.request.Request(url, headers={'User-Agent': 'atrinik-protocol-release-verifier (https://github.com/atrinik/protocol)', 'Accept': 'application/json'}) + try: + with urllib.request.build_opener(SafeRedirect).open(request, timeout=20) as response: + data = response.read(MAX_BYTES + 1) + require(len(data) <= MAX_BYTES, 'response exceeds bound') + return data + except urllib.error.HTTPError as error: + if absent and error.code == 404: + return None + raise + + +def git(*arguments, cwd=ROOT): + return subprocess.check_output(['git', *arguments], cwd=cwd, text=True, timeout=60).strip() + + +def release(tag, revision): + require(re.fullmatch(r'v[0-9]+\.[0-9]+\.[0-9]+', tag) is not None, 'invalid source tag') + require(re.fullmatch(r'[0-9a-f]{40}', revision) is not None, 'invalid source revision') + require(git('rev-parse', '--verify', f'{tag}^{{commit}}') == revision, 'source tag drift') + subprocess.run(['git', 'merge-base', '--is-ancestor', revision, 'origin/main'], cwd=ROOT, check=True, timeout=60) + remote = json.loads(download(f'{API}/git/ref/tags/{tag}'))['object'] + for _ in range(2): + if remote['type'] != 'tag': + break + remote = json.loads(download(f"{API}/git/tags/{remote['sha']}"))['object'] + require(remote['type'] == 'commit' and remote['sha'] == revision, 'remote source tag drift') + value = json.loads(download(f'{API}/releases/tags/{tag}')) + require(value['tag_name'] == tag and not value['draft'] and not value['prerelease'] and value.get('published_at'), 'source release is not published') + provenance = json.loads(release_asset(value, tag, 'provenance.json')) + require(provenance['revision'] == revision and provenance['version'] == tag[1:], 'release provenance drift') + return value + + +def release_asset(value, tag, name): + assets = [asset for asset in value['assets'] if asset['name'] == name] + require(len(assets) == 1 and assets[0]['state'] == 'uploaded', 'missing or duplicate release asset') + asset = assets[0] + url = f'https://github.com/atrinik/protocol/releases/download/{tag}/{name}' + require(asset['browser_download_url'] == url and asset['size'] <= MAX_BYTES, 'unexpected release asset location or bound') + data = download(url) + digest = 'sha256:' + hashlib.sha256(data).hexdigest() + require(asset.get('digest') == digest and len(data) == asset['size'], 'release API asset digest mismatch') + return data + + +def verify_crate(path, revision, inventory): + with tarfile.open(path, 'r:gz') as archive: + members = [] + total = 0 + for member in archive: + total += member.size + require(len(members) < 256 and total <= MAX_BYTES, 'crate archive exceeds bound') + members.append(member) + prefix = f'{NAME}-{VERSION}/' + names = [] + for member in members: + require(member.isfile() and member.name.startswith(prefix), 'unexpected archive member') + name = member.name[len(prefix):] + require(name and '..' not in Path(name).parts and not name.startswith('/'), 'unsafe archive path') + names.append(name) + require(len(names) == len(set(names)) and set(names) == set(inventory), 'crate file inventory mismatch') + def read(name): + return archive.extractfile(prefix + name).read() + vcs = json.loads(read('.cargo_vcs_info.json')) + require(vcs == {'git': {'sha1': revision}, 'path_in_vcs': 'crates/atrinik-protocol'}, 'crate VCS provenance mismatch') + manifest = tomllib.loads(read('Cargo.toml').decode()) + package = manifest['package'] + require(package['name'] == NAME and package['version'] == VERSION and package.get('publish') == ['crates-io'], 'crate is not the publishable reviewed version') + def no_source_override(value): + if isinstance(value, dict): + require('path' not in value and 'git' not in value, 'unapproved crate dependency source') + for child in value.values(): + no_source_override(child) + elif isinstance(value, list): + for child in value: + no_source_override(child) + for key in ('dependencies', 'dev-dependencies', 'build-dependencies', 'target'): + no_source_override(manifest.get(key, {})) + lock = tomllib.loads(read('Cargo.lock').decode()) + for package in lock['package']: + if package['name'] == NAME and package['version'] == VERSION: + require('source' not in package, 'unexpected root crate source') + else: + require(package.get('source') == 'registry+https://github.com/rust-lang/crates.io-index', 'unapproved locked dependency source') + + +def registry_state(digest): + api = download(f'https://crates.io/api/v1/crates/{NAME}/{VERSION}', absent=True) + index = download(f'https://index.crates.io/at/ri/{NAME}', absent=True) + api_digest = None if api is None else json.loads(api)['version']['checksum'] + entries = [] if index is None else [json.loads(line) for line in index.splitlines() if line] + matches = [entry for entry in entries if entry['vers'] == VERSION] + require(len(matches) <= 1, 'duplicate registry index version') + index_digest = None if not matches else matches[0]['cksum'] + require(all(value in (None, digest) for value in (api_digest, index_digest)), 'registry checksum conflict; never republish') + if api_digest == digest and index_digest == digest: + return 'present' + if api_digest is None and index_digest is None: + return 'absent' + return 'indeterminate' + + +def prepare(tag, revision, output, policy, publishing=False): + require(not any(char in str(output) for char in '\r\n'), 'invalid output path') + require(not output.exists() and not output.is_relative_to(ROOT), 'output must be absent and outside source checkout') + value = release(tag, revision) + require(subprocess.check_output(['rustc', '+1.97.1', '--version'], text=True, timeout=60).startswith('rustc 1.97.1 '), 'wrong Rust toolchain') + output.mkdir(parents=True) + source = output / 'source' + subprocess.run(['git', 'worktree', 'add', '--detach', str(source), revision], cwd=ROOT, check=True, timeout=60) + require(not git('status', '--porcelain', cwd=source), 'dirty source worktree') + manifest = tomllib.loads((source / 'crates/atrinik-protocol/Cargo.toml').read_text()) + require(manifest['package'].get('publish') == ['crates-io'], 'source release has publication disabled; prepare a reviewed publishable source release first') + inventory = (source / 'policy/rust-crate-files.txt').read_text().splitlines() + packages = [] + for number in (1, 2): + target = output / f'package-{number}' + subprocess.run(['cargo', '+1.97.1', 'package', '--locked', '--manifest-path', str(source / 'crates/atrinik-protocol/Cargo.toml'), '--target-dir', str(target)], cwd=source, check=True, timeout=600) + package = target / 'package' / ASSET + verify_crate(package, revision, inventory) + packages.append(package) + require(not git('status', '--porcelain', cwd=source), 'packaging changed source') + first = packages[0].read_bytes() + require(first == packages[1].read_bytes(), 'crate reproduction mismatch') + digest = hashlib.sha256(first).hexdigest() + artifact = {'repository_release': tag[1:], 'revision': revision, 'asset': ASSET, 'sha256': digest} + ready = False + if publishing: + require(artifact == policy['artifact'], 'reproduced artifact differs from reviewed pins') + require(release_asset(value, tag, ASSET) == first, 'published release asset differs from reproduced crate') + state = registry_state(digest) + require(state != 'indeterminate', 'registry result indeterminate; re-read before any retry') + ready = state == 'absent' + # Re-read public release and local ref after expensive preparation. + current = release(tag, revision) + require(current['id'] == value['id'], 'release identity changed during preparation') + if publishing: + require(release_asset(current, tag, ASSET) == first, 'release asset changed during preparation') + shutil.copyfile(packages[0], output / ASSET) + (output / 'artifact.json').write_text(json.dumps(artifact, indent=2) + '\n') + if os.environ.get('GITHUB_OUTPUT'): + with open(os.environ['GITHUB_OUTPUT'], 'a') as stream: + stream.write(f'ready_for_upload={str(ready).lower()}\nsource_directory={source}\n') + print(json.dumps({'artifact': artifact, 'ready_for_upload': ready, 'github_release_immutable': current.get('immutable', False)})) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('operation', choices=('prepare', 'verify-publish', 'verify-registry')) + parser.add_argument('--policy', type=Path, default=ROOT / 'policy/rust-crate-next.json') + parser.add_argument('--source-tag') + parser.add_argument('--source-revision') + parser.add_argument('--output', type=Path) + args = parser.parse_args() + execution_boundary() + policy = load_policy(args.policy) + if args.operation == 'prepare': + require(args.source_tag is not None and args.source_revision is not None and args.output is not None, 'prepare requires exact source coordinates and output') + prepare(args.source_tag, args.source_revision, args.output.resolve(), policy) + else: + require(policy['status'] == 'ready-for-publication', 'publication disabled until actual source-release artifact pins are reviewed') + artifact = policy['artifact'] + if args.operation == 'verify-publish': + require(args.output is not None, 'verification output required') + prepare('v' + artifact['repository_release'], artifact['revision'], args.output.resolve(), policy, True) + else: + for attempt in range(6): + if registry_state(artifact['sha256']) == 'present': + print('Public registry API and sparse-index checksums match reviewed artifact') + return + if attempt < 5: + time.sleep(5) + raise ValueError('registry verification indeterminate; inspect public state before any retry') + + +if __name__ == '__main__': + try: + main() + except (ValueError, KeyError, TypeError, OSError, subprocess.SubprocessError) as error: + raise SystemExit(f'crate verification failed: {error}') from error diff --git a/tools/package-release.sh b/tools/package-release.sh index 9d6de5a..4a37438 100755 --- a/tools/package-release.sh +++ b/tools/package-release.sh @@ -43,7 +43,7 @@ metadata_crate_version=$(cargo metadata --locked --offline --no-deps \ python3 tools/check-crate-release-policy.py if [[ ${metadata_crate_version} != "${crate_version}" ]]; then test "$(jq -er '.version' policy/rust-crate-candidate.json)" = "${metadata_crate_version}" - test "$(jq -er '.publication' policy/rust-crate-candidate.json)" = disabled + test "$(jq -er '.publication' policy/rust-crate-candidate.json)" = prepared-without-upload fi crate_included=false diff --git a/tools/test_crate_publication.py b/tools/test_crate_publication.py new file mode 100644 index 0000000..26c1eb5 --- /dev/null +++ b/tools/test_crate_publication.py @@ -0,0 +1,117 @@ +"""Network-free negative tests for the credential-free publication verifier.""" +import importlib.util +import io +import json +import os +from pathlib import Path +import tarfile +import tempfile +import unittest +from unittest.mock import patch + +SPEC = importlib.util.spec_from_file_location('publication', Path(__file__).with_name('crate_publication.py')) +P = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(P) +REVISION = 'a' * 40 +DIGEST = 'b' * 64 +ARTIFACT = {'repository_release': '2.7.0', 'revision': REVISION, + 'asset': P.ASSET, 'sha256': DIGEST} + + +class PublicationTests(unittest.TestCase): + def policy(self, artifact=None): + return {'schema_version': 1, 'name': P.NAME, 'version': P.VERSION, + 'status': 'awaiting-source-release' if artifact is None else 'ready-for-publication', + 'artifact': artifact} + + def test_pending_policy_cannot_publish(self): + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / 'policy.json' + path.write_text(json.dumps(self.policy())) + with patch('sys.argv', ['checker', 'verify-publish', '--policy', str(path)]), patch.object(P, 'prepare') as prepare: + with self.assertRaisesRegex(ValueError, 'publication disabled'): + P.main() + prepare.assert_not_called() + + def test_pending_policy_rejects_fake_pins(self): + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / 'policy.json' + value = self.policy() + value['artifact'] = ARTIFACT + path.write_text(json.dumps(value)) + with self.assertRaisesRegex(ValueError, 'must not invent'): + P.load_policy(path) + + def test_artifact_requires_complete_exact_pins(self): + P.validate_artifact(ARTIFACT) + for key, value in [('revision', 'main'), ('sha256', 'unknown'), ('asset', '../x'), ('repository_release', 'latest')]: + with self.subTest(key=key), self.assertRaises(ValueError): + P.validate_artifact({**ARTIFACT, key: value}) + with self.assertRaises(ValueError): + P.validate_artifact({}) + + def test_registry_states_and_conflict(self): + def values(api, index): + return [None if api is None else json.dumps({'version': {'checksum': api}}).encode(), + None if index is None else json.dumps({'vers': P.VERSION, 'cksum': index}).encode()] + for api, index, expected in [(None, None, 'absent'), (DIGEST, DIGEST, 'present'), (DIGEST, None, 'indeterminate')]: + with patch.object(P, 'download', side_effect=values(api, index)): + self.assertEqual(P.registry_state(DIGEST), expected) + with patch.object(P, 'download', side_effect=values('c' * 64, DIGEST)), self.assertRaisesRegex(ValueError, 'conflict'): + P.registry_state(DIGEST) + + def test_rejects_registry_credentials_and_wrong_workflow(self): + with patch.dict(os.environ, {'CARGO_REGISTRY_TOKEN': 'synthetic-test-only'}, clear=True), self.assertRaisesRegex(ValueError, 'without registry credentials'): + P.execution_boundary() + with patch.dict(os.environ, {'GITHUB_ACTIONS': 'true', 'GITHUB_REPOSITORY': 'other/protocol'}, clear=True), self.assertRaisesRegex(ValueError, 'workflow identity'): + P.execution_boundary() + + def test_tag_drift_fails_before_packaging(self): + with patch.object(P, 'git', return_value='c' * 40), patch.object(P, 'download') as download: + with self.assertRaisesRegex(ValueError, 'source tag drift'): + P.release('v2.7.0', REVISION) + download.assert_not_called() + with patch.object(P, 'git', return_value=REVISION), patch.object(P.subprocess, 'run'), patch.object(P, 'download', return_value=json.dumps({'object': {'type': 'commit', 'sha': 'c' * 40}}).encode()): + with self.assertRaisesRegex(ValueError, 'remote source tag drift'): + P.release('v2.7.0', REVISION) + + def test_asset_mismatch_and_redirect_are_rejected(self): + value = {'assets': [{'name': P.ASSET, 'state': 'uploaded', 'size': 3, + 'browser_download_url': f'https://github.com/atrinik/protocol/releases/download/v2.7.0/{P.ASSET}', 'digest': 'sha256:' + DIGEST}]} + with patch.object(P, 'download', return_value=b'bad'), self.assertRaisesRegex(ValueError, 'digest mismatch'): + P.release_asset(value, 'v2.7.0', P.ASSET) + with self.assertRaisesRegex(ValueError, 'redirect'): + P.SafeRedirect().redirect_request(None, None, 302, '', {}, 'http://attacker.invalid/') + + def test_crate_identity_inventory_and_dependency_checks(self): + entries = { + '.cargo_vcs_info.json': json.dumps({'git': {'sha1': REVISION}, 'path_in_vcs': 'crates/atrinik-protocol'}).encode(), + 'Cargo.toml': b'[package]\nname="atrinik-protocol"\nversion="0.2.0"\npublish=["crates-io"]\n', + 'Cargo.lock': b'version=4\n[[package]]\nname="atrinik-protocol"\nversion="0.2.0"\n', + } + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / P.ASSET + def write(data): + with tarfile.open(path, 'w:gz') as archive: + for name, content in data.items(): + member = tarfile.TarInfo(f'{P.NAME}-{P.VERSION}/{name}') + member.size = len(content) + archive.addfile(member, io.BytesIO(content)) + write(entries) + P.verify_crate(path, REVISION, entries) + for name, replacement, error in [ + ('.cargo_vcs_info.json', b'{"git":{"sha1":"wrong"}}', 'VCS'), + ('Cargo.toml', entries['Cargo.toml'].replace(b'["crates-io"]', b'false'), 'publishable'), + ('Cargo.toml', entries['Cargo.toml'] + b'[dependencies.x]\npath="../x"\n', 'dependency'), + ('Cargo.lock', entries['Cargo.lock'] + b'[[package]]\nname="x"\nversion="1"\nsource="git+evil"\n', 'dependency'), + ]: + write({**entries, name: replacement}) + with self.subTest(name=name, error=error), self.assertRaisesRegex(ValueError, error): + P.verify_crate(path, REVISION, entries) + write({**entries, 'unexpected': b'x'}) + with self.assertRaisesRegex(ValueError, 'inventory'): + P.verify_crate(path, REVISION, entries) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/test_crate_release_policy.py b/tools/test_crate_release_policy.py index 8ea5b9d..bdd7251 100755 --- a/tools/test_crate_release_policy.py +++ b/tools/test_crate_release_policy.py @@ -26,6 +26,7 @@ def setUp(self) -> None: "Cargo.toml", "crates/atrinik-protocol/Cargo.toml", "policy/rust-crate-candidate.json", + "policy/rust-crate-next.json", "README.md", "policy/rust-crate-publishing.json", "policy/rust-crate-release.json", @@ -64,10 +65,10 @@ def test_current_policy_is_disabled_and_valid(self) -> None: def test_rejects_manifest_publication(self) -> None: path = self.root / "crates/atrinik-protocol/Cargo.toml" original = path.read_text() - for setting in ('publish = true', 'publish = ["crates-io"]', ''): + for setting in ('publish = true', 'publish = false', ''): with self.subTest(setting=setting): - path.write_text(original.replace('publish = false', setting)) - self.assert_rejected("manifest must set publish = false") + path.write_text(original.replace('publish = ["crates-io"]', setting)) + self.assert_rejected("manifest must restrict publish to crates-io") def test_rejects_candidate_publication(self) -> None: path = self.root / "policy/rust-crate-candidate.json" @@ -92,10 +93,18 @@ def test_rejects_policy_drift(self) -> None: path.write_text(json.dumps(policy), encoding="utf-8") self.assert_rejected("reviewed Rust registry policy changed") - def test_rejects_publish_workflow_reintroduction(self) -> None: - path = self.root / ".github" / "workflows" / "publish-crate.yml" - path.write_text("name: Publish\n", encoding="utf-8") - self.assert_rejected("Rust registry publication is not activated") + def test_rejects_publish_capability_before_artifact_review(self) -> None: + path = self.root / ".github/workflows/publish-crate.yml" + with path.open("a") as stream: + stream.write("\n# id-token: write\n") + self.assert_rejected("preparation workflow must remain credential-free") + + def test_rejects_unreviewed_artifact_pins(self) -> None: + path = self.root / "policy/rust-crate-next.json" + value = json.loads(path.read_text()) + value["artifact"] = {"revision": "a" * 40} + path.write_text(json.dumps(value)) + self.assert_rejected("separate activation review") def test_rejects_any_unreviewed_workflow(self) -> None: path = self.root / ".github" / "workflows" / "other.yml" diff --git a/tools/validate.sh b/tools/validate.sh index 0b1b9f6..bebf1ba 100755 --- a/tools/validate.sh +++ b/tools/validate.sh @@ -33,15 +33,7 @@ cmp LICENSE crates/atrinik-protocol/LICENSE tools/check-dependencies.sh tools/test-check-dependencies.sh python3 tools/check-crate-release-policy.py -python3 -m unittest tools/test_crate_release_policy.py tools/test_access_route_schema.py -# Cargo itself must reject publication before registry authentication or upload. -if publication_error=$(cargo publish --dry-run --locked --offline \ - --manifest-path crates/atrinik-protocol/Cargo.toml 2>&1); then - echo "Unpublished candidate unexpectedly permits cargo publish." >&2 - exit 1 -fi -grep -Fq 'cannot be published' <<<"${publication_error}" -unset publication_error +python3 -m unittest tools/test_crate_release_policy.py tools/test_access_route_schema.py tools/test_crate_publication.py jq empty \ fixtures/access-tokens-v1.json \ fixtures/access-routes-v1.json \ @@ -58,6 +50,7 @@ jq empty \ schema/metaserver-game-publisher-v2.schema.json \ schema/metaserver-classic-publisher-v3.schema.json \ policy/rust-crate-candidate.json \ + policy/rust-crate-next.json \ fixtures/framing.json \ fixtures/metaserver-directory-v1.json \ fixtures/metaserver-directory-v1/*.json \ @@ -157,6 +150,12 @@ cargo package --locked --offline --allow-dirty \ --manifest-path crates/atrinik-protocol/Cargo.toml \ --target-dir "${protocol_crate_target}" test -s "${protocol_crate_target}/package/${protocol_crate_asset}" +python3 - "${protocol_crate_target}/package/${protocol_crate_asset}" "$(git rev-parse HEAD)" <<'PYTHON' +from pathlib import Path +import sys +from tools.crate_publication import verify_crate +verify_crate(Path(sys.argv[1]), sys.argv[2], Path("policy/rust-crate-files.txt").read_text().splitlines()) +PYTHON protocol_crate_listing=$(mktemp /tmp/atrinik-protocol-crate-files.XXXXXX) protocol_crate_extract=$(mktemp -d /tmp/atrinik-protocol-crate-extract.XXXXXX) tar -tzf "${protocol_crate_target}/package/${protocol_crate_asset}" \ From 888f4d877b8cc13b24660f11bc0e9a9d95a9ff0e Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 4 Oct 2026 12:03:31 -0500 Subject: [PATCH 3/4] fix(release): reject redirected nested fixture roots --- tools/contract_release_bundle.py | 4 ++++ tools/package-release.sh | 7 +++++-- tools/test_release_checksums.py | 18 ++++++++++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/tools/contract_release_bundle.py b/tools/contract_release_bundle.py index 31248f4..d91822b 100644 --- a/tools/contract_release_bundle.py +++ b/tools/contract_release_bundle.py @@ -71,6 +71,10 @@ def _read_payloads(output: Path) -> dict[str, bytes]: if output.is_symlink() or not output.is_dir(): raise ValueError("release output must be a real directory") + for root_name in NESTED_ROOTS: + root = output / root_name + if root.is_symlink() or not root.is_dir(): + raise ValueError(f"unsafe nested contract root: {root_name}") if len(PAYLOAD_PATHS) > MAX_FILES: raise ValueError("contract bundle exceeds its file-count bound") payloads = {} diff --git a/tools/package-release.sh b/tools/package-release.sh index 4a37438..2c97c5b 100755 --- a/tools/package-release.sh +++ b/tools/package-release.sh @@ -131,6 +131,9 @@ jq -n \ # Semantic-release uploads build/release/*, so the downloadable output and # its checksum inventory must both be flat. SHA256SUMS cannot cover itself. test -z "$(find . -mindepth 1 -type d -print -quit)" - find . -mindepth 1 -maxdepth 1 -type f ! -name SHA256SUMS -printf '%P\0' \ - | LC_ALL=C sort -z | xargs -0 sha256sum >SHA256SUMS + mapfile -d '' -t checksum_files < <( + find . -mindepth 1 -maxdepth 1 -type f ! -name SHA256SUMS -printf '%P\0' \ + | LC_ALL=C sort -z + ) + sha256sum "${checksum_files[@]}" >SHA256SUMS ) diff --git a/tools/test_release_checksums.py b/tools/test_release_checksums.py index 147bb0d..4368bed 100644 --- a/tools/test_release_checksums.py +++ b/tools/test_release_checksums.py @@ -220,6 +220,24 @@ def main(): with tempfile.TemporaryDirectory(prefix="atrinik-checksum-regression-") as temporary: temporary = Path(temporary) + for nested_root in bundle_spec.NESTED_ROOTS: + staging = temporary / ("symlink-root-" + nested_root) + staging.mkdir() + for relative in bundle_spec.PAYLOAD_PATHS: + path = staging / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"synthetic contract fixture") + outside = temporary / ("outside-" + nested_root) + (staging / nested_root).rename(outside) + (staging / nested_root).symlink_to(outside, target_is_directory=True) + try: + bundle_spec.build_bundle(staging, "0.0.0-symlink-test") + except ValueError as error: + if "unsafe nested contract root" not in str(error): + raise + else: + raise AssertionError("symlinked fixture root was packaged") + for name in required: root = temporary / ("missing-" + name.replace(".", "-")) shutil.copytree(source, root) From fec06675d996c41e2802a3c8e71d6239733773b2 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 4 Oct 2026 12:13:15 -0500 Subject: [PATCH 4/4] test(release): isolate publication workflow environment --- tools/test_crate_publication.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tools/test_crate_publication.py b/tools/test_crate_publication.py index 26c1eb5..4af3415 100644 --- a/tools/test_crate_publication.py +++ b/tools/test_crate_publication.py @@ -28,7 +28,7 @@ def test_pending_policy_cannot_publish(self): with tempfile.TemporaryDirectory() as temporary: path = Path(temporary) / 'policy.json' path.write_text(json.dumps(self.policy())) - with patch('sys.argv', ['checker', 'verify-publish', '--policy', str(path)]), patch.object(P, 'prepare') as prepare: + with patch.dict(os.environ, {}, clear=True), patch('sys.argv', ['checker', 'verify-publish', '--policy', str(path)]), patch.object(P, 'prepare') as prepare: with self.assertRaisesRegex(ValueError, 'publication disabled'): P.main() prepare.assert_not_called() @@ -66,6 +66,16 @@ def test_rejects_registry_credentials_and_wrong_workflow(self): with patch.dict(os.environ, {'GITHUB_ACTIONS': 'true', 'GITHUB_REPOSITORY': 'other/protocol'}, clear=True), self.assertRaisesRegex(ValueError, 'workflow identity'): P.execution_boundary() + def test_accepts_exact_manual_workflow_identity(self): + environment = { + 'GITHUB_ACTIONS': 'true', 'GITHUB_REPOSITORY': 'atrinik/protocol', + 'GITHUB_REPOSITORY_ID': '1327106950', 'GITHUB_REF': 'refs/heads/main', + 'GITHUB_EVENT_NAME': 'workflow_dispatch', + 'GITHUB_WORKFLOW_REF': 'atrinik/protocol/.github/workflows/publish-crate.yml@refs/heads/main', + } + with patch.dict(os.environ, environment, clear=True): + P.execution_boundary() + def test_tag_drift_fails_before_packaging(self): with patch.object(P, 'git', return_value='c' * 40), patch.object(P, 'download') as download: with self.assertRaisesRegex(ValueError, 'source tag drift'):