diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a4d5dd..da14273 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ Planned first prerelease: `v0.1.0-beta.1`. committer identities, object integrity, owner approval, and secret scans. - Human-only publication handoff for the first hosted pull request, exact CI check discovery, direct bootstrap fast-forward, and protected-branch setup. +- Fail-closed hosted-correction receipts that bind exact verifier source, local + history, remote refs, pull-request state, no-bypass rulesets, and checks. - Shared-checkout and detached linked-worktree lanes. - Exact staged-subset capture through private Git indexes. - All-or-nothing split plans with one final ref compare-and-swap. diff --git a/README.md b/README.md index 1aff171..5d4df58 100644 --- a/README.md +++ b/README.md @@ -348,8 +348,9 @@ authorizing its implementation. Non-authors can use the redacted [beta exercise](docs/BETA-EXERCISE.md) to produce independent evidence. Repository owners can use the human-gated [publication handoff](docs/PUBLICATION-HANDOFF.md) and -[hosted setup runbook](docs/HOSTED-SETUP.md). These procedures do not authorize -a push or release tag. +[hosted setup runbook](docs/HOSTED-SETUP.md). Use the +[correction receipt](docs/PUBLICATION-CORRECTION.md) after a hosted candidate +changes. These procedures do not authorize a push or release tag. Project authority and succession are in [GOVERNANCE.md](GOVERNANCE.md). Current support and safe incident-reporting guidance are in diff --git a/docs/KEP-0002-hosted-candidate-correction.md b/docs/KEP-0002-hosted-candidate-correction.md index f58d3b9..ac316b9 100644 --- a/docs/KEP-0002-hosted-candidate-correction.md +++ b/docs/KEP-0002-hosted-candidate-correction.md @@ -1,6 +1,8 @@ # KEP-0002: Hosted candidate correction receipt -Status: Proposed +Status: Accepted + +Implementation: Verified on 2026-08-17 Decision owner: Repository owner @@ -13,8 +15,9 @@ Target release: Before `v0.1.0-beta.1` Add a fail-closed receipt for a candidate that changes after the first public push. Keep the pre-first-push receipt immutable. -The correction receipt must bind the old candidate, new candidate, local -checks, remote refs, pull request, and hosted runs. It must bind the final tree. +The correction receipt must bind the verifier, old candidate, new candidate, +local checks, remote refs, pull request, and hosted runs. It must bind the final +tree. This KEP does not authorize a tag or a pull-request merge. @@ -29,10 +32,13 @@ candidate passed every required hosted check before `main` moved. The original receipt still proves the pre-first-push boundary. It does not bind the corrected final commit. -## Decision request +## Decision + +Use a second receipt type for post-first-push corrections. Require its +`pre-main` phase before a corrected candidate moves to `main`. -Approve a second receipt type for post-first-push corrections. Require that -receipt before a corrected candidate moves to `main`. +Use the `finalized` phase only to verify an observed bootstrap sequence. This +phase does not claim that the receipt existed before `main` moved. ## Required sequence @@ -54,10 +60,18 @@ If the first hosted candidate fails, use this sequence: Do not force-push a candidate branch. If a fast-forward is not possible, create a new branch. +The implemented command also supports a finalized verification sequence: + +1. Keep the closed pull request and every hosted run. +2. Check that the pull request closed without a merge. +3. Check that remote `main` and the candidate ref equal the corrected commit. +4. Run the complete local and hosted evidence command. +5. Record that the receipt used the `finalized` phase. + ## Receipt contract The command must write one new private JSON file. It must not overwrite a -file or write inside the source checkout. +file or write inside the candidate or verifier checkout. The receipt must bind: @@ -68,42 +82,56 @@ The receipt must bind: - the old candidate commit and tree. - the new candidate commit and tree. - the complete merge-free correction range. +- the verifier commit, tree, command digest, and schema digest. - the complete reviewed delta path list. - the local object and secret-scan results. -- the public author and committer identity result. +- the public author and committer email result. +- a digest and count for every author and committer name and email. - the remote candidate and `main` refs. - the pull-request number, state, head, base, and merge result. - each required run ID, event, head commit, status, and conclusion. - each required check context and GitHub App integration ID. -The receipt must exclude tokens, email addresses, local paths, private reports, +The receipt must exclude tokens, identity values, local paths, private reports, and raw security logs. ## Command behavior -Add a Go command under `scripts/`. The command must accept explicit repository, -bootstrap, old candidate, candidate ref, and pull request values. It must also -accept explicit run, path manifest, and output values. +The Go command is `scripts/publication-correction`. The command accepts these +explicit values: + +- candidate and verifier checkouts. +- phase, bootstrap, old candidate, and new candidate. +- candidate ref, pull request, ruleset, and hosted runs. +- path manifest, first receipt, and output. The command must use bounded timeouts for GitHub, Git, and secret-scan commands. It must reject incomplete dependency injection in tests. The command must stop unless: -- the checkout is clean. +- the candidate checkout is clean and uses complete history. +- the verifier checkout is clean and uses complete history. +- the verifier command and schema are tracked at the recorded verifier commit. - the new candidate is the checked-out commit. - the old candidate is an ancestor of the new candidate. - the correction range is merge-free. - the complete bootstrap delta matches the path manifest. - object checks and secret scans pass. -- all history identities match the public owner identity. +- all history author and committer emails match the public owner email. - the target repository exists and is public. - the remote candidate ref equals the new candidate. -- the pull request is open, unmerged, and points to the new candidate. +- the pull request matches the selected phase and remains unmerged. - every required hosted run succeeded on the new candidate. +- the ruleset has no bypass actor and protects deletion, force pushes, and + linear history. +- the pull-request rule requires review, stale-review dismissal, last-push + approval, resolved threads, and rebase-only merges. +- strict status checks apply to branch creation. +- every ruleset check passed through its required GitHub App integration. -The first version must create the receipt before `main` moves. A separate mode -can check `main` after the fast-forward. +The `pre-main` phase requires the open pull request and bootstrap `main`. The +`finalized` phase requires the closed pull request and corrected `main`. ## Failure behavior @@ -119,6 +147,8 @@ Test these cases: - exact one-commit correction success. - multiple linear correction commits. +- clean verifier commit, tree, command digest, and schema digest. +- dirty, wrong-module, or missing-artifact verifier checkout. - moved candidate ref. - changed path manifest. - merge commit in the correction range. @@ -127,6 +157,9 @@ Test these cases: - pull request with a different head. - merged pull request. - private or different target repository. +- missing ruleset protection or a ruleset bypass actor. +- failed provider-bound check or changed GitHub App integration. +- invalid or incomplete first receipt. - changed public identity. - secret-scan or object-check failure. - existing output file. @@ -156,14 +189,20 @@ Rejected. The public history and hosted evidence already exist. ## Rollout gate -Implement and test the command before the beta tag. Generate a private receipt -for the correction from `ed3f1fadfbc74eb0aa41ef8b90e41f403213d33d` to +The command and test matrix passed before the beta tag. The finalized run bound +the correction from `ed3f1fadfbc74eb0aa41ef8b90e41f403213d33d` to `206fa8b6a1dde1d97081133e4d447c0881849922`. -Record only the redacted result in public evidence. +Verifier commit `6f36147f24f614cff0c7010533d864f8d9ad7628` produced the +source-bound private receipt. Its digest is +`sha256:77db44ba2bfa6f007186ace931f38444521d8a29cf48bd945e07a801eda36a9a`. +Only the redacted result belongs in public evidence. ## Decision log | Date | Decision | | --- | --- | | 2026-08-17 | Propose a second immutable receipt for hosted candidate corrections. | +| 2026-08-17 | The repository owner accepted KEP-0002. | +| 2026-08-17 | Implement both phases and record the finalized correction receipt. | +| 2026-08-17 | Supersede the provisional receipt with source-bound verifier and complete ruleset evidence. | diff --git a/docs/OSS-PUBLIC-BETA.md b/docs/OSS-PUBLIC-BETA.md index 2db49a0..1c04879 100644 --- a/docs/OSS-PUBLIC-BETA.md +++ b/docs/OSS-PUBLIC-BETA.md @@ -8,7 +8,7 @@ Target: `v0.1.0-beta.1` Owner: Repository owner -Last review: 2026-08-16 +Last review: 2026-08-17 Approval: `owner-session-2026-08-16` @@ -36,8 +36,8 @@ final `main` checks. The active `main` ruleset and security settings passed the hosted setup audit. [PUBLICATION-EVIDENCE.md](PUBLICATION-EVIDENCE.md) contains the public record. -No beta tag or GitHub release exists. The independent beta, conduct, correction -receipt, security-notification, tag-approval, and tagged-install gates remain. +No beta tag or GitHub release exists. The independent beta, conduct, +security-notification, tag-approval, and tagged-install gates remain. This approval fixes the repository name, module path, and capture-only scope. It does not approve a release tag. The first-push handoff ran on 2026-08-17. @@ -96,6 +96,8 @@ The owner-controlled repository work is complete. The checkout now contains: versions. - an exact minimum Go 1.25.12 gate that excludes reachable standard-library vulnerabilities found in Go 1.25.0. +- a source-bound, fail-closed hosted-correction command, schema, negative test + matrix, and finalized private receipt for the first corrected candidate. Two clean six-target rehearsals produced equal directory trees. Every recorded archive checksum passed, and the native archive reported the target version. diff --git a/docs/OSS-PUBLIC-BETA.requirements.yaml b/docs/OSS-PUBLIC-BETA.requirements.yaml index 27a01b8..74d285f 100644 --- a/docs/OSS-PUBLIC-BETA.requirements.yaml +++ b/docs/OSS-PUBLIC-BETA.requirements.yaml @@ -444,13 +444,14 @@ "id": "OSS-034", "gate": "public-beta", "title": "Bind hosted candidate corrections", - "status": "planned", + "status": "verified", "owner": "maintainer", "human_gate": false, - "acceptance": "A fail-closed Go command binds each post-first-push correction to the old and new candidates, complete path manifest, local checks, remote candidate ref, unmerged pull request, required hosted runs, and final tree in one immutable private receipt before main moves.", + "acceptance": "A fail-closed Go command binds each post-first-push correction to the old and new candidates, exact verifier source, complete path manifest, local checks, remote candidate and main refs, unmerged pull request, required hosted runs, provider-bound checks, complete no-bypass ruleset controls, and final tree in one immutable private receipt. The pre-main phase verifies the state before main moves. The finalized phase records an observed completed bootstrap sequence without claiming earlier receipt creation.", "evidence": [ - {"kind": "kep", "value": "docs/KEP-0002-hosted-candidate-correction.md defines the receipt contract, sequence, failure behavior, and test plan.", "observed_on": "2026-08-17"}, - {"kind": "gap", "value": "The first receipt binds ed3f1fadfbc74eb0aa41ef8b90e41f403213d33d, while public main is 206fa8b6a1dde1d97081133e4d447c0881849922 after a hosted correction. No source command validates this correction flow yet.", "observed_on": "2026-08-17"} + {"kind": "artifact", "value": "scripts/publication-correction, docs/PUBLICATION-CORRECTION.md, docs/PUBLICATION-CORRECTION.schema.json, and accepted KEP-0002 implement the typed no-overwrite correction contract.", "observed_on": "2026-08-17"}, + {"kind": "test", "value": "The correction matrix covers both success phases, exact clean verifier provenance, multiple linear commits, manifest drift, merge commits, object corruption, identity drift, remote and pull-request mismatch, run and check failure, each required ruleset control, secret scans, timeouts, unsafe output, no-overwrite behavior, redaction, deterministic encoding, and schema shape.", "observed_on": "2026-08-17"}, + {"kind": "receipt", "value": "The validated finalized private receipt bound verifier commit 6f36147f24f614cff0c7010533d864f8d9ad7628, candidate correction ed3f1fadfbc74eb0aa41ef8b90e41f403213d33d to 206fa8b6a1dde1d97081133e4d447c0881849922, tree c0dd6638adcdc231840ad06406dc9f0caa38e45d, 20 bootstrap paths, 5 correction paths, 4 hosted runs, 6 required checks, the complete no-bypass ruleset contract, and GitHub Actions integration 15368. Its SHA-256 digest is 77db44ba2bfa6f007186ace931f38444521d8a29cf48bd945e07a801eda36a9a.", "observed_on": "2026-08-17"} ] } ] diff --git a/docs/OSS-READINESS.md b/docs/OSS-READINESS.md index 2d17a73..88312cf 100644 --- a/docs/OSS-READINESS.md +++ b/docs/OSS-READINESS.md @@ -75,8 +75,8 @@ capture-only scope. It does not authorize a release tag. - Enabled dependency alerts, security updates, secret scanning, push protection, and private vulnerability reporting. - A clean public-module installation smoke test for the exact untagged commit. -- A proposed hosted-correction receipt KEP for the finding that occurred after - the first push. +- An accepted hosted-correction KEP, source-bound fail-closed command, schema, + test matrix, and finalized private receipt for the first hosted correction. - Fail-closed tests for unsupported state directories and record schemas. - Symlink-escape and dual-domain creation-race tests. - Concurrent first-use directory and no-clobber publication tests. @@ -88,17 +88,15 @@ capture-only scope. It does not authorize a release tag. ## Required before the public beta tag -1. Implement and run the correction receipt in - [KEP-0002](KEP-0002-hosted-candidate-correction.md). -2. Verify the maintainer's personal security-alert notifications. -3. Approve a Code of Conduct and configure confidential conduct reporting. -4. Ask at least one independent user to follow +1. Verify the maintainer's personal security-alert notifications. +2. Approve a Code of Conduct and configure confidential conduct reporting. +3. Ask at least one independent user to follow [BETA-EXERCISE.md](BETA-EXERCISE.md) for shared and worktree flows. Retain a redacted receipt that passes [BETA-EXERCISE.schema.json](BETA-EXERCISE.schema.json). -5. Obtain explicit owner approval for `v0.1.0-beta.1`. -6. Create the signed tag only after every tracked public-beta gate passes. -7. Verify the hosted archives, checksums, and attestations. -8. Install the final tag through the public Go module path. +4. Obtain explicit owner approval for `v0.1.0-beta.1`. +5. Create the signed tag only after every tracked public-beta gate passes. +6. Verify the hosted archives, checksums, and attestations. +7. Install the final tag through the public Go module path. ## Required before a stable release diff --git a/docs/PUBLICATION-CORRECTION.md b/docs/PUBLICATION-CORRECTION.md new file mode 100644 index 0000000..2d832dc --- /dev/null +++ b/docs/PUBLICATION-CORRECTION.md @@ -0,0 +1,174 @@ +# Hosted candidate correction receipt + +When hosted checks require a candidate change after the first public push, +use this procedure. + +The command reads local and GitHub evidence. It writes one new private receipt. +It does not change a repository, ref, pull request, run, tag, or release. + +This procedure does not authorize a tag or a pull-request merge. + +## Receipt phases + +Before `main` moves, use `--phase pre-main`. This phase requires these states: + +- Remote `main` equals the reviewed bootstrap. +- The verification pull request is open and unmerged. +- The run list contains a successful candidate push. +- The run list contains one successful candidate pull-request run. + +Use `--phase finalized` to verify a completed bootstrap sequence. This phase +requires these states: + +- Remote `main` equals the corrected candidate. +- The verification pull request is closed and unmerged. +- The run list contains a successful candidate push. +- The run list contains one successful candidate pull-request run. +- The run list contains a successful `main` push. + +The finalized phase records observed history. It does not claim that the +receipt existed before `main` moved. + +## Evidence contract + +The command verifies these local facts: + +- The verifier checkout is clean, complete, and free of replacement refs. +- The receipt binds the verifier commit, tree, command digest, and schema digest. +- The candidate checkout is clean and uses complete history. +- The checked-out commit equals `--candidate`. +- No Git replacement ref changes the history. +- `git fsck --strict --no-dangling` passes. +- The old candidate is an ancestor of the new candidate. +- Every correction commit has one exact parent. +- The complete bootstrap delta matches `--paths-file`. +- Full-history and worktree secret scans pass. +- Every author and committer email matches the public owner email. + +The receipt binds a digest of every distinct author and committer name and +email. It does not include those values. + +GitHub display names are not identity credentials. The receipt records the +display-name match as a separate Boolean result. + +The command verifies these hosted facts: + +- The authenticated GitHub viewer is the repository owner. +- The target repository is public and uses `main` as its default branch. +- The remote candidate and `main` refs match the selected phase. +- The pull request has the required head, base, state, and merge result. +- Every declared hosted run completed successfully on the candidate commit. +- The selected ruleset has no bypass actor and protects the default branch. +- The ruleset blocks deletion and force pushes, and it requires linear history. +- Pull requests require review, last-push approval, and resolved review threads. +- The ruleset dismisses stale reviews and permits rebase merges only. +- Strict required checks apply to branch creation. +- Every ruleset check passed in the declared pull-request run. +- Every check came from the ruleset's GitHub App integration. + +## Private output preparation + +Create a private directory outside the candidate and verifier checkouts. On +Unix, set mode `0700` on the directory. + +```text +mkdir -p ../wip-commit-private-receipts +chmod 700 ../wip-commit-private-receipts +``` + +Make sure that the output file does not exist. The command never overwrites a +file. + +## Finalized verification command + +Run the command source from a clean, reviewed verifier checkout. Set +`--repo-dir` to a clean checkout of the exact candidate. + +```text +go run ./scripts/publication-correction \ + --verifier-dir . \ + --repo-dir ../wip-commit-correction-source \ + --target nstranquist/wip-commit \ + --phase finalized \ + --bootstrap b276204385636c5a8ac338491565bd4894255217 \ + --old-candidate ed3f1fadfbc74eb0aa41ef8b90e41f403213d33d \ + --candidate 206fa8b6a1dde1d97081133e4d447c0881849922 \ + --candidate-ref candidate/v0.1.0-beta.1 \ + --first-receipt ../wip-commit-private-receipts/pre-first-push-ed3f1fa-authorized.json \ + --paths-file docs/PUBLICATION-HANDOFF.paths \ + --pull-request 1 \ + --ruleset 20926881 \ + --run candidate-push:push:candidate/v0.1.0-beta.1:31996054315 \ + --run verification-pr:pull_request:candidate/v0.1.0-beta.1:31996057770 \ + --run main-push:push:main:31996220707 \ + --run dependency-graph:dynamic:main:31996222126 \ + --out ../wip-commit-private-receipts/hosted-correction-206fa8b-v2.json +``` + +Each `--run` value uses `LABEL:EVENT:BRANCH:ID`. Labels and run IDs must be +unique. + +## Schema validation + +Verify the private receipt against +[PUBLICATION-CORRECTION.schema.json](PUBLICATION-CORRECTION.schema.json). + +```text +python3 -c 'import json,jsonschema; schema=json.load(open("docs/PUBLICATION-CORRECTION.schema.json")); receipt=json.load(open("../wip-commit-private-receipts/hosted-correction-206fa8b-v2.json")); jsonschema.Draft202012Validator.check_schema(schema); jsonschema.Draft202012Validator(schema,format_checker=jsonschema.FormatChecker()).validate(receipt)' +``` + +Keep the receipt outside the repository. Do not publish the receipt, email +addresses, local paths, private reports, tokens, or raw scan output. + +## Failure recovery + +If the command fails, preserve every input and fix the typed finding. Use a new +output path for the next complete run. + +| Code | Required recovery | +| --- | --- | +| `VERIFIER_INVALID` | Use a clean, complete checkout that contains the tracked command and schema. | +| `CHECKOUT_NOT_CLEAN` | Use a clean checkout of the exact candidate. | +| `CANDIDATE_MISMATCH` | Check `HEAD` and all three explicit commit IDs. | +| `CORRECTION_NOT_LINEAR` | Use a merge-free descendant or create a new candidate branch. | +| `PATH_MANIFEST_MISMATCH` | Review the complete bootstrap delta and update the manifest through review. | +| `OBJECT_CHECK_FAILED` | Preserve the checkout and repair the Git object store. | +| `SECRET_SCAN_FAILED` | Remove the finding from the complete history and worktree. | +| `IDENTITY_MISMATCH` | Correct the public email or rebuild the candidate with the approved identity. | +| `REMOTE_REF_MISMATCH` | Check the candidate phase and remote refs. Do not force-push. | +| `PULL_REQUEST_MISMATCH` | Restore the required unmerged pull-request state or select the correct phase. | +| `HOSTED_RUN_FAILED` | Run every required workflow on the exact candidate. | +| `HOSTED_RUN_INCOMPLETE` | Add the missing candidate, pull-request, or `main` run. | +| `RULESET_MISMATCH` | Restore active default-branch protection before another run. | +| `REQUIRED_CHECK_FAILED` | Run the provider-bound check on the exact pull-request head. | +| `COMMAND_TIMEOUT` | Inspect the external service, then rerun the complete command. | +| `OUTPUT_UNSAFE` | Select a private `0700` directory outside both checkouts. | +| `OUTPUT_EXISTS` | Preserve the existing file and select a new output name. | + +The command writes no receipt after a failed check. + +## Recorded correction + +The finalized run on 2026-08-17 bound these public facts: + +- Old candidate: `ed3f1fadfbc74eb0aa41ef8b90e41f403213d33d`. +- New candidate: `206fa8b6a1dde1d97081133e4d447c0881849922`. +- New tree: `c0dd6638adcdc231840ad06406dc9f0caa38e45d`. +- Verifier commit: `6f36147f24f614cff0c7010533d864f8d9ad7628`. +- Verifier tree: `e9559e5fc7fa7083e471acb7d9e72e10b9c3110a`. +- Verifier command digest: + `sha256:85a645b26c37a0033de22d3e3b6731d0d953e8ba0a2e7f634f1f8820f843087d`. +- Verifier schema digest: + `sha256:f8873f01997118f7d6d57bfa9f95b68ce0febab64c79dbd727de9ddb0649c7ac`. +- Correction commits: `1`. +- Complete bootstrap delta paths: `20`. +- Correction delta paths: `5`. +- Hosted runs: `4`. +- Provider-bound checks: `6`. +- GitHub Actions integration: `15368`. +- The active ruleset had no bypass actor and matched every required control. +- Private receipt digest: + `sha256:77db44ba2bfa6f007186ace931f38444521d8a29cf48bd945e07a801eda36a9a`. + +The private receipt passed the Draft 2020-12 schema. It contains no email +address, local path, token, private report, or raw security log. diff --git a/docs/PUBLICATION-CORRECTION.schema.json b/docs/PUBLICATION-CORRECTION.schema.json new file mode 100644 index 0000000..59a295a --- /dev/null +++ b/docs/PUBLICATION-CORRECTION.schema.json @@ -0,0 +1,323 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/nstranquist/wip-commit/blob/main/docs/PUBLICATION-CORRECTION.schema.json", + "title": "wip-commit hosted candidate correction receipt", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "generated_at", + "target_repository", + "phase", + "verifier", + "bootstrap", + "first_receipt", + "correction", + "local", + "identity", + "repository", + "remote", + "pull_request", + "hosted_runs", + "ruleset", + "required_checks" + ], + "properties": { + "schema_version": {"const": "1.0.0"}, + "generated_at": {"$ref": "#/$defs/timestamp"}, + "target_repository": {"$ref": "#/$defs/repository_name"}, + "phase": {"enum": ["pre-main", "finalized"]}, + "verifier": { + "type": "object", + "additionalProperties": false, + "required": [ + "commit", + "tree", + "command_path", + "command_digest", + "schema_path", + "schema_digest", + "clean", + "complete_history", + "replacement_ref_count", + "object_integrity_check" + ], + "properties": { + "commit": {"$ref": "#/$defs/object_id"}, + "tree": {"$ref": "#/$defs/object_id"}, + "command_path": {"const": "scripts/publication-correction/main.go"}, + "command_digest": {"$ref": "#/$defs/digest"}, + "schema_path": {"const": "docs/PUBLICATION-CORRECTION.schema.json"}, + "schema_digest": {"$ref": "#/$defs/digest"}, + "clean": {"const": true}, + "complete_history": {"const": true}, + "replacement_ref_count": {"const": 0}, + "object_integrity_check": {"const": "passed"} + } + }, + "bootstrap": {"$ref": "#/$defs/object"}, + "first_receipt": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "digest", + "bootstrap_commit", + "bootstrap_tree", + "candidate_commit", + "candidate_tree" + ], + "properties": { + "schema_version": {"const": "1.0.0"}, + "digest": {"$ref": "#/$defs/digest"}, + "bootstrap_commit": {"$ref": "#/$defs/object_id"}, + "bootstrap_tree": {"$ref": "#/$defs/object_id"}, + "candidate_commit": {"$ref": "#/$defs/object_id"}, + "candidate_tree": {"$ref": "#/$defs/object_id"} + } + }, + "correction": { + "type": "object", + "additionalProperties": false, + "required": [ + "old_candidate", + "new_candidate", + "commits", + "bootstrap_delta_paths", + "correction_delta_paths", + "merge_free", + "old_is_ancestor", + "bootstrap_is_ancestor" + ], + "properties": { + "old_candidate": {"$ref": "#/$defs/object"}, + "new_candidate": {"$ref": "#/$defs/object"}, + "commits": { + "type": "array", + "minItems": 1, + "items": {"$ref": "#/$defs/correction_commit"} + }, + "bootstrap_delta_paths": {"$ref": "#/$defs/path_list"}, + "correction_delta_paths": {"$ref": "#/$defs/path_list"}, + "merge_free": {"const": true}, + "old_is_ancestor": {"const": true}, + "bootstrap_is_ancestor": {"const": true} + } + }, + "local": { + "type": "object", + "additionalProperties": false, + "required": [ + "clean", + "complete_history", + "replacement_ref_count", + "object_integrity_check", + "history_secret_scan", + "worktree_secret_scan", + "path_manifest_matched", + "checked_out_candidate" + ], + "properties": { + "clean": {"const": true}, + "complete_history": {"const": true}, + "replacement_ref_count": {"const": 0}, + "object_integrity_check": {"const": "passed"}, + "history_secret_scan": {"const": "passed"}, + "worktree_secret_scan": {"const": "passed"}, + "path_manifest_matched": {"const": true}, + "checked_out_candidate": {"const": true} + } + }, + "identity": { + "type": "object", + "additionalProperties": false, + "required": [ + "history_commit_count", + "unique_author_names", + "unique_author_emails", + "unique_committer_names", + "unique_committer_emails", + "identity_set_digest", + "public_email_match", + "public_name_match" + ], + "properties": { + "history_commit_count": {"type": "integer", "minimum": 1}, + "unique_author_names": {"type": "integer", "minimum": 1}, + "unique_author_emails": {"type": "integer", "minimum": 1}, + "unique_committer_names": {"type": "integer", "minimum": 1}, + "unique_committer_emails": {"type": "integer", "minimum": 1}, + "identity_set_digest": {"$ref": "#/$defs/digest"}, + "public_email_match": {"const": true}, + "public_name_match": {"type": "boolean"} + } + }, + "repository": { + "type": "object", + "additionalProperties": false, + "required": ["full_name", "visibility", "default_branch", "authenticated_owner"], + "properties": { + "full_name": {"$ref": "#/$defs/repository_name"}, + "visibility": {"const": "public"}, + "default_branch": {"const": "main"}, + "authenticated_owner": {"const": true} + } + }, + "remote": { + "type": "object", + "additionalProperties": false, + "required": ["candidate_ref", "candidate_commit", "main_ref", "main_commit"], + "properties": { + "candidate_ref": {"type": "string", "minLength": 1}, + "candidate_commit": {"$ref": "#/$defs/object_id"}, + "main_ref": {"const": "main"}, + "main_commit": {"$ref": "#/$defs/object_id"} + } + }, + "pull_request": { + "type": "object", + "additionalProperties": false, + "required": ["number", "state", "head_ref", "head_commit", "base_ref", "base_commit", "merged"], + "properties": { + "number": {"type": "integer", "minimum": 1}, + "state": {"enum": ["open", "closed"]}, + "head_ref": {"type": "string", "minLength": 1}, + "head_commit": {"$ref": "#/$defs/object_id"}, + "base_ref": {"const": "main"}, + "base_commit": {"$ref": "#/$defs/object_id"}, + "merged": {"const": false}, + "merge_commit": {"$ref": "#/$defs/object_id"} + } + }, + "hosted_runs": { + "type": "array", + "minItems": 2, + "items": {"$ref": "#/$defs/hosted_run"} + }, + "ruleset": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "name", + "enforcement", + "no_bypass_actors", + "deletion_blocked", + "non_fast_forward_blocked", + "linear_history_required", + "pull_request_required", + "required_approving_review_count", + "dismiss_stale_reviews", + "last_push_approval_required", + "review_threads_resolved", + "allowed_merge_methods", + "strict_required_checks", + "required_checks_on_create" + ], + "properties": { + "id": {"type": "integer", "minimum": 1}, + "name": {"type": "string", "minLength": 1}, + "enforcement": {"const": "active"}, + "no_bypass_actors": {"const": true}, + "deletion_blocked": {"const": true}, + "non_fast_forward_blocked": {"const": true}, + "linear_history_required": {"const": true}, + "pull_request_required": {"const": true}, + "required_approving_review_count": {"type": "integer", "minimum": 1}, + "dismiss_stale_reviews": {"const": true}, + "last_push_approval_required": {"const": true}, + "review_threads_resolved": {"const": true}, + "allowed_merge_methods": { + "type": "array", + "prefixItems": [{"const": "rebase"}], + "items": false, + "minItems": 1, + "maxItems": 1 + }, + "strict_required_checks": {"const": true}, + "required_checks_on_create": {"const": true} + } + }, + "required_checks": { + "type": "array", + "minItems": 1, + "items": {"$ref": "#/$defs/required_check"} + } + }, + "$defs": { + "object_id": { + "type": "string", + "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$" + }, + "digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}([.][0-9]{1,9})?Z$" + }, + "repository_name": { + "type": "string", + "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$" + }, + "path_list": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "pattern": "^[^/]" + } + }, + "object": { + "type": "object", + "additionalProperties": false, + "required": ["commit", "tree"], + "properties": { + "commit": {"$ref": "#/$defs/object_id"}, + "tree": {"$ref": "#/$defs/object_id"} + } + }, + "correction_commit": { + "type": "object", + "additionalProperties": false, + "required": ["commit", "tree", "parent"], + "properties": { + "commit": {"$ref": "#/$defs/object_id"}, + "tree": {"$ref": "#/$defs/object_id"}, + "parent": {"$ref": "#/$defs/object_id"} + } + }, + "hosted_run": { + "type": "object", + "additionalProperties": false, + "required": ["label", "id", "event", "head_branch", "head_commit", "status", "conclusion", "attempt", "workflow"], + "properties": { + "label": {"type": "string", "pattern": "^[a-z][a-z0-9-]{0,62}$"}, + "id": {"type": "integer", "minimum": 1}, + "event": {"type": "string", "pattern": "^[a-z][a-z0-9_]{0,62}$"}, + "head_branch": {"type": "string", "minLength": 1}, + "head_commit": {"$ref": "#/$defs/object_id"}, + "status": {"const": "completed"}, + "conclusion": {"const": "success"}, + "attempt": {"type": "integer", "minimum": 1}, + "workflow": {"type": "string", "minLength": 1} + } + }, + "required_check": { + "type": "object", + "additionalProperties": false, + "required": ["context", "integration_id", "check_run_id", "status", "conclusion"], + "properties": { + "context": {"type": "string", "minLength": 1}, + "integration_id": {"type": "integer", "minimum": 1}, + "check_run_id": {"type": "integer", "minimum": 1}, + "status": {"const": "completed"}, + "conclusion": {"const": "success"} + } + } + } +} diff --git a/docs/PUBLICATION-EVIDENCE.md b/docs/PUBLICATION-EVIDENCE.md index 1ecb591..33b1053 100644 --- a/docs/PUBLICATION-EVIDENCE.md +++ b/docs/PUBLICATION-EVIDENCE.md @@ -36,6 +36,39 @@ The corrected commit passed these runs: - [final `main` push](https://github.com/nstranquist/wip-commit/actions/runs/31996220707). - [dependency graph](https://github.com/nstranquist/wip-commit/actions/runs/31996222126). +## Source-current correction receipt + +The command in +[PUBLICATION-CORRECTION.md](PUBLICATION-CORRECTION.md) verified the completed +bootstrap sequence. It used the explicit `finalized` phase. + +The private receipt bound: + +- the immutable first receipt digest. +- bootstrap `b276204385636c5a8ac338491565bd4894255217`. +- old candidate `ed3f1fadfbc74eb0aa41ef8b90e41f403213d33d`. +- final candidate `206fa8b6a1dde1d97081133e4d447c0881849922`. +- final tree `c0dd6638adcdc231840ad06406dc9f0caa38e45d`. +- verifier commit `6f36147f24f614cff0c7010533d864f8d9ad7628` and tree + `e9559e5fc7fa7083e471acb7d9e72e10b9c3110a`. +- verifier command and schema SHA-256 digests. +- one merge-free correction commit. +- the complete 20-path bootstrap delta. +- the complete 5-path correction delta. +- four successful hosted runs. +- six successful checks from GitHub Actions integration `15368`. +- closed pull request 1 with `merged: false`. +- final candidate and `main` refs at the same commit. +- the active no-bypass ruleset and all review, history, and update controls. + +The receipt passed +[PUBLICATION-CORRECTION.schema.json](PUBLICATION-CORRECTION.schema.json). Its +SHA-256 digest is +`77db44ba2bfa6f007186ace931f38444521d8a29cf48bd945e07a801eda36a9a`. + +The private file remains outside the repository. The public record contains no +identity value, local path, token, private report, or raw security log. + ## Required checks All required checks came from GitHub Actions integration `15368`. @@ -103,8 +136,6 @@ the tagged-install gate. Do not create `v0.1.0-beta.1` until all these gates pass: -- Implement the source-current correction receipt in - [KEP-0002](KEP-0002-hosted-candidate-correction.md). - Obtain a valid receipt from one independent beta tester. - Approve a Code of Conduct and a confidential conduct-reporting path. - Verify the maintainer's security-alert notifications. diff --git a/docs/PUBLICATION-HANDOFF.md b/docs/PUBLICATION-HANDOFF.md index 4ebd7d3..78edf2a 100644 --- a/docs/PUBLICATION-HANDOFF.md +++ b/docs/PUBLICATION-HANDOFF.md @@ -27,8 +27,9 @@ Pull request 1 closed without a merge commit. Then `main` moved by a direct fast-forward. The pre-first-push command cannot validate this correction because the target -now exists. [KEP-0002](KEP-0002-hosted-candidate-correction.md) defines the -source-current correction receipt that remains required before the tag. +now exists. The `finalized` procedure in +[PUBLICATION-CORRECTION.md](PUBLICATION-CORRECTION.md) verified the completed +sequence and produced the required private correction receipt. ## Boundary diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 1c4f85b..38f15cb 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -78,6 +78,9 @@ Use [PUBLICATION-HANDOFF.md](PUBLICATION-HANDOFF.md) for the first push and [HOSTED-SETUP.md](HOSTED-SETUP.md) for the hosted controls. Require a non-author receipt produced with [BETA-EXERCISE.md](BETA-EXERCISE.md) and validated against [BETA-EXERCISE.schema.json](BETA-EXERCISE.schema.json). +If hosted checks require a candidate change, use +[PUBLICATION-CORRECTION.md](PUBLICATION-CORRECTION.md) from a clean verifier +checkout before another `main` update. Before the tag, move the release notes from `Unreleased` to a `0.1.0-beta.1` section with the actual tag date. Add the release comparison diff --git a/scripts/publication-correction/main.go b/scripts/publication-correction/main.go new file mode 100644 index 0000000..38f5ff9 --- /dev/null +++ b/scripts/publication-correction/main.go @@ -0,0 +1,1513 @@ +// Command publication-correction creates a fail-closed receipt for a hosted +// candidate correction. It reads local and GitHub evidence. It does not change +// a repository, pull request, workflow run, tag, or Git ref. +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "sort" + "strconv" + "strings" + "time" + + "github.com/nstranquist/wip-commit/internal/gitx" + "github.com/nstranquist/wip-commit/internal/safeio" +) + +const ( + receiptSchemaVersion = "1.0.0" + verifierCommandPath = "scripts/publication-correction/main.go" + verifierSchemaPath = "docs/PUBLICATION-CORRECTION.schema.json" + maximumManifestBytes = 64 << 10 + maximumFirstReceiptBytes = 2 << 20 + maximumVerifierFileBytes = 2 << 20 + maximumCommandOutputBytes = 2 << 20 + gitCommandTimeout = 2 * time.Minute + githubCommandTimeout = 30 * time.Second + secretScanTimeout = 5 * time.Minute +) + +var ( + objectPattern = regexp.MustCompile(`^(?:[0-9a-f]{40}|[0-9a-f]{64})$`) + targetPattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$`) + runLabelPattern = regexp.MustCompile(`^[a-z][a-z0-9-]{0,62}$`) + runEventPattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,62}$`) + sha256Pattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) + allowedPhaseNames = map[string]bool{"pre-main": true, "finalized": true} +) + +type typedError struct { + Code string + Err error +} + +func (err *typedError) Error() string { return err.Err.Error() } +func (err *typedError) Unwrap() error { return err.Err } + +func problem(code, format string, arguments ...any) error { + return &typedError{Code: code, Err: fmt.Errorf(format, arguments...)} +} + +func wrapProblem(code string, err error, format string, arguments ...any) error { + if err == nil { + return nil + } + prefix := fmt.Sprintf(format, arguments...) + if prefix == "" { + return &typedError{Code: code, Err: err} + } + return &typedError{Code: code, Err: fmt.Errorf("%s: %w", prefix, err)} +} + +func errorCode(err error) string { + var typed *typedError + if errors.As(err, &typed) && typed.Code != "" { + return typed.Code + } + return "INTERNAL_ERROR" +} + +type objectEvidence struct { + Commit string `json:"commit"` + Tree string `json:"tree"` +} + +type firstReceiptEvidence struct { + SchemaVersion string `json:"schema_version"` + Digest string `json:"digest"` + Bootstrap string `json:"bootstrap_commit"` + BootstrapTree string `json:"bootstrap_tree"` + Candidate string `json:"candidate_commit"` + CandidateTree string `json:"candidate_tree"` +} + +type verifierEvidence struct { + Commit string `json:"commit"` + Tree string `json:"tree"` + CommandPath string `json:"command_path"` + CommandDigest string `json:"command_digest"` + SchemaPath string `json:"schema_path"` + SchemaDigest string `json:"schema_digest"` + Clean bool `json:"clean"` + CompleteHistory bool `json:"complete_history"` + ReplacementRefCount int `json:"replacement_ref_count"` + ObjectIntegrityCheck string `json:"object_integrity_check"` +} + +type correctionCommit struct { + Commit string `json:"commit"` + Tree string `json:"tree"` + Parent string `json:"parent"` +} + +type correctionEvidence struct { + OldCandidate objectEvidence `json:"old_candidate"` + NewCandidate objectEvidence `json:"new_candidate"` + Commits []correctionCommit `json:"commits"` + BootstrapDelta []string `json:"bootstrap_delta_paths"` + CorrectionDelta []string `json:"correction_delta_paths"` + MergeFree bool `json:"merge_free"` + OldIsAncestor bool `json:"old_is_ancestor"` + BootstrapIsAncestor bool `json:"bootstrap_is_ancestor"` +} + +type localEvidence struct { + Clean bool `json:"clean"` + CompleteHistory bool `json:"complete_history"` + ReplacementRefCount int `json:"replacement_ref_count"` + ObjectIntegrityCheck string `json:"object_integrity_check"` + HistorySecretScan string `json:"history_secret_scan"` + WorktreeSecretScan string `json:"worktree_secret_scan"` + PathManifestMatched bool `json:"path_manifest_matched"` + CheckedOutCandidate bool `json:"checked_out_candidate"` +} + +type identityEvidence struct { + HistoryCommitCount int `json:"history_commit_count"` + UniqueAuthorNames int `json:"unique_author_names"` + UniqueAuthorEmails int `json:"unique_author_emails"` + UniqueCommitterNames int `json:"unique_committer_names"` + UniqueCommitterEmails int `json:"unique_committer_emails"` + IdentitySetDigest string `json:"identity_set_digest"` + PublicEmailMatch bool `json:"public_email_match"` + PublicNameMatch bool `json:"public_name_match"` +} + +type repositoryEvidence struct { + FullName string `json:"full_name"` + Visibility string `json:"visibility"` + DefaultBranch string `json:"default_branch"` + AuthenticatedOwner bool `json:"authenticated_owner"` +} + +type remoteEvidence struct { + CandidateRef string `json:"candidate_ref"` + CandidateCommit string `json:"candidate_commit"` + MainRef string `json:"main_ref"` + MainCommit string `json:"main_commit"` +} + +type pullRequestEvidence struct { + Number int `json:"number"` + State string `json:"state"` + HeadRef string `json:"head_ref"` + HeadCommit string `json:"head_commit"` + BaseRef string `json:"base_ref"` + BaseCommit string `json:"base_commit"` + Merged bool `json:"merged"` + MergeCommit string `json:"merge_commit,omitempty"` +} + +type runEvidence struct { + Label string `json:"label"` + ID int64 `json:"id"` + Event string `json:"event"` + HeadBranch string `json:"head_branch"` + HeadCommit string `json:"head_commit"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + Attempt int `json:"attempt"` + Workflow string `json:"workflow"` +} + +type requiredCheckEvidence struct { + Context string `json:"context"` + IntegrationID int64 `json:"integration_id"` + CheckRunID int64 `json:"check_run_id"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` +} + +type rulesetEvidence struct { + ID int64 `json:"id"` + Name string `json:"name"` + Enforcement string `json:"enforcement"` + NoBypassActors bool `json:"no_bypass_actors"` + DeletionBlocked bool `json:"deletion_blocked"` + NonFastForwardBlocked bool `json:"non_fast_forward_blocked"` + LinearHistoryRequired bool `json:"linear_history_required"` + PullRequestRequired bool `json:"pull_request_required"` + RequiredApprovingReviewCount int `json:"required_approving_review_count"` + DismissStaleReviews bool `json:"dismiss_stale_reviews"` + LastPushApprovalRequired bool `json:"last_push_approval_required"` + ReviewThreadsResolved bool `json:"review_threads_resolved"` + AllowedMergeMethods []string `json:"allowed_merge_methods"` + StrictRequiredChecks bool `json:"strict_required_checks"` + RequiredChecksOnCreate bool `json:"required_checks_on_create"` +} + +type receipt struct { + SchemaVersion string `json:"schema_version"` + GeneratedAt time.Time `json:"generated_at"` + Target string `json:"target_repository"` + Phase string `json:"phase"` + Verifier verifierEvidence `json:"verifier"` + Bootstrap objectEvidence `json:"bootstrap"` + FirstReceipt firstReceiptEvidence `json:"first_receipt"` + Correction correctionEvidence `json:"correction"` + Local localEvidence `json:"local"` + Identity identityEvidence `json:"identity"` + Repository repositoryEvidence `json:"repository"` + Remote remoteEvidence `json:"remote"` + PullRequest pullRequestEvidence `json:"pull_request"` + HostedRuns []runEvidence `json:"hosted_runs"` + Ruleset rulesetEvidence `json:"ruleset"` + RequiredChecks []requiredCheckEvidence `json:"required_checks"` +} + +type identitySet struct { + HistoryCommitCount int + AuthorNames []string + AuthorEmails []string + CommitterNames []string + CommitterEmails []string + Digest string +} + +type gitEvidence struct { + Bootstrap objectEvidence + Correction correctionEvidence + Identity identitySet +} + +type firstReceiptDocument struct { + SchemaVersion string `json:"schema_version"` + TargetRepository string `json:"target_repository"` + TargetVisibility string `json:"target_visibility"` + DefaultBranch string `json:"default_branch"` + Bootstrap objectEvidence `json:"bootstrap"` + Candidate struct { + Commit string `json:"commit"` + Tree string `json:"tree"` + } `json:"candidate"` + HistoryCommitCount int `json:"history_commit_count"` + DeltaPaths []string `json:"delta_paths"` + Local struct { + Clean bool `json:"clean"` + HistorySecretScan string `json:"history_secret_scan"` + WorktreeSecretScan string `json:"worktree_secret_scan"` + ObjectIntegrityCheck string `json:"object_integrity_check"` + ExpectedDeltaMatched bool `json:"expected_delta_matched"` + BootstrapIsAncestor bool `json:"bootstrap_is_ancestor"` + LinearCandidateRange bool `json:"linear_candidate_range"` + } `json:"local"` + GitHub struct { + AuthenticatedOwner bool `json:"authenticated_owner"` + TargetRepositoryExists bool `json:"target_repository_exists"` + PublicAuthorIdentityMatch bool `json:"public_author_identity_match"` + } `json:"github"` +} + +type runSpec struct { + Label, Event, Branch string + ID int64 +} + +type stringList []string + +func (values *stringList) String() string { return strings.Join(*values, ",") } +func (values *stringList) Set(value string) error { + *values = append(*values, value) + return nil +} + +type externalRunner func(context.Context, time.Duration, string, string, ...string) (string, string, int, error) + +type dependencies struct { + external externalRunner + now func() time.Time +} + +type options struct { + RepoDir, VerifierDir, Target, Phase, Bootstrap, OldCandidate, Candidate, CandidateRef string + FirstReceipt, PathsFile, Out string + PullRequest int + Ruleset int64 + Runs []runSpec +} + +func main() { + if err := run(context.Background(), os.Args[1:], os.Stdout); err != nil { + fmt.Fprintf(os.Stderr, "publication-correction: %s: %s\n", errorCode(err), err) + os.Exit(2) + } +} + +func run(ctx context.Context, args []string, stdout io.Writer) error { + return runWithDependencies(ctx, args, stdout, dependencies{external: externalCommandWithTimeout, now: time.Now}) +} + +func runWithDependencies(ctx context.Context, args []string, stdout io.Writer, deps dependencies) error { + if deps.external == nil || deps.now == nil { + return problem("DEPENDENCIES_INCOMPLETE", "publication-correction dependencies are incomplete") + } + configuration, err := parseOptions(args) + if err != nil { + return err + } + repo, err := gitx.Discover(ctx, configuration.RepoDir) + if err != nil { + return wrapProblem("NOT_A_REPOSITORY", err, "discover candidate checkout") + } + if err := verifyModuleTarget(repo.Root, configuration.Target); err != nil { + return err + } + verifierRepo, err := gitx.Discover(ctx, configuration.VerifierDir) + if err != nil { + return wrapProblem("VERIFIER_INVALID", err, "discover verifier checkout") + } + if err := verifyModuleTarget(verifierRepo.Root, configuration.Target); err != nil { + return problem("VERIFIER_INVALID", "verifier go.mod does not match github.com/%s", configuration.Target) + } + verifier, err := collectVerifierEvidence(ctx, verifierRepo) + if err != nil { + return err + } + if err := validateBranchName(ctx, repo, configuration.CandidateRef); err != nil { + return err + } + roots := []string{repo.Root, verifierRepo.Root} + outputPath, err := validatedReceiptOutput(roots, configuration.Out) + if err != nil { + return err + } + manifest, err := loadExpectedPaths(repo, configuration.PathsFile) + if err != nil { + return err + } + firstReceipt, err := loadFirstReceipt(roots, configuration.FirstReceipt, configuration.Target, configuration.Bootstrap, configuration.OldCandidate) + if err != nil { + return err + } + gitState, err := collectGitEvidence(ctx, repo, configuration.Bootstrap, configuration.OldCandidate, configuration.Candidate, manifest) + if err != nil { + return err + } + if firstReceipt.BootstrapTree != gitState.Bootstrap.Tree || firstReceipt.CandidateTree != gitState.Correction.OldCandidate.Tree { + return problem("FIRST_RECEIPT_MISMATCH", "first receipt trees do not match the local bootstrap and old candidate") + } + if err := runSecretScans(ctx, repo.Root, deps.external); err != nil { + return err + } + hosted, err := collectGitHubEvidence(ctx, repo.Root, configuration, gitState.Identity, deps.external) + if err != nil { + return err + } + + result := receipt{ + SchemaVersion: receiptSchemaVersion, + GeneratedAt: deps.now().UTC(), + Target: configuration.Target, + Phase: configuration.Phase, + Verifier: verifier, + Bootstrap: gitState.Bootstrap, + FirstReceipt: firstReceipt, + Correction: gitState.Correction, + Local: localEvidence{ + Clean: true, + CompleteHistory: true, + ReplacementRefCount: 0, + ObjectIntegrityCheck: "passed", + HistorySecretScan: "passed", + WorktreeSecretScan: "passed", + PathManifestMatched: true, + CheckedOutCandidate: true, + }, + Identity: identityEvidence{ + HistoryCommitCount: gitState.Identity.HistoryCommitCount, + UniqueAuthorNames: len(gitState.Identity.AuthorNames), + UniqueAuthorEmails: len(gitState.Identity.AuthorEmails), + UniqueCommitterNames: len(gitState.Identity.CommitterNames), + UniqueCommitterEmails: len(gitState.Identity.CommitterEmails), + IdentitySetDigest: gitState.Identity.Digest, + PublicEmailMatch: true, + PublicNameMatch: hosted.PublicNameMatch, + }, + Repository: hosted.Repository, + Remote: hosted.Remote, + PullRequest: hosted.PullRequest, + HostedRuns: hosted.Runs, + Ruleset: hosted.Ruleset, + RequiredChecks: hosted.RequiredChecks, + } + body, digest, err := encodeReceipt(result) + if err != nil { + return err + } + if err := writeReceipt(outputPath, body); err != nil { + return err + } + return json.NewEncoder(stdout).Encode(map[string]any{ + "ok": true, + "action": "publication-correction", + "candidate_commit": result.Correction.NewCandidate.Commit, + "candidate_tree": result.Correction.NewCandidate.Tree, + "phase": result.Phase, + "receipt_digest": digest, + }) +} + +func parseOptions(args []string) (options, error) { + var configuration options + var rawRuns stringList + flags := flag.NewFlagSet("publication-correction", flag.ContinueOnError) + flags.SetOutput(io.Discard) + flags.StringVar(&configuration.RepoDir, "repo-dir", ".", "exact candidate checkout") + flags.StringVar(&configuration.VerifierDir, "verifier-dir", ".", "clean checkout that contains this verifier") + flags.StringVar(&configuration.Target, "target", "", "GitHub OWNER/REPO") + flags.StringVar(&configuration.Phase, "phase", "pre-main", "pre-main or finalized") + flags.StringVar(&configuration.Bootstrap, "bootstrap", "", "reviewed bootstrap commit") + flags.StringVar(&configuration.OldCandidate, "old-candidate", "", "candidate bound by the first receipt") + flags.StringVar(&configuration.Candidate, "candidate", "", "corrected candidate commit") + flags.StringVar(&configuration.CandidateRef, "candidate-ref", "", "remote candidate branch without refs/heads/") + flags.StringVar(&configuration.FirstReceipt, "first-receipt", "", "immutable pre-first-push receipt") + flags.StringVar(&configuration.PathsFile, "paths-file", "", "reviewed bootstrap-delta path manifest") + flags.StringVar(&configuration.Out, "out", "", "new private receipt outside the checkout") + flags.IntVar(&configuration.PullRequest, "pull-request", 0, "verification pull request number") + flags.Int64Var(&configuration.Ruleset, "ruleset", 0, "required main-branch ruleset ID") + flags.Var(&rawRuns, "run", "LABEL:EVENT:BRANCH:ID; repeat for each required hosted run") + if err := flags.Parse(args); err != nil { + return configuration, problem("INVALID_ARGS", "%s", err) + } + if flags.NArg() != 0 { + return configuration, problem("INVALID_ARGS", "unexpected positional arguments: %s", strings.Join(flags.Args(), " ")) + } + if !targetPattern.MatchString(configuration.Target) || strings.Contains(configuration.Target, "..") { + return configuration, problem("INVALID_ARGS", "--target must use OWNER/REPO") + } + if !allowedPhaseNames[configuration.Phase] { + return configuration, problem("INVALID_ARGS", "--phase must be pre-main or finalized") + } + for label, value := range map[string]string{ + "--bootstrap": configuration.Bootstrap, + "--old-candidate": configuration.OldCandidate, + "--candidate": configuration.Candidate, + } { + if !objectPattern.MatchString(value) { + return configuration, problem("INVALID_ARGS", "%s must be a complete Git object ID", label) + } + } + if configuration.Bootstrap == configuration.OldCandidate || configuration.OldCandidate == configuration.Candidate { + return configuration, problem("INVALID_ARGS", "bootstrap, old candidate, and candidate must identify distinct commits") + } + if configuration.CandidateRef == "" || strings.HasPrefix(configuration.CandidateRef, "refs/") { + return configuration, problem("INVALID_ARGS", "--candidate-ref must be a branch name without refs/heads/") + } + if configuration.PullRequest <= 0 || configuration.Ruleset <= 0 { + return configuration, problem("INVALID_ARGS", "--pull-request and --ruleset must be positive") + } + for label, value := range map[string]string{ + "--first-receipt": configuration.FirstReceipt, + "--paths-file": configuration.PathsFile, + "--out": configuration.Out, + } { + if strings.TrimSpace(value) == "" { + return configuration, problem("INVALID_ARGS", "%s is required", label) + } + } + runs, err := parseRunSpecs(rawRuns) + if err != nil { + return configuration, err + } + configuration.Runs = runs + return configuration, nil +} + +func parseRunSpecs(values []string) ([]runSpec, error) { + if len(values) == 0 { + return nil, problem("INVALID_ARGS", "at least one --run is required") + } + seenLabels, seenIDs := map[string]bool{}, map[int64]bool{} + runs := make([]runSpec, 0, len(values)) + for _, value := range values { + parts := strings.Split(value, ":") + if len(parts) != 4 || !runLabelPattern.MatchString(parts[0]) || !runEventPattern.MatchString(parts[1]) || strings.TrimSpace(parts[2]) == "" { + return nil, problem("INVALID_ARGS", "--run must use LABEL:EVENT:BRANCH:ID") + } + id, err := strconv.ParseInt(parts[3], 10, 64) + if err != nil || id <= 0 { + return nil, problem("INVALID_ARGS", "--run ID must be a positive integer") + } + if seenLabels[parts[0]] || seenIDs[id] { + return nil, problem("INVALID_ARGS", "--run labels and IDs must be unique") + } + seenLabels[parts[0]], seenIDs[id] = true, true + runs = append(runs, runSpec{Label: parts[0], Event: parts[1], Branch: parts[2], ID: id}) + } + sort.Slice(runs, func(left, right int) bool { return runs[left].Label < runs[right].Label }) + return runs, nil +} + +func verifyModuleTarget(root, target string) error { + body, err := safeio.ReadRegular(filepath.Join(root, "go.mod"), maximumManifestBytes) + if err != nil { + return wrapProblem("TARGET_MISMATCH", err, "read go.mod") + } + wanted, count := "module github.com/"+target, 0 + for _, line := range strings.Split(strings.ReplaceAll(string(body), "\r\n", "\n"), "\n") { + if strings.HasPrefix(strings.TrimSpace(line), "module ") { + count++ + if strings.TrimSpace(line) != wanted { + return problem("TARGET_MISMATCH", "go.mod module must equal github.com/%s", target) + } + } + } + if count != 1 { + return problem("TARGET_MISMATCH", "go.mod must contain exactly one module declaration") + } + return nil +} + +func validateBranchName(parent context.Context, repo gitx.Repo, branch string) error { + ctx, cancel := context.WithTimeout(parent, gitCommandTimeout) + defer cancel() + resolved, err := repo.Text(ctx, nil, "check-ref-format", "--branch", branch) + if err != nil || resolved != branch { + return gitProblem(ctx, "INVALID_ARGS", err, "--candidate-ref is not a valid Git branch") + } + return nil +} + +func loadExpectedPaths(repo gitx.Repo, name string) ([]string, error) { + path := name + if !filepath.IsAbs(path) { + path = filepath.Join(repo.Root, path) + } + inside, err := pathWithinRoot(repo.Root, path) + if err != nil || !inside { + return nil, problem("PATH_MANIFEST_UNSAFE", "--paths-file must resolve inside the candidate checkout") + } + body, err := safeio.ReadRegular(path, maximumManifestBytes) + if err != nil { + return nil, wrapProblem("PATH_MANIFEST_INVALID", err, "read path manifest") + } + var paths []string + for _, line := range strings.Split(strings.ReplaceAll(string(body), "\r\n", "\n"), "\n") { + if line = strings.TrimSpace(line); line != "" { + paths = append(paths, line) + } + } + if len(paths) == 0 { + return nil, problem("PATH_MANIFEST_INVALID", "path manifest is empty") + } + normalized, err := repo.NormalizePaths(paths) + if err != nil || !equalStrings(paths, normalized) { + return nil, problem("PATH_MANIFEST_INVALID", "path manifest must contain unique sorted canonical repository paths") + } + return normalized, nil +} + +func loadFirstReceipt(roots []string, name, target, bootstrap, oldCandidate string) (firstReceiptEvidence, error) { + path, err := filepath.Abs(name) + if err != nil { + return firstReceiptEvidence{}, wrapProblem("FIRST_RECEIPT_INVALID", err, "resolve first receipt") + } + for _, root := range roots { + inside, containmentErr := pathWithinRoot(root, path) + if containmentErr != nil || inside { + return firstReceiptEvidence{}, problem("FIRST_RECEIPT_INVALID", "--first-receipt must resolve outside the candidate and verifier checkouts") + } + } + body, err := safeio.ReadRegular(path, maximumFirstReceiptBytes) + if err != nil { + return firstReceiptEvidence{}, wrapProblem("FIRST_RECEIPT_INVALID", err, "read first receipt") + } + if runtime.GOOS != "windows" { + info, statErr := os.Stat(path) + if statErr != nil || info.Mode().Perm()&0o077 != 0 { + return firstReceiptEvidence{}, problem("FIRST_RECEIPT_INVALID", "first receipt must not grant group or other permissions") + } + } + var document firstReceiptDocument + decoder := json.NewDecoder(bytes.NewReader(body)) + if err := decoder.Decode(&document); err != nil { + return firstReceiptEvidence{}, wrapProblem("FIRST_RECEIPT_INVALID", err, "decode first receipt") + } + if err := requireJSONEOF(decoder); err != nil { + return firstReceiptEvidence{}, wrapProblem("FIRST_RECEIPT_INVALID", err, "decode first receipt") + } + if document.SchemaVersion != "1.0.0" || document.TargetRepository != target || document.TargetVisibility != "public" || document.DefaultBranch != "main" || document.Bootstrap.Commit != bootstrap || document.Candidate.Commit != oldCandidate { + return firstReceiptEvidence{}, problem("FIRST_RECEIPT_MISMATCH", "first receipt does not bind the target, bootstrap, and old candidate") + } + if document.HistoryCommitCount < 2 || len(document.DeltaPaths) == 0 || !document.Local.Clean || document.Local.HistorySecretScan != "passed" || document.Local.WorktreeSecretScan != "passed" || document.Local.ObjectIntegrityCheck != "passed" || !document.Local.ExpectedDeltaMatched || !document.Local.BootstrapIsAncestor || !document.Local.LinearCandidateRange || !document.GitHub.AuthenticatedOwner || document.GitHub.TargetRepositoryExists || !document.GitHub.PublicAuthorIdentityMatch { + return firstReceiptEvidence{}, problem("FIRST_RECEIPT_INVALID", "first receipt does not contain the required successful pre-first-push evidence") + } + if !objectPattern.MatchString(document.Bootstrap.Tree) || !objectPattern.MatchString(document.Candidate.Tree) { + return firstReceiptEvidence{}, problem("FIRST_RECEIPT_INVALID", "first receipt contains an invalid object ID") + } + digest := sha256.Sum256(body) + return firstReceiptEvidence{ + SchemaVersion: document.SchemaVersion, + Digest: "sha256:" + hex.EncodeToString(digest[:]), + Bootstrap: bootstrap, + BootstrapTree: document.Bootstrap.Tree, + Candidate: oldCandidate, + CandidateTree: document.Candidate.Tree, + }, nil +} + +func collectVerifierEvidence(parent context.Context, repo gitx.Repo) (verifierEvidence, error) { + ctx, cancel := context.WithTimeout(parent, gitCommandTimeout) + defer cancel() + var result verifierEvidence + shallow, err := repo.Text(ctx, nil, "rev-parse", "--is-shallow-repository") + if err != nil || shallow != "false" { + return result, verifierProblem(ctx, err, "verifier history must be complete and non-shallow") + } + replacements, err := repo.Lines(ctx, nil, "for-each-ref", "--format=%(refname)", "refs/replace") + if err != nil { + return result, verifierProblem(ctx, err, "inspect verifier replacement refs") + } + if len(replacements) != 0 { + return result, problem("VERIFIER_INVALID", "verifier repository has replacement refs") + } + if _, err := repo.Raw(ctx, nil, "fsck", "--strict", "--no-dangling"); err != nil { + return result, verifierProblem(ctx, err, "verify verifier object integrity") + } + status, err := repo.Raw(ctx, nil, "status", "--porcelain=v1", "-z", "--untracked-files=all") + if err != nil { + return result, verifierProblem(ctx, err, "inspect verifier checkout") + } + if status != "" { + return result, problem("VERIFIER_INVALID", "verifier checkout is not clean") + } + commit, err := repo.Text(ctx, nil, "rev-parse", "--verify", "HEAD^{commit}") + if err != nil || !objectPattern.MatchString(commit) { + return result, verifierProblem(ctx, err, "resolve verifier commit") + } + tree, err := objectTree(ctx, repo, commit) + if err != nil { + return result, problem("VERIFIER_INVALID", "resolve verifier tree: %v", err) + } + commandDigest, err := trackedBlobDigest(ctx, repo, commit, verifierCommandPath) + if err != nil { + return result, err + } + schemaDigest, err := trackedBlobDigest(ctx, repo, commit, verifierSchemaPath) + if err != nil { + return result, err + } + return verifierEvidence{ + Commit: commit, + Tree: tree, + CommandPath: verifierCommandPath, + CommandDigest: commandDigest, + SchemaPath: verifierSchemaPath, + SchemaDigest: schemaDigest, + Clean: true, + CompleteHistory: true, + ReplacementRefCount: 0, + ObjectIntegrityCheck: "passed", + }, nil +} + +func trackedBlobDigest(ctx context.Context, repo gitx.Repo, commit, path string) (string, error) { + object := commit + ":" + path + typeName, err := repo.Text(ctx, nil, "cat-file", "-t", object) + if err != nil || typeName != "blob" { + return "", verifierProblem(ctx, err, "verifier artifact %s is not a tracked file", path) + } + sizeText, err := repo.Text(ctx, nil, "cat-file", "-s", object) + if err != nil { + return "", verifierProblem(ctx, err, "inspect verifier artifact %s", path) + } + size, err := strconv.ParseInt(sizeText, 10, 64) + if err != nil || size < 1 || size > maximumVerifierFileBytes { + return "", problem("VERIFIER_INVALID", "verifier artifact %s has an invalid size", path) + } + body, err := repo.Raw(ctx, nil, "cat-file", "blob", object) + if err != nil || int64(len(body)) != size { + return "", verifierProblem(ctx, err, "read verifier artifact %s", path) + } + digest := sha256.Sum256([]byte(body)) + return "sha256:" + hex.EncodeToString(digest[:]), nil +} + +func verifierProblem(ctx context.Context, err error, format string, arguments ...any) error { + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return problem("COMMAND_TIMEOUT", "verifier checks exceeded %s", gitCommandTimeout) + } + if err == nil { + return problem("VERIFIER_INVALID", format, arguments...) + } + return wrapProblem("VERIFIER_INVALID", err, format, arguments...) +} + +func collectGitEvidence(parent context.Context, repo gitx.Repo, bootstrap, oldCandidate, candidate string, expectedPaths []string) (gitEvidence, error) { + ctx, cancel := context.WithTimeout(parent, gitCommandTimeout) + defer cancel() + var result gitEvidence + if _, err := repo.Text(ctx, nil, "check-ref-format", "--branch", "main"); err != nil { + return result, gitProblem(ctx, "GIT_CHECK_FAILED", err, "validate Git command execution") + } + shallow, err := repo.Text(ctx, nil, "rev-parse", "--is-shallow-repository") + if err != nil || shallow != "false" { + return result, gitProblem(ctx, "GIT_CHECK_FAILED", err, "candidate history must be complete and non-shallow") + } + replacements, err := repo.Lines(ctx, nil, "for-each-ref", "--format=%(refname)", "refs/replace") + if err != nil { + return result, gitProblem(ctx, "GIT_CHECK_FAILED", err, "inspect replacement refs") + } + if len(replacements) != 0 { + return result, problem("GIT_CHECK_FAILED", "candidate repository has replacement refs") + } + if _, err := repo.Raw(ctx, nil, "fsck", "--strict", "--no-dangling"); err != nil { + return result, gitProblem(ctx, "OBJECT_CHECK_FAILED", err, "candidate object integrity check") + } + status, err := repo.Raw(ctx, nil, "status", "--porcelain=v1", "-z", "--untracked-files=all") + if err != nil { + return result, gitProblem(ctx, "GIT_CHECK_FAILED", err, "inspect candidate checkout") + } + if status != "" { + return result, problem("CHECKOUT_NOT_CLEAN", "candidate checkout is not clean") + } + for label, object := range map[string]string{"bootstrap": bootstrap, "old candidate": oldCandidate, "candidate": candidate} { + resolved, resolveErr := repo.Text(ctx, nil, "rev-parse", "--verify", object+"^{commit}") + if resolveErr != nil || resolved != object { + return result, gitProblem(ctx, "CANDIDATE_MISMATCH", resolveErr, "%s does not resolve exactly", label) + } + } + head, err := repo.Text(ctx, nil, "rev-parse", "HEAD") + if err != nil || head != candidate { + return result, gitProblem(ctx, "CANDIDATE_MISMATCH", err, "checked-out commit does not equal --candidate") + } + ancestorCode, err := repo.Exit(ctx, nil, "merge-base", "--is-ancestor", bootstrap, oldCandidate) + if err != nil || ancestorCode != 0 { + return result, gitProblem(ctx, "CORRECTION_NOT_LINEAR", err, "bootstrap is not an ancestor of the old candidate") + } + commits, err := collectLinearCorrectionRange(ctx, repo, oldCandidate, candidate) + if err != nil { + return result, err + } + bootstrapTree, err := objectTree(ctx, repo, bootstrap) + if err != nil { + return result, err + } + oldTree, err := objectTree(ctx, repo, oldCandidate) + if err != nil { + return result, err + } + candidateTree, err := objectTree(ctx, repo, candidate) + if err != nil { + return result, err + } + bootstrapDelta, err := changedPaths(ctx, repo, bootstrap, candidate) + if err != nil { + return result, err + } + if !equalStrings(bootstrapDelta, expectedPaths) { + return result, problem("PATH_MANIFEST_MISMATCH", "bootstrap delta does not match the reviewed path manifest") + } + correctionDelta, err := changedPaths(ctx, repo, oldCandidate, candidate) + if err != nil { + return result, err + } + if len(correctionDelta) == 0 { + return result, problem("CORRECTION_NOT_LINEAR", "correction range does not change a reviewed path") + } + identities, err := collectIdentities(ctx, repo, candidate) + if err != nil { + return result, err + } + result.Bootstrap = objectEvidence{Commit: bootstrap, Tree: bootstrapTree} + result.Correction = correctionEvidence{ + OldCandidate: objectEvidence{Commit: oldCandidate, Tree: oldTree}, + NewCandidate: objectEvidence{Commit: candidate, Tree: candidateTree}, + Commits: commits, + BootstrapDelta: bootstrapDelta, + CorrectionDelta: correctionDelta, + MergeFree: true, + OldIsAncestor: true, + BootstrapIsAncestor: true, + } + result.Identity = identities + return result, nil +} + +func collectLinearCorrectionRange(ctx context.Context, repo gitx.Repo, oldCandidate, candidate string) ([]correctionCommit, error) { + commits, err := repo.Lines(ctx, nil, "rev-list", "--reverse", "--topo-order", oldCandidate+".."+candidate) + if err != nil { + return nil, gitProblem(ctx, "CORRECTION_NOT_LINEAR", err, "inspect correction range") + } + if len(commits) == 0 || commits[len(commits)-1] != candidate { + return nil, problem("CORRECTION_NOT_LINEAR", "old candidate must be an ancestor of a non-empty correction range") + } + previous := oldCandidate + result := make([]correctionCommit, 0, len(commits)) + for _, commit := range commits { + parents, parentErr := repo.Text(ctx, nil, "rev-list", "--parents", "-n", "1", commit) + if parentErr != nil { + return nil, gitProblem(ctx, "CORRECTION_NOT_LINEAR", parentErr, "inspect correction commit") + } + fields := strings.Fields(parents) + if len(fields) != 2 || fields[0] != commit || fields[1] != previous { + return nil, problem("CORRECTION_NOT_LINEAR", "correction range is not a linear first-parent chain") + } + tree, treeErr := objectTree(ctx, repo, commit) + if treeErr != nil { + return nil, treeErr + } + result = append(result, correctionCommit{Commit: commit, Tree: tree, Parent: previous}) + previous = commit + } + return result, nil +} + +func objectTree(ctx context.Context, repo gitx.Repo, object string) (string, error) { + tree, err := repo.Text(ctx, nil, "show", "-s", "--format=%T", object) + if err != nil || !objectPattern.MatchString(tree) { + return "", gitProblem(ctx, "OBJECT_CHECK_FAILED", err, "resolve commit tree") + } + return tree, nil +} + +func changedPaths(ctx context.Context, repo gitx.Repo, old, current string) ([]string, error) { + paths, err := repo.NULPaths(ctx, nil, "diff", "--no-renames", "--name-only", "-z", old, current) + if err != nil { + return nil, gitProblem(ctx, "GIT_CHECK_FAILED", err, "inspect changed paths") + } + sort.Strings(paths) + return paths, nil +} + +func collectIdentities(ctx context.Context, repo gitx.Repo, candidate string) (identitySet, error) { + countText, err := repo.Text(ctx, nil, "rev-list", "--count", candidate) + if err != nil { + return identitySet{}, gitProblem(ctx, "IDENTITY_MISMATCH", err, "count history identities") + } + count, err := strconv.Atoi(countText) + if err != nil || count < 1 { + return identitySet{}, problem("IDENTITY_MISMATCH", "history commit count is invalid") + } + output, err := repo.Raw(ctx, nil, "log", "-z", "--format=%H%x00%an%x00%ae%x00%cn%x00%ce%x00", candidate) + if err != nil { + return identitySet{}, gitProblem(ctx, "IDENTITY_MISMATCH", err, "read history identities") + } + values := nonemptyNULValues(output) + if len(values) != count*5 { + return identitySet{}, problem("IDENTITY_MISMATCH", "history identity stream is incomplete") + } + authorNames, authorEmails, committerNames, committerEmails := map[string]bool{}, map[string]bool{}, map[string]bool{}, map[string]bool{} + for index := 0; index < len(values); index += 5 { + if !objectPattern.MatchString(values[index]) { + return identitySet{}, problem("IDENTITY_MISMATCH", "history identity stream contains an invalid commit") + } + authorNames[values[index+1]], authorEmails[values[index+2]] = true, true + committerNames[values[index+3]], committerEmails[values[index+4]] = true, true + } + result := identitySet{ + HistoryCommitCount: count, + AuthorNames: sortedKeys(authorNames), + AuthorEmails: sortedKeys(authorEmails), + CommitterNames: sortedKeys(committerNames), + CommitterEmails: sortedKeys(committerEmails), + } + hash := sha256.New() + for _, group := range []struct { + label string + values []string + }{ + {label: "author-email", values: result.AuthorEmails}, + {label: "author-name", values: result.AuthorNames}, + {label: "committer-email", values: result.CommitterEmails}, + {label: "committer-name", values: result.CommitterNames}, + } { + for _, value := range group.values { + _, _ = fmt.Fprintf(hash, "%s\x00%s\x00", group.label, value) + } + } + result.Digest = "sha256:" + hex.EncodeToString(hash.Sum(nil)) + return result, nil +} + +func gitProblem(ctx context.Context, code string, err error, format string, arguments ...any) error { + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return problem("COMMAND_TIMEOUT", "Git checks exceeded %s", gitCommandTimeout) + } + message := fmt.Sprintf(format, arguments...) + if err == nil { + return problem(code, "%s", message) + } + return wrapProblem(code, err, "%s", message) +} + +type hostedEvidence struct { + Repository repositoryEvidence + Remote remoteEvidence + PullRequest pullRequestEvidence + Runs []runEvidence + Ruleset rulesetEvidence + RequiredChecks []requiredCheckEvidence + PublicNameMatch bool +} + +type githubUser struct { + Login string `json:"login"` + Name string `json:"name"` + Email string `json:"email"` +} + +type githubViewer struct { + Data struct { + Viewer struct { + Login string `json:"login"` + } `json:"viewer"` + } `json:"data"` +} + +type githubRepository struct { + FullName string `json:"full_name"` + Visibility string `json:"visibility"` + Private bool `json:"private"` + DefaultBranch string `json:"default_branch"` + Owner struct { + Login string `json:"login"` + } `json:"owner"` +} + +type githubRef struct { + Ref string `json:"ref"` + Object struct { + SHA string `json:"sha"` + Type string `json:"type"` + } `json:"object"` +} + +type githubPullRequest struct { + Number int `json:"number"` + State string `json:"state"` + Merged bool `json:"merged"` + MergedAt *time.Time `json:"merged_at"` + MergeCommit string `json:"merge_commit_sha"` + Head struct { + Ref string `json:"ref"` + SHA string `json:"sha"` + } `json:"head"` + Base struct { + Ref string `json:"ref"` + SHA string `json:"sha"` + } `json:"base"` +} + +type githubRun struct { + ID int64 `json:"id"` + Event string `json:"event"` + HeadBranch string `json:"head_branch"` + HeadSHA string `json:"head_sha"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + Attempt int `json:"run_attempt"` + Name string `json:"name"` +} + +type githubRuleset struct { + ID int64 `json:"id"` + Name string `json:"name"` + Target string `json:"target"` + Enforcement string `json:"enforcement"` + BypassActors []map[string]any `json:"bypass_actors"` + Conditions struct { + RefName struct { + Include []string `json:"include"` + Exclude []string `json:"exclude"` + } `json:"ref_name"` + } `json:"conditions"` + Rules []struct { + Type string `json:"type"` + Parameters struct { + RequiredApprovingReviewCount int `json:"required_approving_review_count"` + DismissStaleReviews bool `json:"dismiss_stale_reviews_on_push"` + RequireLastPushApproval bool `json:"require_last_push_approval"` + RequireReviewThreadResolution bool `json:"required_review_thread_resolution"` + AllowedMergeMethods []string `json:"allowed_merge_methods"` + StrictRequiredChecks bool `json:"strict_required_status_checks_policy"` + DoNotEnforceOnCreate bool `json:"do_not_enforce_on_create"` + RequiredChecks []struct { + Context string `json:"context"` + IntegrationID int64 `json:"integration_id"` + } `json:"required_status_checks"` + } `json:"parameters"` + } `json:"rules"` +} + +type githubJobs struct { + Jobs []struct { + ID int64 `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + } `json:"jobs"` +} + +type githubCheckRun struct { + ID int64 `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + App struct { + ID int64 `json:"id"` + } `json:"app"` +} + +func collectGitHubEvidence(ctx context.Context, directory string, configuration options, identities identitySet, runExternal externalRunner) (hostedEvidence, error) { + var result hostedEvidence + owner := strings.SplitN(configuration.Target, "/", 2)[0] + authenticated, err := githubAuthenticatedLogin(ctx, directory, runExternal) + if err != nil { + return result, err + } + if authenticated != owner { + return result, problem("TARGET_MISMATCH", "authenticated GitHub account does not match the target owner") + } + var publicOwner githubUser + if err := githubAPI(ctx, directory, runExternal, "users/"+owner, &publicOwner); err != nil { + return result, err + } + if publicOwner.Login != owner || strings.TrimSpace(publicOwner.Email) == "" { + return result, problem("IDENTITY_MISMATCH", "target owner public email is unavailable") + } + if !allIdentityValuesMatch(identities.AuthorEmails, publicOwner.Email) || !allIdentityValuesMatch(identities.CommitterEmails, publicOwner.Email) { + return result, problem("IDENTITY_MISMATCH", "history author or committer email does not match the public owner profile") + } + result.PublicNameMatch = allIdentityValuesMatch(identities.AuthorNames, publicOwner.Name) && allIdentityValuesMatch(identities.CommitterNames, publicOwner.Name) + var repository githubRepository + if err := githubAPI(ctx, directory, runExternal, "repos/"+configuration.Target, &repository); err != nil { + return result, err + } + if repository.FullName != configuration.Target || repository.Owner.Login != owner || repository.Private || repository.Visibility != "public" || repository.DefaultBranch != "main" { + return result, problem("TARGET_MISMATCH", "target repository identity, visibility, owner, or default branch differs") + } + var candidateRef githubRef + if err := githubAPI(ctx, directory, runExternal, "repos/"+configuration.Target+"/git/ref/heads/"+configuration.CandidateRef, &candidateRef); err != nil { + return result, err + } + if candidateRef.Ref != "refs/heads/"+configuration.CandidateRef || candidateRef.Object.Type != "commit" || candidateRef.Object.SHA != configuration.Candidate { + return result, problem("REMOTE_REF_MISMATCH", "remote candidate ref does not equal the corrected candidate") + } + var mainRef githubRef + if err := githubAPI(ctx, directory, runExternal, "repos/"+configuration.Target+"/git/ref/heads/main", &mainRef); err != nil { + return result, err + } + expectedMain := configuration.Bootstrap + if configuration.Phase == "finalized" { + expectedMain = configuration.Candidate + } + if mainRef.Ref != "refs/heads/main" || mainRef.Object.Type != "commit" || mainRef.Object.SHA != expectedMain { + return result, problem("REMOTE_REF_MISMATCH", "remote main does not match the required %s phase commit", configuration.Phase) + } + var pull githubPullRequest + if err := githubAPI(ctx, directory, runExternal, fmt.Sprintf("repos/%s/pulls/%d", configuration.Target, configuration.PullRequest), &pull); err != nil { + return result, err + } + expectedPRState := "open" + if configuration.Phase == "finalized" { + expectedPRState = "closed" + } + if pull.Number != configuration.PullRequest || pull.State != expectedPRState || pull.Merged || pull.MergedAt != nil || pull.Head.Ref != configuration.CandidateRef || pull.Head.SHA != configuration.Candidate || pull.Base.Ref != "main" || pull.Base.SHA != configuration.Bootstrap { + return result, problem("PULL_REQUEST_MISMATCH", "verification pull request does not match the candidate, phase, base, or unmerged state") + } + runs, verificationRun, err := collectRuns(ctx, directory, configuration, runExternal) + if err != nil { + return result, err + } + ruleset, requirements, err := collectRuleset(ctx, directory, configuration, runExternal) + if err != nil { + return result, err + } + checks, err := collectRequiredChecks(ctx, directory, configuration.Target, verificationRun, requirements, runExternal) + if err != nil { + return result, err + } + result.Repository = repositoryEvidence{FullName: configuration.Target, Visibility: "public", DefaultBranch: "main", AuthenticatedOwner: true} + result.Remote = remoteEvidence{CandidateRef: configuration.CandidateRef, CandidateCommit: configuration.Candidate, MainRef: "main", MainCommit: expectedMain} + result.PullRequest = pullRequestEvidence{Number: pull.Number, State: pull.State, HeadRef: pull.Head.Ref, HeadCommit: pull.Head.SHA, BaseRef: pull.Base.Ref, BaseCommit: pull.Base.SHA, Merged: false, MergeCommit: pull.MergeCommit} + result.Runs, result.Ruleset, result.RequiredChecks = runs, ruleset, checks + return result, nil +} + +func githubAuthenticatedLogin(ctx context.Context, directory string, runExternal externalRunner) (string, error) { + stdout, stderr, code, err := runExternal(ctx, githubCommandTimeout, directory, "gh", "api", "graphql", "-f", "query=query { viewer { login } }") + if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + return "", problem("COMMAND_TIMEOUT", "GitHub request exceeded %s", githubCommandTimeout) + } + return "", wrapProblem("GITHUB_CHECK_FAILED", err, "query authenticated GitHub viewer") + } + if code != 0 { + return "", problem("GITHUB_CHECK_FAILED", "GitHub viewer request failed: %s", commandFailureOutput("", stderr)) + } + var viewer githubViewer + if err := json.Unmarshal([]byte(stdout), &viewer); err != nil { + return "", wrapProblem("GITHUB_CHECK_FAILED", err, "decode authenticated GitHub viewer") + } + if strings.TrimSpace(viewer.Data.Viewer.Login) == "" { + return "", problem("GITHUB_CHECK_FAILED", "authenticated GitHub viewer has no login") + } + return viewer.Data.Viewer.Login, nil +} + +func collectRuns(ctx context.Context, directory string, configuration options, runExternal externalRunner) ([]runEvidence, int64, error) { + runs := make([]runEvidence, 0, len(configuration.Runs)) + verificationRun, pullRuns, candidatePushes, mainPushes := int64(0), 0, 0, 0 + for _, spec := range configuration.Runs { + var run githubRun + if err := githubAPI(ctx, directory, runExternal, fmt.Sprintf("repos/%s/actions/runs/%d", configuration.Target, spec.ID), &run); err != nil { + return nil, 0, err + } + if run.ID != spec.ID || run.Event != spec.Event || run.HeadBranch != spec.Branch || run.HeadSHA != configuration.Candidate || run.Status != "completed" || run.Conclusion != "success" || run.Attempt < 1 || strings.TrimSpace(run.Name) == "" { + return nil, 0, problem("HOSTED_RUN_FAILED", "hosted run %s does not match its reviewed event, branch, candidate, or successful result", spec.Label) + } + if run.Event == "pull_request" && run.HeadBranch == configuration.CandidateRef { + pullRuns++ + verificationRun = run.ID + } + if run.Event == "push" && run.HeadBranch == configuration.CandidateRef { + candidatePushes++ + } + if run.Event == "push" && run.HeadBranch == "main" { + mainPushes++ + } + runs = append(runs, runEvidence{Label: spec.Label, ID: run.ID, Event: run.Event, HeadBranch: run.HeadBranch, HeadCommit: run.HeadSHA, Status: run.Status, Conclusion: run.Conclusion, Attempt: run.Attempt, Workflow: run.Name}) + } + if pullRuns != 1 || candidatePushes < 1 { + return nil, 0, problem("HOSTED_RUN_INCOMPLETE", "runs must contain one candidate pull-request run and at least one candidate push run") + } + if configuration.Phase == "finalized" && mainPushes < 1 { + return nil, 0, problem("HOSTED_RUN_INCOMPLETE", "finalized runs must contain a successful main push run") + } + return runs, verificationRun, nil +} + +type checkRequirement struct { + Context string + IntegrationID int64 +} + +func collectRuleset(ctx context.Context, directory string, configuration options, runExternal externalRunner) (rulesetEvidence, []checkRequirement, error) { + var ruleset githubRuleset + if err := githubAPI(ctx, directory, runExternal, fmt.Sprintf("repos/%s/rulesets/%d", configuration.Target, configuration.Ruleset), &ruleset); err != nil { + return rulesetEvidence{}, nil, err + } + if ruleset.ID != configuration.Ruleset || strings.TrimSpace(ruleset.Name) == "" || ruleset.Target != "branch" || ruleset.Enforcement != "active" || len(ruleset.BypassActors) != 0 || !equalStrings(ruleset.Conditions.RefName.Include, []string{"~DEFAULT_BRANCH"}) || len(ruleset.Conditions.RefName.Exclude) != 0 { + return rulesetEvidence{}, nil, problem("RULESET_MISMATCH", "ruleset must have a name, no bypass actor, and one active default-branch target") + } + ruleCounts := map[string]int{} + var pullParameters struct { + RequiredApprovingReviewCount int + DismissStaleReviews bool + RequireLastPushApproval bool + RequireReviewThreadResolution bool + AllowedMergeMethods []string + } + strict, requiredChecksOnCreate := false, false + var requirements []checkRequirement + for _, rule := range ruleset.Rules { + ruleCounts[rule.Type]++ + switch rule.Type { + case "pull_request": + pullParameters.RequiredApprovingReviewCount = rule.Parameters.RequiredApprovingReviewCount + pullParameters.DismissStaleReviews = rule.Parameters.DismissStaleReviews + pullParameters.RequireLastPushApproval = rule.Parameters.RequireLastPushApproval + pullParameters.RequireReviewThreadResolution = rule.Parameters.RequireReviewThreadResolution + pullParameters.AllowedMergeMethods = append([]string(nil), rule.Parameters.AllowedMergeMethods...) + case "required_status_checks": + strict = rule.Parameters.StrictRequiredChecks + requiredChecksOnCreate = !rule.Parameters.DoNotEnforceOnCreate + for _, check := range rule.Parameters.RequiredChecks { + requirements = append(requirements, checkRequirement{Context: check.Context, IntegrationID: check.IntegrationID}) + } + } + } + for _, requiredRule := range []string{"deletion", "non_fast_forward", "required_linear_history", "pull_request", "required_status_checks"} { + if ruleCounts[requiredRule] != 1 { + return rulesetEvidence{}, nil, problem("RULESET_MISMATCH", "ruleset must contain exactly one %s rule", requiredRule) + } + } + if pullParameters.RequiredApprovingReviewCount < 1 || !pullParameters.DismissStaleReviews || !pullParameters.RequireLastPushApproval || !pullParameters.RequireReviewThreadResolution || !equalStrings(pullParameters.AllowedMergeMethods, []string{"rebase"}) { + return rulesetEvidence{}, nil, problem("RULESET_MISMATCH", "pull-request rule must require review, stale-review dismissal, last-push approval, resolved threads, and rebase-only merges") + } + if !strict || !requiredChecksOnCreate || len(requirements) == 0 { + return rulesetEvidence{}, nil, problem("RULESET_MISMATCH", "status checks must be strict, required on branch creation, and non-empty") + } + sort.Slice(requirements, func(left, right int) bool { return requirements[left].Context < requirements[right].Context }) + seen := map[string]bool{} + for _, requirement := range requirements { + if strings.TrimSpace(requirement.Context) == "" || requirement.IntegrationID <= 0 || seen[requirement.Context] { + return rulesetEvidence{}, nil, problem("RULESET_MISMATCH", "ruleset contains an invalid or duplicate required check") + } + seen[requirement.Context] = true + } + return rulesetEvidence{ + ID: ruleset.ID, + Name: ruleset.Name, + Enforcement: ruleset.Enforcement, + NoBypassActors: true, + DeletionBlocked: true, + NonFastForwardBlocked: true, + LinearHistoryRequired: true, + PullRequestRequired: true, + RequiredApprovingReviewCount: pullParameters.RequiredApprovingReviewCount, + DismissStaleReviews: true, + LastPushApprovalRequired: true, + ReviewThreadsResolved: true, + AllowedMergeMethods: []string{"rebase"}, + StrictRequiredChecks: true, + RequiredChecksOnCreate: true, + }, requirements, nil +} + +func collectRequiredChecks(ctx context.Context, directory, target string, verificationRun int64, requirements []checkRequirement, runExternal externalRunner) ([]requiredCheckEvidence, error) { + var jobs githubJobs + endpoint := fmt.Sprintf("repos/%s/actions/runs/%d/jobs?filter=latest&per_page=100", target, verificationRun) + if err := githubAPI(ctx, directory, runExternal, endpoint, &jobs); err != nil { + return nil, err + } + result := make([]requiredCheckEvidence, 0, len(requirements)) + for _, requirement := range requirements { + var jobID int64 + for _, job := range jobs.Jobs { + if job.Name == requirement.Context && job.Status == "completed" && job.Conclusion == "success" && job.ID > jobID { + jobID = job.ID + } + } + if jobID == 0 { + return nil, problem("REQUIRED_CHECK_FAILED", "required check %q has no successful job in the verification run", requirement.Context) + } + var check githubCheckRun + if err := githubAPI(ctx, directory, runExternal, fmt.Sprintf("repos/%s/check-runs/%d", target, jobID), &check); err != nil { + return nil, err + } + if check.ID != jobID || check.Name != requirement.Context || check.App.ID != requirement.IntegrationID || check.Status != "completed" || check.Conclusion != "success" { + return nil, problem("REQUIRED_CHECK_FAILED", "required check %q does not match its integration or successful result", requirement.Context) + } + result = append(result, requiredCheckEvidence{Context: check.Name, IntegrationID: check.App.ID, CheckRunID: check.ID, Status: check.Status, Conclusion: check.Conclusion}) + } + return result, nil +} + +func githubAPI(ctx context.Context, directory string, runExternal externalRunner, endpoint string, target any) error { + stdout, stderr, code, err := runExternal(ctx, githubCommandTimeout, directory, "gh", "api", endpoint) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + return problem("COMMAND_TIMEOUT", "GitHub request exceeded %s", githubCommandTimeout) + } + return wrapProblem("GITHUB_CHECK_FAILED", err, "query GitHub API") + } + if code != 0 { + return problem("GITHUB_CHECK_FAILED", "GitHub API request failed: %s", commandFailureOutput("", stderr)) + } + if err := json.Unmarshal([]byte(stdout), target); err != nil { + return wrapProblem("GITHUB_CHECK_FAILED", err, "decode GitHub API response") + } + return nil +} + +func allIdentityValuesMatch(values []string, expected string) bool { + if len(values) == 0 || strings.TrimSpace(expected) == "" { + return false + } + for _, value := range values { + if !strings.EqualFold(strings.TrimSpace(value), strings.TrimSpace(expected)) { + return false + } + } + return true +} + +func runSecretScans(ctx context.Context, directory string, runExternal externalRunner) error { + for _, arguments := range [][]string{{"git", "--redact", "--exit-code", "1", "."}, {"dir", "--redact", "--exit-code", "1", "."}} { + stdout, stderr, code, err := runExternal(ctx, secretScanTimeout, directory, "gitleaks", arguments...) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + return problem("COMMAND_TIMEOUT", "secret scan exceeded %s", secretScanTimeout) + } + return wrapProblem("SECRET_SCAN_FAILED", err, "run gitleaks %s", arguments[0]) + } + if code != 0 { + return problem("SECRET_SCAN_FAILED", "gitleaks %s failed: %s", arguments[0], commandFailureOutput(stdout, stderr)) + } + } + return nil +} + +func externalCommandWithTimeout(ctx context.Context, timeout time.Duration, directory, name string, arguments ...string) (string, string, int, error) { + commandContext, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + stdout, stderr, code, err := externalCommand(commandContext, directory, name, arguments...) + if commandContext.Err() != nil { + return stdout, stderr, -1, commandContext.Err() + } + return stdout, stderr, code, err +} + +func externalCommand(ctx context.Context, directory, name string, arguments ...string) (string, string, int, error) { + command := exec.CommandContext(ctx, name, arguments...) + command.Dir = directory + command.Env = gitx.Environment(nil) + stdout, stderr := newBoundedOutput(maximumCommandOutputBytes), newBoundedOutput(maximumCommandOutputBytes) + command.Stdout, command.Stderr = &stdout, &stderr + err := command.Run() + if ctx.Err() != nil { + return stdout.String(), stderr.String(), -1, ctx.Err() + } + if err == nil { + return stdout.String(), stderr.String(), 0, nil + } + var exitError *exec.ExitError + if errors.As(err, &exitError) { + return stdout.String(), stderr.String(), exitError.ExitCode(), nil + } + return stdout.String(), stderr.String(), -1, err +} + +type boundedOutput struct { + buffer bytes.Buffer + remaining int + truncated bool +} + +func newBoundedOutput(maximum int) boundedOutput { return boundedOutput{remaining: maximum} } + +func (output *boundedOutput) Write(value []byte) (int, error) { + length, writeLength := len(value), min(output.remaining, len(value)) + if writeLength > 0 { + _, _ = output.buffer.Write(value[:writeLength]) + output.remaining -= writeLength + } + if writeLength < length { + output.truncated = true + } + return length, nil +} + +func (output *boundedOutput) String() string { + if output.truncated { + return output.buffer.String() + "\n[output truncated]" + } + return output.buffer.String() +} + +func commandFailureOutput(stdout, stderr string) string { + var parts []string + if value := strings.TrimSpace(stdout); value != "" { + parts = append(parts, value) + } + if value := strings.TrimSpace(stderr); value != "" { + parts = append(parts, value) + } + if len(parts) == 0 { + return "no diagnostic output" + } + return strings.Join(parts, "\n") +} + +func encodeReceipt(value receipt) ([]byte, string, error) { + body, err := json.MarshalIndent(value, "", " ") + if err != nil { + return nil, "", wrapProblem("RECEIPT_ENCODE_FAILED", err, "encode correction receipt") + } + body = append(body, '\n') + digest := sha256.Sum256(body) + return body, "sha256:" + hex.EncodeToString(digest[:]), nil +} + +func writeReceipt(path string, body []byte) error { + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if errors.Is(err, os.ErrExist) { + return problem("OUTPUT_EXISTS", "receipt output already exists") + } + if err != nil { + return wrapProblem("OUTPUT_WRITE_FAILED", err, "create correction receipt") + } + complete := false + defer func() { + _ = file.Close() + if !complete { + _ = os.Remove(path) + } + }() + if written, writeErr := file.Write(body); writeErr != nil || written != len(body) { + if writeErr == nil { + writeErr = io.ErrShortWrite + } + return wrapProblem("OUTPUT_WRITE_FAILED", writeErr, "write correction receipt") + } + if err := file.Sync(); err != nil { + return wrapProblem("OUTPUT_WRITE_FAILED", err, "sync correction receipt") + } + if err := file.Close(); err != nil { + return wrapProblem("OUTPUT_WRITE_FAILED", err, "close correction receipt") + } + complete = true + return nil +} + +func validatedReceiptOutput(roots []string, name string) (string, error) { + out, err := filepath.Abs(name) + if err != nil { + return "", wrapProblem("OUTPUT_UNSAFE", err, "resolve receipt output") + } + for _, root := range roots { + inside, containmentErr := pathWithinRoot(root, out) + if containmentErr != nil || inside { + return "", problem("OUTPUT_UNSAFE", "--out must resolve outside the candidate and verifier checkouts") + } + } + resolvedParent, err := filepath.EvalSymlinks(filepath.Dir(out)) + if err != nil { + return "", wrapProblem("OUTPUT_UNSAFE", err, "resolve receipt directory") + } + info, err := os.Stat(resolvedParent) + if err != nil || !info.IsDir() { + return "", problem("OUTPUT_UNSAFE", "receipt parent must be an existing directory") + } + if runtime.GOOS != "windows" && info.Mode().Perm()&0o077 != 0 { + return "", problem("OUTPUT_UNSAFE", "receipt directory must not grant group or other permissions") + } + resolvedOut := filepath.Join(resolvedParent, filepath.Base(out)) + if _, err := os.Lstat(resolvedOut); err == nil { + return "", problem("OUTPUT_EXISTS", "receipt output already exists") + } else if !errors.Is(err, os.ErrNotExist) { + return "", wrapProblem("OUTPUT_UNSAFE", err, "inspect receipt output") + } + return resolvedOut, nil +} + +func pathWithinRoot(root, name string) (bool, error) { + resolvedRoot, err := filepath.EvalSymlinks(root) + if err != nil { + return false, err + } + var resolvedName string + if existing, resolveErr := filepath.EvalSymlinks(name); resolveErr == nil { + resolvedName = existing + } else { + parent, parentErr := filepath.EvalSymlinks(filepath.Dir(name)) + if parentErr != nil { + return false, parentErr + } + resolvedName = filepath.Join(parent, filepath.Base(name)) + } + relative, err := filepath.Rel(resolvedRoot, resolvedName) + if err != nil { + return false, err + } + return relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)), nil +} + +func requireJSONEOF(decoder *json.Decoder) error { + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + if err == nil { + return errors.New("document contains more than one JSON value") + } + return err + } + return nil +} + +func nonemptyNULValues(output string) []string { + var values []string + for _, value := range strings.Split(output, "\x00") { + if value = strings.TrimSpace(value); value != "" { + values = append(values, value) + } + } + return values +} + +func sortedKeys(values map[string]bool) []string { + keys := make([]string, 0, len(values)) + for value := range values { + if strings.TrimSpace(value) != "" { + keys = append(keys, value) + } + } + sort.Strings(keys) + return keys +} + +func equalStrings(left, right []string) bool { + if len(left) != len(right) { + return false + } + for index := range left { + if left[index] != right[index] { + return false + } + } + return true +} diff --git a/scripts/publication-correction/main_test.go b/scripts/publication-correction/main_test.go new file mode 100644 index 0000000..3aeb096 --- /dev/null +++ b/scripts/publication-correction/main_test.go @@ -0,0 +1,829 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "testing" + "time" + + "github.com/nstranquist/wip-commit/internal/gitx" +) + +const ( + testTarget = "owner/project" + testOwner = "owner" + testPublicName = "Public Author" + testPublicEmail = "public@example.invalid" + testCandidateRef = "candidate/beta" + testRulesetID = int64(77) + testIntegration = int64(15368) + testCandidateRun = int64(101) + testPullRun = int64(102) + testMainRun = int64(103) + testCheckRun = int64(201) +) + +type correctionFixture struct { + repo gitx.Repo + verifier gitx.Repo + bootstrap, old string + candidate string + correctionCommits []string + pathsFile string + firstReceipt string + out string + privateDirectory string + hosted *hostedFixture +} + +type hostedRunFixture struct { + ID int64 + Event string + HeadBranch string + HeadSHA string + Status string + Conclusion string + Attempt int + Name string +} + +type hostedFixture struct { + bootstrap, candidate, candidateRef string + mainCommit string + publicName, publicEmail string + repositoryPrivate bool + repositoryVisibility string + remoteCandidate string + pullState string + pullHead string + pullMerged bool + pullMergedAt any + runs map[int64]hostedRunFixture + rulesetEnforcement string + rulesetStrict bool + rulesetBypass bool + rulesetDeletion bool + rulesetNonFastForward bool + rulesetLinearHistory bool + rulesetPullRequest bool + rulesetReviewCount int + rulesetDismissStale bool + rulesetLastPushApproval bool + rulesetReviewThreads bool + rulesetAllowedMergeMethods []string + rulesetDoNotEnforceOnCreate bool + checkIntegration int64 + checkConclusion string + secretFailure bool + timeoutCommand string +} + +func TestRunFinalizedCorrectionSuccess(t *testing.T) { + fixture := newCorrectionFixture(t, 1) + stdout := runFixture(t, fixture, "finalized") + var result map[string]any + if err := json.Unmarshal([]byte(stdout), &result); err != nil { + t.Fatal(err) + } + if result["ok"] != true || result["phase"] != "finalized" || result["candidate_commit"] != fixture.candidate { + t.Fatalf("result = %#v", result) + } + var receiptValue receipt + readJSONFile(t, fixture.out, &receiptValue) + if receiptValue.SchemaVersion != receiptSchemaVersion || receiptValue.Correction.NewCandidate.Commit != fixture.candidate || receiptValue.Correction.NewCandidate.Tree == "" { + t.Fatalf("receipt identity = %#v", receiptValue) + } + if len(receiptValue.Correction.Commits) != 1 || len(receiptValue.RequiredChecks) != 1 || len(receiptValue.HostedRuns) != 3 { + t.Fatalf("receipt evidence counts = correction %d, checks %d, runs %d", len(receiptValue.Correction.Commits), len(receiptValue.RequiredChecks), len(receiptValue.HostedRuns)) + } + if receiptValue.Remote.MainCommit != fixture.candidate || receiptValue.PullRequest.State != "closed" || receiptValue.PullRequest.Merged { + t.Fatalf("finalized evidence = %#v %#v", receiptValue.Remote, receiptValue.PullRequest) + } + if !sha256Pattern.MatchString(receiptValue.FirstReceipt.Digest) || !sha256Pattern.MatchString(receiptValue.Identity.IdentitySetDigest) { + t.Fatalf("receipt digests = %#v %#v", receiptValue.FirstReceipt, receiptValue.Identity) + } + if !sha256Pattern.MatchString(receiptValue.Verifier.CommandDigest) || !sha256Pattern.MatchString(receiptValue.Verifier.SchemaDigest) || !receiptValue.Ruleset.NoBypassActors || !receiptValue.Ruleset.LastPushApprovalRequired { + t.Fatalf("verifier or ruleset evidence = %#v %#v", receiptValue.Verifier, receiptValue.Ruleset) + } + info, err := os.Stat(fixture.out) + if err != nil { + t.Fatal(err) + } + if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 { + t.Fatalf("receipt mode = %v", info.Mode()) + } + body, err := os.ReadFile(fixture.out) + if err != nil { + t.Fatal(err) + } + for _, forbidden := range []string{fixture.repo.Root, fixture.firstReceipt, testPublicName, testPublicEmail, "token-value", "raw secret finding"} { + if strings.Contains(string(body), forbidden) { + t.Fatalf("receipt contains forbidden private value %q", forbidden) + } + } +} + +func TestRunPreMainCorrectionSuccess(t *testing.T) { + fixture := newCorrectionFixture(t, 1) + fixture.hosted.mainCommit = fixture.bootstrap + fixture.hosted.pullState = "open" + delete(fixture.hosted.runs, testMainRun) + stdout := runFixture(t, fixture, "pre-main") + if !strings.Contains(stdout, `"phase":"pre-main"`) { + t.Fatalf("stdout = %s", stdout) + } + var got receipt + readJSONFile(t, fixture.out, &got) + if got.Remote.MainCommit != fixture.bootstrap || got.PullRequest.State != "open" || len(got.HostedRuns) != 2 { + t.Fatalf("pre-main receipt = %#v", got) + } +} + +func TestRunRecordsDisplayNameDifferenceWithoutWeakeningEmailIdentity(t *testing.T) { + fixture := newCorrectionFixture(t, 1) + fixture.hosted.publicName = "Different Display Name" + runFixture(t, fixture, "finalized") + var got receipt + readJSONFile(t, fixture.out, &got) + if !got.Identity.PublicEmailMatch || got.Identity.PublicNameMatch { + t.Fatalf("identity result = %#v", got.Identity) + } +} + +func TestRunRecordsMultipleLinearCorrectionCommits(t *testing.T) { + fixture := newCorrectionFixture(t, 3) + runFixture(t, fixture, "finalized") + var got receipt + readJSONFile(t, fixture.out, &got) + if len(got.Correction.Commits) != 3 { + t.Fatalf("correction commits = %#v", got.Correction.Commits) + } + previous := fixture.old + for index, commit := range got.Correction.Commits { + if commit.Parent != previous || commit.Commit != fixture.correctionCommits[index] || commit.Tree == "" { + t.Fatalf("commit %d = %#v", index, commit) + } + previous = commit.Commit + } +} + +func TestRunRejectsHostedEvidenceMismatchesWithoutReceipt(t *testing.T) { + tests := []struct { + name string + code string + edit func(*correctionFixture) + }{ + {name: "moved candidate ref", code: "REMOTE_REF_MISMATCH", edit: func(f *correctionFixture) { f.hosted.remoteCandidate = f.old }}, + {name: "failed run", code: "HOSTED_RUN_FAILED", edit: func(f *correctionFixture) { + run := f.hosted.runs[testCandidateRun] + run.Conclusion = "failure" + f.hosted.runs[testCandidateRun] = run + }}, + {name: "pending run", code: "HOSTED_RUN_FAILED", edit: func(f *correctionFixture) { + run := f.hosted.runs[testCandidateRun] + run.Status, run.Conclusion = "in_progress", "" + f.hosted.runs[testCandidateRun] = run + }}, + {name: "run for another head", code: "HOSTED_RUN_FAILED", edit: func(f *correctionFixture) { + run := f.hosted.runs[testPullRun] + run.HeadSHA = f.old + f.hosted.runs[testPullRun] = run + }}, + {name: "pull request head moved", code: "PULL_REQUEST_MISMATCH", edit: func(f *correctionFixture) { f.hosted.pullHead = f.old }}, + {name: "pull request merged", code: "PULL_REQUEST_MISMATCH", edit: func(f *correctionFixture) { + f.hosted.pullMerged = true + f.hosted.pullMergedAt = "2026-08-17T00:00:00Z" + }}, + {name: "private repository", code: "TARGET_MISMATCH", edit: func(f *correctionFixture) { + f.hosted.repositoryPrivate, f.hosted.repositoryVisibility = true, "private" + }}, + {name: "public identity changed", code: "IDENTITY_MISMATCH", edit: func(f *correctionFixture) { f.hosted.publicEmail = "different@example.invalid" }}, + {name: "failed required check", code: "REQUIRED_CHECK_FAILED", edit: func(f *correctionFixture) { f.hosted.checkConclusion = "failure" }}, + {name: "different check integration", code: "REQUIRED_CHECK_FAILED", edit: func(f *correctionFixture) { f.hosted.checkIntegration = 999 }}, + {name: "inactive ruleset", code: "RULESET_MISMATCH", edit: func(f *correctionFixture) { f.hosted.rulesetEnforcement = "disabled" }}, + {name: "non-strict ruleset", code: "RULESET_MISMATCH", edit: func(f *correctionFixture) { f.hosted.rulesetStrict = false }}, + {name: "ruleset bypass", code: "RULESET_MISMATCH", edit: func(f *correctionFixture) { f.hosted.rulesetBypass = true }}, + {name: "deletion allowed", code: "RULESET_MISMATCH", edit: func(f *correctionFixture) { f.hosted.rulesetDeletion = false }}, + {name: "force push allowed", code: "RULESET_MISMATCH", edit: func(f *correctionFixture) { f.hosted.rulesetNonFastForward = false }}, + {name: "nonlinear history", code: "RULESET_MISMATCH", edit: func(f *correctionFixture) { f.hosted.rulesetLinearHistory = false }}, + {name: "pull requests optional", code: "RULESET_MISMATCH", edit: func(f *correctionFixture) { f.hosted.rulesetPullRequest = false }}, + {name: "reviews optional", code: "RULESET_MISMATCH", edit: func(f *correctionFixture) { f.hosted.rulesetReviewCount = 0 }}, + {name: "stale reviews retained", code: "RULESET_MISMATCH", edit: func(f *correctionFixture) { f.hosted.rulesetDismissStale = false }}, + {name: "last push approval optional", code: "RULESET_MISMATCH", edit: func(f *correctionFixture) { f.hosted.rulesetLastPushApproval = false }}, + {name: "review threads unresolved", code: "RULESET_MISMATCH", edit: func(f *correctionFixture) { f.hosted.rulesetReviewThreads = false }}, + {name: "merge commits allowed", code: "RULESET_MISMATCH", edit: func(f *correctionFixture) { f.hosted.rulesetAllowedMergeMethods = []string{"merge", "rebase"} }}, + {name: "checks skipped on create", code: "RULESET_MISMATCH", edit: func(f *correctionFixture) { f.hosted.rulesetDoNotEnforceOnCreate = true }}, + {name: "secret scan failure", code: "SECRET_SCAN_FAILED", edit: func(f *correctionFixture) { f.hosted.secretFailure = true }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fixture := newCorrectionFixture(t, 1) + test.edit(fixture) + expectFixtureFailure(t, fixture, "finalized", test.code) + }) + } +} + +func TestRunRejectsLocalEvidenceMismatchesWithoutReceipt(t *testing.T) { + t.Run("changed path manifest", func(t *testing.T) { + fixture := newCorrectionFixture(t, 1) + writeCorrectionFile(t, fixture.repo.Root, fixture.pathsFile, "core.txt\ndocs/paths.txt\n") + correctionGit(t, fixture.repo.Root, "add", fixture.pathsFile) + correctionGit(t, fixture.repo.Root, "commit", "-m", "test: change reviewed manifest") + fixture.updateCandidate(correctionGit(t, fixture.repo.Root, "rev-parse", "HEAD")) + expectFixtureFailure(t, fixture, "finalized", "PATH_MANIFEST_MISMATCH") + }) + + t.Run("merge commit", func(t *testing.T) { + fixture := newCorrectionFixture(t, 1) + correctionGit(t, fixture.repo.Root, "branch", "side", fixture.old) + correctionGit(t, fixture.repo.Root, "switch", "side") + writeCorrectionFile(t, fixture.repo.Root, "side.txt", "side\n") + correctionGit(t, fixture.repo.Root, "add", "side.txt") + correctionGit(t, fixture.repo.Root, "commit", "-m", "test: create side") + correctionGit(t, fixture.repo.Root, "switch", "main") + correctionGit(t, fixture.repo.Root, "merge", "--no-ff", "side", "-m", "test: merge side") + fixture.updateCandidate(correctionGit(t, fixture.repo.Root, "rev-parse", "HEAD")) + expectFixtureFailure(t, fixture, "finalized", "CORRECTION_NOT_LINEAR") + }) + + t.Run("checked out commit differs", func(t *testing.T) { + fixture := newCorrectionFixture(t, 2) + arguments := fixture.arguments("finalized") + replaceFlag(arguments, "--candidate", fixture.correctionCommits[0]) + err := runWithDependencies(context.Background(), arguments, io.Discard, fixture.dependencies()) + assertTypedError(t, err, "CANDIDATE_MISMATCH") + assertNoFile(t, fixture.out) + }) + + t.Run("different target repository", func(t *testing.T) { + fixture := newCorrectionFixture(t, 1) + arguments := fixture.arguments("finalized") + replaceFlag(arguments, "--target", "owner/different") + err := runWithDependencies(context.Background(), arguments, io.Discard, fixture.dependencies()) + assertTypedError(t, err, "TARGET_MISMATCH") + assertNoFile(t, fixture.out) + }) + + t.Run("first receipt trees differ", func(t *testing.T) { + fixture := newCorrectionFixture(t, 1) + var first map[string]any + readJSONFile(t, fixture.firstReceipt, &first) + first["candidate"].(map[string]any)["tree"] = fixture.bootstrap + writeJSONFile(t, fixture.firstReceipt, first, 0o600) + expectFixtureFailure(t, fixture, "finalized", "FIRST_RECEIPT_MISMATCH") + }) + + t.Run("corrupt object database", func(t *testing.T) { + fixture := newCorrectionFixture(t, 1) + object := fixture.candidate + path := filepath.Join(fixture.repo.GitDir, "objects", object[:2], object[2:]) + if err := os.Chmod(path, 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("corrupt"), 0o600); err != nil { + t.Fatal(err) + } + expectFixtureFailure(t, fixture, "finalized", "OBJECT_CHECK_FAILED") + }) +} + +func TestRunRejectsInvalidVerifierWithoutReceipt(t *testing.T) { + t.Run("dirty verifier", func(t *testing.T) { + fixture := newCorrectionFixture(t, 1) + writeCorrectionFile(t, fixture.verifier.Root, "untracked.txt", "dirty\n") + expectFixtureFailure(t, fixture, "finalized", "VERIFIER_INVALID") + }) + + t.Run("wrong verifier module", func(t *testing.T) { + fixture := newCorrectionFixture(t, 1) + writeCorrectionFile(t, fixture.verifier.Root, "go.mod", "module github.com/owner/different\n\ngo 1.25.12\n") + correctionGit(t, fixture.verifier.Root, "add", "go.mod") + correctionGit(t, fixture.verifier.Root, "commit", "-m", "test: change verifier module") + expectFixtureFailure(t, fixture, "finalized", "VERIFIER_INVALID") + }) + + t.Run("missing verifier schema", func(t *testing.T) { + fixture := newCorrectionFixture(t, 1) + correctionGit(t, fixture.verifier.Root, "rm", verifierSchemaPath) + correctionGit(t, fixture.verifier.Root, "commit", "-m", "test: remove verifier schema") + expectFixtureFailure(t, fixture, "finalized", "VERIFIER_INVALID") + }) +} + +func TestRunRejectsUnsafeOrExistingOutput(t *testing.T) { + t.Run("existing output", func(t *testing.T) { + fixture := newCorrectionFixture(t, 1) + if err := os.WriteFile(fixture.out, []byte("keep\n"), 0o600); err != nil { + t.Fatal(err) + } + err := runWithDependencies(context.Background(), fixture.arguments("finalized"), io.Discard, fixture.dependencies()) + assertTypedError(t, err, "OUTPUT_EXISTS") + body, err := os.ReadFile(fixture.out) + if err != nil || string(body) != "keep\n" { + t.Fatalf("existing output changed: %q, %v", body, err) + } + }) + + t.Run("output inside checkout", func(t *testing.T) { + fixture := newCorrectionFixture(t, 1) + fixture.out = filepath.Join(fixture.repo.Root, "receipt.json") + expectFixtureFailure(t, fixture, "finalized", "OUTPUT_UNSAFE") + }) + + t.Run("output inside verifier checkout", func(t *testing.T) { + fixture := newCorrectionFixture(t, 1) + fixture.out = filepath.Join(fixture.verifier.Root, "receipt.json") + expectFixtureFailure(t, fixture, "finalized", "OUTPUT_UNSAFE") + }) + + if runtime.GOOS != "windows" { + t.Run("open receipt directory", func(t *testing.T) { + fixture := newCorrectionFixture(t, 1) + if err := os.Chmod(fixture.privateDirectory, 0o755); err != nil { + t.Fatal(err) + } + expectFixtureFailure(t, fixture, "finalized", "OUTPUT_UNSAFE") + }) + } +} + +func TestRunRejectsTimeoutAndIncompleteDependencies(t *testing.T) { + fixture := newCorrectionFixture(t, 1) + fixture.hosted.timeoutCommand = "gitleaks" + expectFixtureFailure(t, fixture, "finalized", "COMMAND_TIMEOUT") + if err := runWithDependencies(context.Background(), nil, io.Discard, dependencies{}); errorCode(err) != "DEPENDENCIES_INCOMPLETE" { + t.Fatalf("incomplete dependencies error = %v (%s)", err, errorCode(err)) + } +} + +func TestReceiptEncodingIsDeterministicAndRedacted(t *testing.T) { + first := newCorrectionFixture(t, 2) + runFixture(t, first, "finalized") + firstBody, err := os.ReadFile(first.out) + if err != nil { + t.Fatal(err) + } + // Commit object IDs differ across fixture repositories. Compare two encodings + // of the same validated receipt instead of two repository histories. + var value receipt + if err := json.Unmarshal(firstBody, &value); err != nil { + t.Fatal(err) + } + bodyOne, digestOne, err := encodeReceipt(value) + if err != nil { + t.Fatal(err) + } + bodyTwo, digestTwo, err := encodeReceipt(value) + if err != nil { + t.Fatal(err) + } + if string(bodyOne) != string(bodyTwo) || digestOne != digestTwo { + t.Fatal("receipt encoding or digest is not deterministic") + } +} + +func TestReceiptSchemaMatchesGeneratedTopLevelContract(t *testing.T) { + fixture := newCorrectionFixture(t, 1) + runFixture(t, fixture, "finalized") + var document map[string]any + readJSONFile(t, fixture.out, &document) + var schema struct { + Schema string `json:"$schema"` + AdditionalProperties bool `json:"additionalProperties"` + Required []string `json:"required"` + Properties map[string]any `json:"properties"` + } + readJSONFile(t, filepath.Join("..", "..", "docs", "PUBLICATION-CORRECTION.schema.json"), &schema) + if schema.Schema != "https://json-schema.org/draft/2020-12/schema" || schema.AdditionalProperties { + t.Fatalf("schema identity = %#v", schema) + } + required := map[string]bool{} + for _, name := range schema.Required { + if required[name] { + t.Fatalf("duplicate required property %q", name) + } + required[name] = true + } + if len(required) != len(document) || len(schema.Properties) != len(document) { + t.Fatalf("schema keys = required %d, properties %d, receipt %d", len(required), len(schema.Properties), len(document)) + } + for name := range document { + if !required[name] || schema.Properties[name] == nil { + t.Errorf("generated property %q is not required by the schema", name) + } + } +} + +func TestExternalCommandWithTimeout(t *testing.T) { + t.Setenv("GO_WANT_CORRECTION_HELPER", "1") + arguments := func(mode string) []string { + return []string{"-test.run=^TestCorrectionExternalHelperProcess$", "--", mode} + } + stdout, stderr, code, err := externalCommandWithTimeout(context.Background(), 2*time.Second, t.TempDir(), os.Args[0], arguments("success")...) + if err != nil || code != 0 || stdout != "stdout" || stderr != "stderr" { + t.Fatalf("success = stdout %q, stderr %q, code %d, err %v", stdout, stderr, code, err) + } + _, _, code, err = externalCommandWithTimeout(context.Background(), 2*time.Second, t.TempDir(), os.Args[0], arguments("failure")...) + if err != nil || code != 7 { + t.Fatalf("failure = code %d, err %v", code, err) + } + _, _, code, err = externalCommandWithTimeout(context.Background(), 50*time.Millisecond, t.TempDir(), os.Args[0], arguments("timeout")...) + if err == nil || code != -1 || !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("timeout = code %d, err %v", code, err) + } +} + +func TestCorrectionExternalHelperProcess(t *testing.T) { + if os.Getenv("GO_WANT_CORRECTION_HELPER") != "1" { + return + } + switch os.Args[len(os.Args)-1] { + case "success": + _, _ = fmt.Fprint(os.Stdout, "stdout") + _, _ = fmt.Fprint(os.Stderr, "stderr") + os.Exit(0) + case "failure": + os.Exit(7) + case "timeout": + time.Sleep(10 * time.Second) + os.Exit(0) + default: + os.Exit(8) + } +} + +func newCorrectionFixture(t *testing.T, correctionCount int) *correctionFixture { + t.Helper() + if correctionCount < 1 { + t.Fatal("correctionCount must be positive") + } + directory := t.TempDir() + correctionGit(t, directory, "init", "-b", "main") + correctionGit(t, directory, "config", "user.name", testPublicName) + correctionGit(t, directory, "config", "user.email", testPublicEmail) + writeCorrectionFile(t, directory, "README.md", "base\n") + writeCorrectionFile(t, directory, "go.mod", "module github.com/"+testTarget+"\n\ngo 1.25.12\n") + correctionGit(t, directory, "add", "README.md", "go.mod") + correctionGit(t, directory, "commit", "-m", "test: create bootstrap") + bootstrap := correctionGit(t, directory, "rev-parse", "HEAD") + + paths := []string{"core.txt", "docs/paths.txt"} + for index := 1; index <= correctionCount; index++ { + paths = append(paths, fmt.Sprintf("fix-%d.txt", index)) + } + writeCorrectionFile(t, directory, "core.txt", "old candidate\n") + writeCorrectionFile(t, directory, "docs/paths.txt", strings.Join(paths, "\n")+"\n") + correctionGit(t, directory, "add", "core.txt", "docs/paths.txt") + correctionGit(t, directory, "commit", "-m", "test: create old candidate") + oldCandidate := correctionGit(t, directory, "rev-parse", "HEAD") + + var corrections []string + for index := 1; index <= correctionCount; index++ { + name := fmt.Sprintf("fix-%d.txt", index) + writeCorrectionFile(t, directory, name, fmt.Sprintf("correction %d\n", index)) + correctionGit(t, directory, "add", name) + correctionGit(t, directory, "commit", "-m", fmt.Sprintf("fix: correction %d", index)) + corrections = append(corrections, correctionGit(t, directory, "rev-parse", "HEAD")) + } + candidate := corrections[len(corrections)-1] + repo, err := gitx.Discover(context.Background(), directory) + if err != nil { + t.Fatal(err) + } + verifier := newVerifierFixture(t) + privateDirectory := t.TempDir() + if err := os.Chmod(privateDirectory, 0o700); err != nil { + t.Fatal(err) + } + firstReceipt := filepath.Join(privateDirectory, "first.json") + first := map[string]any{ + "schema_version": "1.0.0", + "target_repository": testTarget, + "target_visibility": "public", + "default_branch": "main", + "bootstrap": map[string]any{ + "commit": bootstrap, + "tree": correctionGit(t, directory, "show", "-s", "--format=%T", bootstrap), + }, + "candidate": map[string]any{ + "commit": oldCandidate, + "tree": correctionGit(t, directory, "show", "-s", "--format=%T", oldCandidate), + }, + "history_commit_count": 2, + "delta_paths": []string{"core.txt", "docs/paths.txt"}, + "local": map[string]any{ + "clean": true, "history_secret_scan": "passed", "worktree_secret_scan": "passed", + "object_integrity_check": "passed", "expected_delta_matched": true, + "bootstrap_is_ancestor": true, "linear_candidate_range": true, + }, + "github": map[string]any{ + "authenticated_owner": true, "target_repository_exists": false, "public_author_identity_match": true, + }, + } + writeJSONFile(t, firstReceipt, first, 0o600) + hosted := newHostedFixture(bootstrap, candidate) + return &correctionFixture{ + repo: repo, + verifier: verifier, + bootstrap: bootstrap, + old: oldCandidate, + candidate: candidate, + correctionCommits: corrections, + pathsFile: "docs/paths.txt", + firstReceipt: firstReceipt, + out: filepath.Join(privateDirectory, "correction.json"), + privateDirectory: privateDirectory, + hosted: hosted, + } +} + +func newVerifierFixture(t *testing.T) gitx.Repo { + t.Helper() + directory := t.TempDir() + correctionGit(t, directory, "init", "-b", "main") + correctionGit(t, directory, "config", "user.name", testPublicName) + correctionGit(t, directory, "config", "user.email", testPublicEmail) + writeCorrectionFile(t, directory, "go.mod", "module github.com/"+testTarget+"\n\ngo 1.25.12\n") + writeCorrectionFile(t, directory, verifierCommandPath, "package main\n") + writeCorrectionFile(t, directory, verifierSchemaPath, "{}\n") + correctionGit(t, directory, "add", "go.mod", verifierCommandPath, verifierSchemaPath) + correctionGit(t, directory, "commit", "-m", "test: create verifier") + repo, err := gitx.Discover(context.Background(), directory) + if err != nil { + t.Fatal(err) + } + return repo +} + +func newHostedFixture(bootstrap, candidate string) *hostedFixture { + return &hostedFixture{ + bootstrap: bootstrap, + candidate: candidate, + candidateRef: testCandidateRef, + mainCommit: candidate, + publicName: testPublicName, + publicEmail: testPublicEmail, + repositoryVisibility: "public", + remoteCandidate: candidate, + pullState: "closed", + pullHead: candidate, + rulesetEnforcement: "active", + rulesetStrict: true, + rulesetDeletion: true, + rulesetNonFastForward: true, + rulesetLinearHistory: true, + rulesetPullRequest: true, + rulesetReviewCount: 1, + rulesetDismissStale: true, + rulesetLastPushApproval: true, + rulesetReviewThreads: true, + rulesetAllowedMergeMethods: []string{"rebase"}, + checkIntegration: testIntegration, + checkConclusion: "success", + runs: map[int64]hostedRunFixture{ + testCandidateRun: {ID: testCandidateRun, Event: "push", HeadBranch: testCandidateRef, HeadSHA: candidate, Status: "completed", Conclusion: "success", Attempt: 1, Name: "ci"}, + testPullRun: {ID: testPullRun, Event: "pull_request", HeadBranch: testCandidateRef, HeadSHA: candidate, Status: "completed", Conclusion: "success", Attempt: 1, Name: "ci"}, + testMainRun: {ID: testMainRun, Event: "push", HeadBranch: "main", HeadSHA: candidate, Status: "completed", Conclusion: "success", Attempt: 1, Name: "ci"}, + }, + } +} + +func (fixture *correctionFixture) updateCandidate(candidate string) { + fixture.candidate = candidate + fixture.hosted.candidate = candidate + fixture.hosted.mainCommit = candidate + fixture.hosted.remoteCandidate = candidate + fixture.hosted.pullHead = candidate + for id, run := range fixture.hosted.runs { + run.HeadSHA = candidate + fixture.hosted.runs[id] = run + } +} + +func (fixture *correctionFixture) arguments(phase string) []string { + arguments := []string{ + "--repo-dir", fixture.repo.Root, + "--verifier-dir", fixture.verifier.Root, + "--target", testTarget, + "--phase", phase, + "--bootstrap", fixture.bootstrap, + "--old-candidate", fixture.old, + "--candidate", fixture.candidate, + "--candidate-ref", testCandidateRef, + "--first-receipt", fixture.firstReceipt, + "--paths-file", fixture.pathsFile, + "--pull-request", "1", + "--ruleset", strconv.FormatInt(testRulesetID, 10), + "--out", fixture.out, + "--run", fmt.Sprintf("candidate-push:push:%s:%d", testCandidateRef, testCandidateRun), + "--run", fmt.Sprintf("verification-pr:pull_request:%s:%d", testCandidateRef, testPullRun), + } + if phase == "finalized" { + arguments = append(arguments, "--run", fmt.Sprintf("main-push:push:main:%d", testMainRun)) + } + return arguments +} + +func (fixture *correctionFixture) dependencies() dependencies { + return dependencies{ + external: fixture.hosted.run, + now: func() time.Time { return time.Date(2026, time.August, 17, 12, 0, 0, 123456789, time.UTC) }, + } +} + +func (fixture *hostedFixture) run(_ context.Context, _ time.Duration, _ string, name string, arguments ...string) (string, string, int, error) { + if fixture.timeoutCommand == name { + return "", "", -1, context.DeadlineExceeded + } + if name == "gitleaks" { + if fixture.secretFailure { + return "raw secret finding", "", 1, nil + } + return "", "", 0, nil + } + if name == "gh" && len(arguments) == 4 && arguments[0] == "api" && arguments[1] == "graphql" && arguments[2] == "-f" { + body, err := json.Marshal(map[string]any{"data": map[string]any{"viewer": map[string]any{"login": testOwner}}}) + if err != nil { + return "", "", -1, err + } + return string(body), "", 0, nil + } + if name != "gh" || len(arguments) != 2 || arguments[0] != "api" { + return "", "", -1, fmt.Errorf("unexpected external command %q %q", name, arguments) + } + endpoint := arguments[1] + var value any + switch endpoint { + case "users/" + testOwner: + value = map[string]any{"login": testOwner, "name": fixture.publicName, "email": fixture.publicEmail} + case "repos/" + testTarget: + value = map[string]any{ + "full_name": testTarget, "visibility": fixture.repositoryVisibility, "private": fixture.repositoryPrivate, + "default_branch": "main", "owner": map[string]any{"login": testOwner}, + } + case "repos/" + testTarget + "/git/ref/heads/" + fixture.candidateRef: + value = map[string]any{"ref": "refs/heads/" + fixture.candidateRef, "object": map[string]any{"sha": fixture.remoteCandidate, "type": "commit"}} + case "repos/" + testTarget + "/git/ref/heads/main": + value = map[string]any{"ref": "refs/heads/main", "object": map[string]any{"sha": fixture.mainCommit, "type": "commit"}} + case "repos/" + testTarget + "/pulls/1": + value = map[string]any{ + "number": 1, "state": fixture.pullState, "merged": fixture.pullMerged, "merged_at": fixture.pullMergedAt, + "merge_commit_sha": fixture.candidate, + "head": map[string]any{"ref": fixture.candidateRef, "sha": fixture.pullHead}, + "base": map[string]any{"ref": "main", "sha": fixture.bootstrap}, + } + case fmt.Sprintf("repos/%s/rulesets/%d", testTarget, testRulesetID): + var bypassActors any + if fixture.rulesetBypass { + bypassActors = []any{map[string]any{"actor_id": 1, "actor_type": "RepositoryRole", "bypass_mode": "always"}} + } + var rules []any + if fixture.rulesetDeletion { + rules = append(rules, map[string]any{"type": "deletion"}) + } + if fixture.rulesetNonFastForward { + rules = append(rules, map[string]any{"type": "non_fast_forward"}) + } + if fixture.rulesetLinearHistory { + rules = append(rules, map[string]any{"type": "required_linear_history"}) + } + if fixture.rulesetPullRequest { + rules = append(rules, map[string]any{"type": "pull_request", "parameters": map[string]any{ + "required_approving_review_count": fixture.rulesetReviewCount, + "dismiss_stale_reviews_on_push": fixture.rulesetDismissStale, + "require_last_push_approval": fixture.rulesetLastPushApproval, + "required_review_thread_resolution": fixture.rulesetReviewThreads, + "allowed_merge_methods": fixture.rulesetAllowedMergeMethods, + }}) + } + rules = append(rules, map[string]any{"type": "required_status_checks", "parameters": map[string]any{ + "strict_required_status_checks_policy": fixture.rulesetStrict, + "do_not_enforce_on_create": fixture.rulesetDoNotEnforceOnCreate, + "required_status_checks": []any{map[string]any{"context": "test", "integration_id": testIntegration}}, + }}) + value = map[string]any{ + "id": testRulesetID, "name": "Protect main", "target": "branch", "enforcement": fixture.rulesetEnforcement, + "bypass_actors": bypassActors, + "conditions": map[string]any{"ref_name": map[string]any{"include": []string{"~DEFAULT_BRANCH"}, "exclude": []string{}}}, + "rules": rules, + } + case fmt.Sprintf("repos/%s/actions/runs/%d/jobs?filter=latest&per_page=100", testTarget, testPullRun): + value = map[string]any{"jobs": []any{map[string]any{"id": testCheckRun, "name": "test", "status": "completed", "conclusion": "success"}}} + case fmt.Sprintf("repos/%s/check-runs/%d", testTarget, testCheckRun): + value = map[string]any{"id": testCheckRun, "name": "test", "status": "completed", "conclusion": fixture.checkConclusion, "app": map[string]any{"id": fixture.checkIntegration}} + default: + prefix := "repos/" + testTarget + "/actions/runs/" + if strings.HasPrefix(endpoint, prefix) { + id, err := strconv.ParseInt(strings.TrimPrefix(endpoint, prefix), 10, 64) + run, ok := fixture.runs[id] + if err != nil || !ok { + return "", "not found", 1, nil + } + value = map[string]any{ + "id": run.ID, "event": run.Event, "head_branch": run.HeadBranch, "head_sha": run.HeadSHA, + "status": run.Status, "conclusion": run.Conclusion, "run_attempt": run.Attempt, "name": run.Name, + } + } else { + return "", "", -1, fmt.Errorf("unexpected GitHub endpoint %q", endpoint) + } + } + body, err := json.Marshal(value) + if err != nil { + return "", "", -1, err + } + return string(body), "", 0, nil +} + +func runFixture(t *testing.T, fixture *correctionFixture, phase string) string { + t.Helper() + var stdout strings.Builder + if err := runWithDependencies(context.Background(), fixture.arguments(phase), &stdout, fixture.dependencies()); err != nil { + t.Fatalf("run: %s: %v", errorCode(err), err) + } + return stdout.String() +} + +func expectFixtureFailure(t *testing.T, fixture *correctionFixture, phase, code string) { + t.Helper() + err := runWithDependencies(context.Background(), fixture.arguments(phase), io.Discard, fixture.dependencies()) + assertTypedError(t, err, code) + assertNoFile(t, fixture.out) +} + +func assertTypedError(t *testing.T, err error, code string) { + t.Helper() + if err == nil || errorCode(err) != code { + t.Fatalf("error = %v (%s), want %s", err, errorCode(err), code) + } +} + +func assertNoFile(t *testing.T, name string) { + t.Helper() + if _, err := os.Lstat(name); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("unexpected output %s: %v", name, err) + } +} + +func replaceFlag(arguments []string, name, value string) { + for index := 0; index+1 < len(arguments); index++ { + if arguments[index] == name { + arguments[index+1] = value + return + } + } +} + +func writeCorrectionFile(t *testing.T, root, name, body string) { + t.Helper() + path := filepath.Join(root, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } +} + +func writeJSONFile(t *testing.T, name string, value any, mode os.FileMode) { + t.Helper() + body, err := json.MarshalIndent(value, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(name, append(body, '\n'), mode); err != nil { + t.Fatal(err) + } +} + +func readJSONFile(t *testing.T, name string, target any) { + t.Helper() + body, err := os.ReadFile(name) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(body, target); err != nil { + t.Fatal(err) + } +} + +func correctionGit(t *testing.T, directory string, arguments ...string) string { + t.Helper() + command := exec.Command("git", arguments...) + command.Dir = directory + command.Env = gitx.Environment(nil) + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v\n%s", arguments, err, output) + } + return strings.TrimSpace(string(output)) +}