diff --git a/crates/commandf-pkg/tests/af02_authority_reconstruction.rs b/crates/commandf-pkg/tests/af02_authority_reconstruction.rs new file mode 100644 index 00000000..2bb628ec --- /dev/null +++ b/crates/commandf-pkg/tests/af02_authority_reconstruction.rs @@ -0,0 +1,605 @@ +#[path = "../../../tools/af02-verifier/src/authority.rs"] +mod authority; +#[path = "../../../tools/af02-verifier/src/canonical.rs"] +mod canonical; +#[path = "../../../tools/af02-verifier/src/retained.rs"] +mod retained; + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::{Mutex, OnceLock}; + +use authority::{project_assurance_ruleset, project_authority, project_cf06, Cf06Source}; +use canonical::{canonical_json_bytes, git_blob_sha1_hex, parse_json_no_duplicates}; +use retained::{ + locator_plan, project_retained, validate_and_parse, verify_artifacts, verify_workflow_run, +}; +use serde_json::{json, Value}; + +const MAIN_SHA: &str = "54b9772a3b86464da6f395f8ba8371f364c9bb38"; +const MAIN_TREE: &str = "4ac26d8de419a0bec0faba8e14ded1763cfe30b3"; +const RETAINED_HEAD: &str = "5fe10d9859407272acf6649fc3e868d3eb2fbd12"; + +const RETAINED_SOURCES_PATH: &str = + "specs/016-af-02-adversarial-test-strength/retained-authority-sources.json"; +const RETAINED_SOURCES_BLOB: &str = "f9c0bc16ac742238c93ff77a85486cd1db5dbcf3"; +const RETAINED_SCHEMA_PATH: &str = "specs/016-af-02-adversarial-test-strength/schemas/af02-retained-authority-sources-v1.schema.json"; +const RETAINED_SCHEMA_BLOB: &str = "7d0daced343fd15d797cc0d4d53e9d63aac790c5"; + +const ORACLE_MODEL_PATH: &str = "crates/commandf-pkg/src/oracle_model.rs"; +const ORACLE_MODEL_BLOB: &str = "9046546a86061961cf3e17f3f1880165625edea8"; +const CF06_DONOR_PATH: &str = "donors/hl7-fhir-validator-6.10.2.yaml"; +const CF06_DONOR_BLOB: &str = "9add2dad45cb8958c9304d38e29950ed1f769990"; +const CF06_WORKFLOW_PATH: &str = ".github/workflows/cf06-oracle.yml"; +const CF06_WORKFLOW_BLOB: &str = "664e303983d2ef85aad934cbef2c14d63744e0ee"; + +const AF01_CLOSEOUT_PATH: &str = "specs/015-af-01-trusted-development-baseline/closeout.md"; +const AF01_CLOSEOUT_BLOB: &str = "ac01a88ff7c1a4f4771dd16c5a61afe6e2566ce6"; + +const ASSURANCE_RULESET: &[u8] = + include_bytes!("../../../tools/af02-verifier/tests/fixtures/assurance-ruleset.json"); +const REVIEW_RULESET: &[u8] = + include_bytes!("../../../tools/af02-verifier/tests/fixtures/review-ruleset.json"); +const RETAINED_RUN: &[u8] = + include_bytes!("../../../tools/af02-verifier/tests/fixtures/cf10-run.json"); +const RETAINED_ARTIFACTS: &[u8] = + include_bytes!("../../../tools/af02-verifier/tests/fixtures/cf10-artifacts.json"); + +static GIT_FETCH_LOCK: OnceLock> = OnceLock::new(); + +fn repository_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +fn pinned_commit_available(root: &Path, revision: &str) -> bool { + let commit = format!("{revision}^{{commit}}"); + Command::new("git") + .arg("-C") + .arg(root) + .args(["cat-file", "-e", &commit]) + .status() + .expect("run git cat-file commit probe") + .success() +} + +fn ensure_pinned_commit_available(root: &Path, revision: &str) { + if pinned_commit_available(root, revision) { + return; + } + + let fetch_lock = GIT_FETCH_LOCK.get_or_init(|| Mutex::new(())); + let _guard = fetch_lock.lock().expect("Git fetch mutex poisoned"); + if pinned_commit_available(root, revision) { + return; + } + + let remote = Command::new("git") + .arg("-C") + .arg(root) + .args(["remote", "get-url", "origin"]) + .output() + .expect("read Git origin URL"); + assert!( + remote.status.success(), + "git remote get-url origin failed: {}", + String::from_utf8_lossy(&remote.stderr) + ); + let origin = String::from_utf8(remote.stdout) + .expect("Git origin URL UTF-8") + .trim() + .trim_end_matches('/') + .trim_end_matches(".git") + .to_owned(); + assert!( + origin == "https://github.com/TheHalfMoon/commandF" + || origin == "git@github.com:TheHalfMoon/commandF", + "refusing to fetch authority objects from non-canonical origin {origin}" + ); + + let fetched = Command::new("git") + .arg("-C") + .arg(root) + .args([ + "fetch", + "--no-tags", + "--no-write-fetch-head", + "--depth=1", + "origin", + revision, + ]) + .output() + .expect("fetch pinned authority commit"); + assert!( + fetched.status.success(), + "git fetch failed for pinned authority commit {revision}: {}", + String::from_utf8_lossy(&fetched.stderr) + ); + + let commit = format!("{revision}^{{commit}}"); + let resolved = Command::new("git") + .arg("-C") + .arg(root) + .args(["rev-parse", "--verify", &commit]) + .output() + .expect("verify fetched authority commit"); + assert!( + resolved.status.success(), + "git rev-parse failed for fetched authority commit {revision}: {}", + String::from_utf8_lossy(&resolved.stderr) + ); + assert_eq!( + String::from_utf8(resolved.stdout) + .expect("fetched authority commit UTF-8") + .trim(), + revision, + "fetched authority commit identity drifted" + ); +} + +fn github_content_object_bytes(revision: &str, path: &str, expected_blob: &str) -> Vec { + assert!( + revision.len() == 40 + && revision + .bytes() + .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f')), + "refusing non-immutable GitHub revision {revision}" + ); + assert!( + expected_blob.len() == 40 + && expected_blob + .bytes() + .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f')), + "invalid expected Git blob identity {expected_blob}" + ); + assert!( + !path.starts_with('/') + && !path + .split('/') + .any(|segment| segment.is_empty() || segment == "." || segment == "..") + && path.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b'.' | b'_' | b'-') + }), + "refusing unsafe GitHub authority path {path}" + ); + + let url = + format!("https://api.github.com/repos/TheHalfMoon/commandF/contents/{path}?ref={revision}"); + let response = Command::new("curl") + .args([ + "--fail", + "--silent", + "--show-error", + "--proto", + "=https", + "--tlsv1.2", + "--connect-timeout", + "10", + "--max-time", + "30", + "--header", + "Accept: application/vnd.github.raw+json", + "--header", + "X-GitHub-Api-Version: 2022-11-28", + "--header", + "User-Agent: commandF-af02-authority-reconstruction", + &url, + ]) + .output() + .expect("fetch immutable GitHub authority object with curl"); + assert!( + response.status.success(), + "immutable GitHub authority request failed for {revision}:{path}: {}", + String::from_utf8_lossy(&response.stderr) + ); + assert_eq!( + git_blob_sha1_hex(&response.stdout), + expected_blob, + "immutable GitHub authority bytes do not reproduce expected blob identity for {revision}:{path}" + ); + response.stdout +} + +fn git_object_bytes(revision: &str, path: &str, expected_blob: &str) -> Vec { + let root = repository_root(); + ensure_pinned_commit_available(&root, revision); + let spec = format!("{revision}:{path}"); + let resolved = Command::new("git") + .arg("-C") + .arg(&root) + .args(["rev-parse", &spec]) + .output() + .expect("run git rev-parse"); + if !resolved.status.success() { + return github_content_object_bytes(revision, path, expected_blob); + } + let observed_blob = String::from_utf8(resolved.stdout) + .expect("git rev-parse UTF-8") + .trim() + .to_owned(); + assert_eq!( + observed_blob, expected_blob, + "canonical Git object identity drifted for {spec}" + ); + + let object = Command::new("git") + .arg("-C") + .arg(&root) + .args(["cat-file", "blob", expected_blob]) + .output() + .expect("run git cat-file"); + assert!( + object.status.success(), + "git cat-file failed for {expected_blob}: {}", + String::from_utf8_lossy(&object.stderr) + ); + assert_eq!( + git_blob_sha1_hex(&object.stdout), + expected_blob, + "Git object bytes do not reproduce expected blob identity" + ); + object.stdout +} + +fn canonical_retained_contract() -> (Vec, Vec) { + ( + git_object_bytes(MAIN_SHA, RETAINED_SOURCES_PATH, RETAINED_SOURCES_BLOB), + git_object_bytes(MAIN_SHA, RETAINED_SCHEMA_PATH, RETAINED_SCHEMA_BLOB), + ) +} + +fn canonical_cf06_sources() -> (Vec, Vec, Vec) { + ( + git_object_bytes(MAIN_SHA, ORACLE_MODEL_PATH, ORACLE_MODEL_BLOB), + git_object_bytes(MAIN_SHA, CF06_DONOR_PATH, CF06_DONOR_BLOB), + git_object_bytes(MAIN_SHA, CF06_WORKFLOW_PATH, CF06_WORKFLOW_BLOB), + ) +} + +fn canonical_ruleset_view(url: &str, ruleset_id: u64) -> Value { + let response = github_api_bytes(url); + let mut value = parse_json_no_duplicates(&response).unwrap(); + let bypass_is_redacted = matches!(value.get("bypass_actors"), None | Some(Value::Null)); + if !bypass_is_redacted { + return value; + } + + // GitHub intentionally withholds bypass_actors from callers without write + // access to the ruleset. Recover only that redacted field from AF-01's + // owner-authorized canonical closeout; every non-privileged field remains + // live API authority. The closeout itself is bound to MAIN_SHA and an exact + // Git blob identity, so candidate-controlled fixtures cannot supply it. + let closeout = git_object_bytes(MAIN_SHA, AF01_CLOSEOUT_PATH, AF01_CLOSEOUT_BLOB); + let closeout = std::str::from_utf8(&closeout).expect("AF-01 closeout must be UTF-8"); + let bypass = match ruleset_id { + authority::ASSURANCE_RULESET_ID => { + let owner_evidence = "21652953 commandF main assurance\n enforcement: active\n bypass actors: none\n current user bypass: never"; + assert!( + closeout.contains(owner_evidence), + "canonical AF-01 closeout no longer proves assurance bypass authority" + ); + json!([]) + } + authority::REVIEW_RULESET_ID => { + let owner_evidence = "21652974 commandF main review governance\n enforcement: active\n merge method: merge\n approvals: 1\n code-owner review: required\n latest-push approval: required\n stale approvals: dismissed\n review-thread resolution: required\n bypass: RepositoryRole actor 5, pull_request only\n current user bypass: pull_requests_only"; + assert!( + closeout.contains(owner_evidence), + "canonical AF-01 closeout no longer proves review bypass authority" + ); + json!([{ + "actor_id": 5, + "actor_type": "RepositoryRole", + "bypass_mode": "pull_request" + }]) + } + other => panic!("unexpected AF-01 ruleset id {other}"), + }; + + value + .as_object_mut() + .expect("ruleset API response must be an object") + .insert("bypass_actors".to_owned(), bypass); + value +} + +fn github_api_bytes(url: &str) -> Vec { + const CANONICAL_API_PREFIX: &str = "https://api.github.com/repos/TheHalfMoon/commandF/"; + assert!( + url.starts_with(CANONICAL_API_PREFIX), + "refusing non-canonical GitHub authority URL {url}" + ); + + let response = Command::new("curl") + .args([ + "--fail", + "--silent", + "--show-error", + "--proto", + "=https", + "--tlsv1.2", + "--connect-timeout", + "10", + "--max-time", + "30", + "--header", + "Accept: application/vnd.github+json", + "--header", + "X-GitHub-Api-Version: 2022-11-28", + "--header", + "User-Agent: commandF-af02-authority-reconstruction", + url, + ]) + .output() + .expect("fetch live GitHub authority response with curl"); + assert!( + response.status.success(), + "GitHub authority request failed for {url}: {}", + String::from_utf8_lossy(&response.stderr) + ); + response.stdout +} + +fn build_baseline() -> authority::AuthorityBaseline { + let (retained_sources, retained_schema) = canonical_retained_contract(); + let retained = validate_and_parse(&retained_sources, &retained_schema).unwrap(); + assert_eq!(retained.cf10.retained_head, RETAINED_HEAD); + + let plan = locator_plan(&retained).unwrap(); + assert_eq!( + plan.workflow_run, + "https://api.github.com/repos/TheHalfMoon/commandF/actions/runs/31916124080" + ); + assert_eq!( + plan.workflow_run_artifacts, + "https://api.github.com/repos/TheHalfMoon/commandF/actions/runs/31916124080/artifacts" + ); + let run_bytes = github_api_bytes(&plan.workflow_run); + let run = parse_json_no_duplicates(&run_bytes).unwrap(); + verify_workflow_run(&retained, &run).unwrap(); + let artifact_bytes = github_api_bytes(&plan.workflow_run_artifacts); + let artifacts = parse_json_no_duplicates(&artifact_bytes).unwrap(); + verify_artifacts(&retained, &artifacts).unwrap(); + + let retained_manifest = git_object_bytes( + RETAINED_HEAD, + &retained.cf10.manifest.path, + &retained.cf10.manifest.git_blob_sha, + ); + let retained_donor = git_object_bytes( + RETAINED_HEAD, + &retained.cf10.donor.path, + &retained.cf10.donor.git_blob_sha, + ); + let retained_projection = + project_retained(&retained, &retained_manifest, &retained_donor).unwrap(); + + assert_eq!(authority::ASSURANCE_RULESET_ID, 21652953); + assert_eq!(authority::REVIEW_RULESET_ID, 21652974); + let assurance = canonical_ruleset_view( + "https://api.github.com/repos/TheHalfMoon/commandF/rulesets/21652953", + authority::ASSURANCE_RULESET_ID, + ); + let review = canonical_ruleset_view( + "https://api.github.com/repos/TheHalfMoon/commandF/rulesets/21652974", + authority::REVIEW_RULESET_ID, + ); + let (oracle_model, cf06_donor, cf06_workflow) = canonical_cf06_sources(); + + project_authority( + MAIN_SHA, + MAIN_TREE, + &assurance, + &review, + [ + Cf06Source { + path: ORACLE_MODEL_PATH, + git_blob_sha: ORACLE_MODEL_BLOB, + bytes: &oracle_model, + }, + Cf06Source { + path: CF06_DONOR_PATH, + git_blob_sha: CF06_DONOR_BLOB, + bytes: &cf06_donor, + }, + Cf06Source { + path: CF06_WORKFLOW_PATH, + git_blob_sha: CF06_WORKFLOW_BLOB, + bytes: &cf06_workflow, + }, + ], + retained_projection, + ) + .unwrap() +} + +fn duplicate_probe(bytes: &[u8]) -> Vec { + assert_eq!(bytes.first(), Some(&b'{')); + let mut duplicate = br#"{"__duplicate_probe":0,"__duplicate_probe":1,"#.to_vec(); + duplicate.extend_from_slice(&bytes[1..]); + duplicate +} + +#[test] +fn retained_schema_rejects_candidate_url_authority() { + let (retained_sources, retained_schema) = canonical_retained_contract(); + let mut value: Value = parse_json_no_duplicates(&retained_sources).unwrap(); + value.as_object_mut().unwrap().insert( + "url".to_owned(), + Value::String("https://example.invalid".to_owned()), + ); + let bytes = serde_json::to_vec(&value).unwrap(); + let error = validate_and_parse(&bytes, &retained_schema).unwrap_err(); + assert!(error.to_string().contains("unknown field")); +} + +#[test] +fn retained_contract_rejects_duplicate_semantic_keys_before_schema_validation() { + let (retained_sources, retained_schema) = canonical_retained_contract(); + let mut duplicate = br#"{"schema":"forged","#.to_vec(); + duplicate.extend_from_slice(&retained_sources[1..]); + let error = validate_and_parse(&duplicate, &retained_schema).unwrap_err(); + assert!(error + .to_string() + .contains("duplicate JSON object key \"schema\"")); +} + +#[test] +fn authority_api_inputs_reject_duplicate_keys_before_projection() { + for bytes in [ + ASSURANCE_RULESET, + REVIEW_RULESET, + RETAINED_RUN, + RETAINED_ARTIFACTS, + ] { + let error = parse_json_no_duplicates(&duplicate_probe(bytes)).unwrap_err(); + assert!(error + .to_string() + .contains("duplicate JSON object key \"__duplicate_probe\"")); + } +} + +#[test] +fn retained_locator_plan_reconstructs_frozen_github_urls() { + let (retained_sources, retained_schema) = canonical_retained_contract(); + let retained = validate_and_parse(&retained_sources, &retained_schema).unwrap(); + let plan = locator_plan(&retained).unwrap(); + + assert_eq!( + plan.pull_request, + "https://api.github.com/repos/TheHalfMoon/commandF/pulls/11" + ); + assert_eq!( + plan.retained_head_commit, + "https://api.github.com/repos/TheHalfMoon/commandF/commits/5fe10d9859407272acf6649fc3e868d3eb2fbd12" + ); + assert_eq!( + plan.retained_base_commit, + "https://api.github.com/repos/TheHalfMoon/commandF/commits/5cb1a4c3445c0ebd86654cfb467a5e008e801c3e" + ); + assert_eq!( + plan.manifest_contents, + "https://api.github.com/repos/TheHalfMoon/commandF/contents/corpus/real-ig/v1/corpus.json?ref=5fe10d9859407272acf6649fc3e868d3eb2fbd12" + ); + assert_eq!( + plan.manifest_blob, + "https://api.github.com/repos/TheHalfMoon/commandF/git/blobs/655949a8a30d67502dffd624a175d2e8e02b1d1f" + ); + assert_eq!( + plan.donor_contents, + "https://api.github.com/repos/TheHalfMoon/commandF/contents/donors/cf-10-real-ig-delta-corpus.yaml?ref=5fe10d9859407272acf6649fc3e868d3eb2fbd12" + ); + assert_eq!( + plan.donor_blob, + "https://api.github.com/repos/TheHalfMoon/commandF/git/blobs/566b46f4e6f467a1ccae3ac810b31956309173b6" + ); + assert_eq!( + plan.workflow_run, + "https://api.github.com/repos/TheHalfMoon/commandF/actions/runs/31916124080" + ); + assert_eq!( + plan.workflow_run_artifacts, + "https://api.github.com/repos/TheHalfMoon/commandF/actions/runs/31916124080/artifacts" + ); +} + +#[test] +fn retained_run_binding_rejects_wrong_event() { + let (retained_sources, retained_schema) = canonical_retained_contract(); + let retained = validate_and_parse(&retained_sources, &retained_schema).unwrap(); + let mut run = parse_json_no_duplicates(RETAINED_RUN).unwrap(); + run.as_object_mut() + .unwrap() + .insert("event".to_owned(), Value::String("push".to_owned())); + let error = verify_workflow_run(&retained, &run).unwrap_err(); + assert!(error.to_string().contains("event mismatch")); +} + +#[test] +fn retained_artifact_binding_rejects_wrong_digest() { + let (retained_sources, retained_schema) = canonical_retained_contract(); + let retained = validate_and_parse(&retained_sources, &retained_schema).unwrap(); + let mut artifacts = parse_json_no_duplicates(RETAINED_ARTIFACTS).unwrap(); + artifacts["artifacts"][0]["digest"] = Value::String( + "sha256:0000000000000000000000000000000000000000000000000000000000000000".to_owned(), + ); + let error = verify_artifacts(&retained, &artifacts).unwrap_err(); + assert!(error.to_string().contains("artifact digest mismatch")); +} + +#[test] +fn retained_projection_rejects_candidate_controlled_manifest_bytes() { + let (retained_sources, retained_schema) = canonical_retained_contract(); + let retained = validate_and_parse(&retained_sources, &retained_schema).unwrap(); + let mut manifest = git_object_bytes( + RETAINED_HEAD, + &retained.cf10.manifest.path, + &retained.cf10.manifest.git_blob_sha, + ); + let donor = git_object_bytes( + RETAINED_HEAD, + &retained.cf10.donor.path, + &retained.cf10.donor.git_blob_sha, + ); + let index = manifest.iter().position(|byte| *byte == b'C').unwrap(); + manifest[index] = b'X'; + let error = project_retained(&retained, &manifest, &donor).unwrap_err(); + assert!(error + .to_string() + .contains("retained manifest Git blob mismatch")); +} + +#[test] +fn assurance_projection_rejects_wrong_required_check_app() { + let mut assurance = parse_json_no_duplicates(ASSURANCE_RULESET).unwrap(); + assurance["rules"][2]["parameters"]["required_status_checks"][0]["integration_id"] = + Value::from(1); + let error = project_assurance_ruleset(&assurance).unwrap_err(); + assert!(error.to_string().contains("unexpected integration")); +} + +#[test] +fn cf06_projection_rejects_candidate_controlled_source_bytes() { + let (mut oracle_model, cf06_donor, cf06_workflow) = canonical_cf06_sources(); + let altered = oracle_model + .windows(authority::CF06_SOURCE_COMMIT.len()) + .position(|window| window == authority::CF06_SOURCE_COMMIT.as_bytes()) + .unwrap(); + oracle_model[altered] = b'0'; + + let error = project_cf06([ + Cf06Source { + path: ORACLE_MODEL_PATH, + git_blob_sha: ORACLE_MODEL_BLOB, + bytes: &oracle_model, + }, + Cf06Source { + path: CF06_DONOR_PATH, + git_blob_sha: CF06_DONOR_BLOB, + bytes: &cf06_donor, + }, + Cf06Source { + path: CF06_WORKFLOW_PATH, + git_blob_sha: CF06_WORKFLOW_BLOB, + bytes: &cf06_workflow, + }, + ]) + .unwrap_err(); + assert!(error.to_string().contains("Git blob mismatch")); +} + +#[test] +fn authority_baseline_v2_matches_canonical_snapshot() { + let baseline = build_baseline(); + let value = serde_json::to_value(&baseline).unwrap(); + let generated = canonical_json_bytes(&value).unwrap(); + + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../specs/016-af-02-adversarial-test-strength/authority-baseline.json"); + match fs::read(&path) { + Ok(expected) => assert_eq!(generated, expected), + Err(error) => panic!( + "authority baseline snapshot is missing ({error}); AF02_GENERATED_BASELINE={}", + String::from_utf8(generated).unwrap() + ), + } +} diff --git a/specs/016-af-02-adversarial-test-strength/authority-baseline.json b/specs/016-af-02-adversarial-test-strength/authority-baseline.json new file mode 100644 index 00000000..5141ef28 --- /dev/null +++ b/specs/016-af-02-adversarial-test-strength/authority-baseline.json @@ -0,0 +1 @@ +{"af01":{"assurance":{"ruleset_id":21652953,"semantic_sha256":"f0c60f48d728e7dc77128c8babe324a1654151d10808aea3f3a5f9e499b10272"},"review_governance":{"ruleset_id":21652974,"semantic_sha256":"26d9ec9b4342486866627b01c1c50e19846881e8db0efc77b6dea29c4e15bb27"}},"captured_from_main_sha":"54b9772a3b86464da6f395f8ba8371f364c9bb38","captured_from_main_tree":"4ac26d8de419a0bec0faba8e14ded1763cfe30b3","cf06":{"project":"hapifhir/org.hl7.fhir.core","projection_sha256":"236d71b6816978a4f7c9ea587d70301801b91a1d8a038f93ac7940203dc62787","r4_core_context":"hl7.fhir.r4.core@4.0.1","release":"6.10.2","source_commit":"d06577dbc5c62c74a2a8823fbc4830a3024d5b0b","source_files":[{"git_blob_sha":"9046546a86061961cf3e17f3f1880165625edea8","path":"crates/commandf-pkg/src/oracle_model.rs","raw_sha256":"bc2150ca19d41e8b87c1dd447e3329e4f7c4127c716f931eb5fd583f5706bcc2"},{"git_blob_sha":"9add2dad45cb8958c9304d38e29950ed1f769990","path":"donors/hl7-fhir-validator-6.10.2.yaml","raw_sha256":"3f972c5f0aa72dd742afede770ac086c3bbc551a209dfc57a4b60af48ec7a8e2"},{"git_blob_sha":"664e303983d2ef85aad934cbef2c14d63744e0ee","path":".github/workflows/cf06-oracle.yml","raw_sha256":"7c7cc67272dd55e3d55495ff352e1a4e60f1a57e1c4711982790e69db8f5fbf0"}],"validator_cli_jar_sha256":"a3addadfa18dfa23146a0a243b6ede68eaad92157a5407738c468bb3d7e4ccd6"},"cf10":{"deltas":[{"after":"9.0.0","before":"8.0.1","id":"C001","package":"hl7.fhir.us.core"},{"after":"2.0.1","before":"1.1.0","id":"C002","package":"hl7.fhir.uv.ips"},{"after":"4.0.0","before":"3.0.0","id":"C003","package":"hl7.fhir.us.mcode"}],"projection_sha256":"21a33fc90bbac03e222af37ec2131dcd586dcfac700e413a29858ef468673d30","retained_artifact_id":9255732702,"retained_artifact_name":"cf10-real-corpus-evidence","retained_artifact_sha256":"9fdde985bb5abbe53ec2bce2dadc5f65c95557f8848c9af68755fc81a45af612","retained_base":"5cb1a4c3445c0ebd86654cfb467a5e008e801c3e","retained_donor_blob_sha":"566b46f4e6f467a1ccae3ac810b31956309173b6","retained_donor_sha256":"ee9117b7b18b4ceccf128df4033a8d5ea6cfd0c5efab2276f3c49ee9befc28e9","retained_head":"5fe10d9859407272acf6649fc3e868d3eb2fbd12","retained_manifest_blob_sha":"655949a8a30d67502dffd624a175d2e8e02b1d1f","retained_manifest_sha256":"2d4c4d1eaff31c1647d9bbdd222006e796b0fec690c5ea42c45646f9acc0d255","retained_pr":11,"retained_run":31916124080,"retained_run_conclusion":"failure","states":[{"archive_bytes":2749959,"archive_sha256":"d7b54d2ec2a48cea94ffea5d939ad67a681f80b94d69594a08cebac36da9e059","case_id":"C001","package":"hl7.fhir.us.core","side":"after","state_id":"C001-after","version":"9.0.0"},{"archive_bytes":2713046,"archive_sha256":"3c02eef48ef10617021bee95e58cbc66d596ceda8cada24b72000d33ad67c464","case_id":"C001","package":"hl7.fhir.us.core","side":"before","state_id":"C001-before","version":"8.0.1"},{"archive_bytes":725312,"archive_sha256":"7183242b70fb2a9058aa3701fb607517a3c2fd0e3100d1d8c538d744c2adf799","case_id":"C002","package":"hl7.fhir.uv.ips","side":"after","state_id":"C002-after","version":"2.0.1"},{"archive_bytes":1065103,"archive_sha256":"403c4141101810e924f2928287985084819d8a5cc3a62e2b3840a557129840ef","case_id":"C002","package":"hl7.fhir.uv.ips","side":"before","state_id":"C002-before","version":"1.1.0"},{"archive_bytes":1003918,"archive_sha256":"e603283bafa508a3705ad022bce95bba1fbd0b8b3b87b978e7412813b7bc1778","case_id":"C003","package":"hl7.fhir.us.mcode","side":"after","state_id":"C003-after","version":"4.0.0"},{"archive_bytes":1014084,"archive_sha256":"c94c91971747efeae760aa037d168e4df992cefb6dacece08217c464b9d39214","case_id":"C003","package":"hl7.fhir.us.mcode","side":"before","state_id":"C003-before","version":"3.0.0"}]},"schema":"commandf.af02-authority-baseline/v2"} \ No newline at end of file diff --git a/tools/af02-verifier/Cargo.toml b/tools/af02-verifier/Cargo.toml new file mode 100644 index 00000000..51726311 --- /dev/null +++ b/tools/af02-verifier/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "commandf-af02-verifier" +version = "0.0.0" +edition = "2021" +rust-version = "1.97.1" +publish = false + +[workspace] + +[dependencies] +serde = { version = "=1.0.229", features = ["derive"] } +serde_json = "=1.0.151" +sha2 = "=0.10.9" +thiserror = "=1.0.69" diff --git a/tools/af02-verifier/src/authority.rs b/tools/af02-verifier/src/authority.rs new file mode 100644 index 00000000..1c30ca03 --- /dev/null +++ b/tools/af02-verifier/src/authority.rs @@ -0,0 +1,555 @@ +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use thiserror::Error; + +use crate::canonical::{canonical_sha256, git_blob_sha1_hex, sha256_hex, CanonicalError}; +use crate::retained::{RetainedError, RetainedProjection}; + +pub const AUTHORITY_BASELINE_SCHEMA: &str = "commandf.af02-authority-baseline/v2"; +pub const ASSURANCE_RULESET_ID: u64 = 21652953; +pub const REVIEW_RULESET_ID: u64 = 21652974; + +pub const CF06_PROJECT: &str = "hapifhir/org.hl7.fhir.core"; +pub const CF06_RELEASE: &str = "6.10.2"; +pub const CF06_SOURCE_COMMIT: &str = "d06577dbc5c62c74a2a8823fbc4830a3024d5b0b"; +pub const CF06_VALIDATOR_SHA256: &str = + "a3addadfa18dfa23146a0a243b6ede68eaad92157a5407738c468bb3d7e4ccd6"; +pub const CF06_R4_CONTEXT: &str = "hl7.fhir.r4.core@4.0.1"; + +#[derive(Debug, Error)] +pub enum AuthorityError { + #[error("canonicalization failed: {0}")] + Canonical(#[from] CanonicalError), + #[error("retained authority error: {0}")] + Retained(#[from] RetainedError), + #[error("authority mismatch: {0}")] + Mismatch(String), + #[error("failed to serialize authority projection: {0}")] + Json(#[from] serde_json::Error), +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AuthorityBaseline { + pub schema: String, + pub captured_from_main_sha: String, + pub captured_from_main_tree: String, + pub af01: Af01Baseline, + pub cf06: Cf06Baseline, + pub cf10: Cf10Baseline, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Af01Baseline { + pub assurance: RulesetProjection, + pub review_governance: RulesetProjection, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RulesetProjection { + pub ruleset_id: u64, + pub semantic_sha256: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Cf06Baseline { + pub project: String, + pub release: String, + pub source_commit: String, + pub validator_cli_jar_sha256: String, + pub r4_core_context: String, + pub projection_sha256: String, + pub source_files: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SourceFileEvidence { + pub path: String, + pub git_blob_sha: String, + pub raw_sha256: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Cf10Baseline { + pub deltas: Vec, + pub states: Vec, + pub retained_pr: u64, + pub retained_head: String, + pub retained_base: String, + pub retained_run: u64, + pub retained_run_conclusion: String, + pub retained_artifact_id: u64, + pub retained_artifact_name: String, + pub retained_artifact_sha256: String, + pub retained_manifest_blob_sha: String, + pub retained_manifest_sha256: String, + pub retained_donor_blob_sha: String, + pub retained_donor_sha256: String, + pub projection_sha256: String, +} + +pub struct Cf06Source<'a> { + pub path: &'a str, + pub git_blob_sha: &'a str, + pub bytes: &'a [u8], +} + +pub fn project_authority( + captured_from_main_sha: &str, + captured_from_main_tree: &str, + assurance_ruleset: &Value, + review_ruleset: &Value, + cf06_sources: [Cf06Source<'_>; 3], + retained: RetainedProjection, +) -> Result { + validate_git_sha(captured_from_main_sha, "captured_from_main_sha")?; + validate_git_sha(captured_from_main_tree, "captured_from_main_tree")?; + + let assurance = project_assurance_ruleset(assurance_ruleset)?; + let review_governance = project_review_ruleset(review_ruleset)?; + let cf06 = project_cf06(cf06_sources)?; + let cf10 = project_cf10(retained)?; + + Ok(AuthorityBaseline { + schema: AUTHORITY_BASELINE_SCHEMA.to_owned(), + captured_from_main_sha: captured_from_main_sha.to_owned(), + captured_from_main_tree: captured_from_main_tree.to_owned(), + af01: Af01Baseline { + assurance, + review_governance, + }, + cf06, + cf10, + }) +} + +pub fn project_assurance_ruleset(value: &Value) -> Result { + validate_ruleset_header(value, ASSURANCE_RULESET_ID)?; + let bypass = value + .get("bypass_actors") + .and_then(Value::as_array) + .ok_or_else(|| AuthorityError::Mismatch("assurance bypass_actors missing".to_owned()))?; + if !bypass.is_empty() { + return Err(AuthorityError::Mismatch( + "assurance ruleset must have no bypass actors".to_owned(), + )); + } + + let rules = value + .get("rules") + .and_then(Value::as_array) + .ok_or_else(|| AuthorityError::Mismatch("assurance rules missing".to_owned()))?; + if rules.len() != 3 { + return Err(AuthorityError::Mismatch(format!( + "assurance ruleset must contain exactly three rules, observed {}", + rules.len() + ))); + } + let deletion = exactly_one_rule(rules, "deletion")?; + reject_parameters(deletion, "deletion")?; + let non_fast_forward = exactly_one_rule(rules, "non_fast_forward")?; + reject_parameters(non_fast_forward, "non_fast_forward")?; + let required = exactly_one_rule(rules, "required_status_checks")?; + let parameters = required + .get("parameters") + .and_then(Value::as_object) + .ok_or_else(|| { + AuthorityError::Mismatch("required_status_checks parameters missing".to_owned()) + })?; + if parameters + .get("strict_required_status_checks_policy") + .and_then(Value::as_bool) + != Some(true) + || parameters + .get("do_not_enforce_on_create") + .and_then(Value::as_bool) + != Some(false) + { + return Err(AuthorityError::Mismatch( + "assurance strict required-check policy drifted".to_owned(), + )); + } + let checks = parameters + .get("required_status_checks") + .and_then(Value::as_array) + .ok_or_else(|| AuthorityError::Mismatch("required status checks missing".to_owned()))?; + if checks.len() != 3 { + return Err(AuthorityError::Mismatch(format!( + "required status checks must contain exactly three entries, observed {}", + checks.len() + ))); + } + + let mut normalized = Vec::new(); + for check in checks { + let context = check + .get("context") + .and_then(Value::as_str) + .ok_or_else(|| AuthorityError::Mismatch("required check context missing".to_owned()))?; + let integration_id = check + .get("integration_id") + .and_then(Value::as_u64) + .ok_or_else(|| { + AuthorityError::Mismatch("required check integration_id missing".to_owned()) + })?; + if integration_id != 15368 { + return Err(AuthorityError::Mismatch(format!( + "required check {context} has unexpected integration {integration_id}" + ))); + } + normalized.push((context.to_owned(), integration_id)); + } + normalized.sort(); + let expected = vec![ + ("assurance-proof".to_owned(), 15368), + ("rust".to_owned(), 15368), + ("scorecard".to_owned(), 15368), + ]; + if normalized != expected { + return Err(AuthorityError::Mismatch(format!( + "required check membership drifted: {normalized:?}" + ))); + } + + let semantic = json!({ + "bypass_actors": [], + "deletion": true, + "enforcement": "active", + "non_fast_forward": true, + "ref_name": { + "exclude": [], + "include": ["refs/heads/main"] + }, + "required_status_checks": normalized + .iter() + .map(|(context, integration_id)| json!({ + "context": context, + "integration_id": integration_id + })) + .collect::>(), + "strict_required_status_checks_policy": true + }); + + Ok(RulesetProjection { + ruleset_id: ASSURANCE_RULESET_ID, + semantic_sha256: canonical_sha256(&semantic)?, + }) +} + +pub fn project_review_ruleset(value: &Value) -> Result { + validate_ruleset_header(value, REVIEW_RULESET_ID)?; + let bypass = value + .get("bypass_actors") + .and_then(Value::as_array) + .ok_or_else(|| AuthorityError::Mismatch("review bypass_actors missing".to_owned()))?; + if bypass.len() != 1 { + return Err(AuthorityError::Mismatch(format!( + "review governance must contain one bypass actor, observed {}", + bypass.len() + ))); + } + let actor = &bypass[0]; + if actor.get("actor_id").and_then(Value::as_u64) != Some(5) + || actor.get("actor_type").and_then(Value::as_str) != Some("RepositoryRole") + || actor.get("bypass_mode").and_then(Value::as_str) != Some("pull_request") + { + return Err(AuthorityError::Mismatch( + "review governance bypass actor drifted".to_owned(), + )); + } + + let rules = value + .get("rules") + .and_then(Value::as_array) + .ok_or_else(|| AuthorityError::Mismatch("review rules missing".to_owned()))?; + if rules.len() != 1 { + return Err(AuthorityError::Mismatch(format!( + "review governance must contain exactly one rule, observed {}", + rules.len() + ))); + } + let pull_request = exactly_one_rule(rules, "pull_request")?; + let parameters = pull_request + .get("parameters") + .and_then(Value::as_object) + .ok_or_else(|| AuthorityError::Mismatch("pull_request parameters missing".to_owned()))?; + + expect_u64(parameters, "required_approving_review_count", 1)?; + expect_bool(parameters, "dismiss_stale_reviews_on_push", true)?; + expect_bool(parameters, "require_code_owner_review", true)?; + expect_bool(parameters, "require_last_push_approval", true)?; + expect_bool(parameters, "required_review_thread_resolution", true)?; + expect_bool( + parameters, + "require_extra_approval_for_unattributed_changes", + true, + )?; + let required_reviewers = parameters + .get("required_reviewers") + .and_then(Value::as_array) + .ok_or_else(|| AuthorityError::Mismatch("required_reviewers missing".to_owned()))?; + if !required_reviewers.is_empty() { + return Err(AuthorityError::Mismatch( + "review governance required_reviewers unexpectedly non-empty".to_owned(), + )); + } + let allowed_merge_methods = parameters + .get("allowed_merge_methods") + .and_then(Value::as_array) + .ok_or_else(|| AuthorityError::Mismatch("allowed_merge_methods missing".to_owned()))?; + if allowed_merge_methods.as_slice() != [Value::String("merge".to_owned())] { + return Err(AuthorityError::Mismatch( + "review governance must remain merge-only".to_owned(), + )); + } + + let semantic = json!({ + "allowed_merge_methods": ["merge"], + "bypass_actors": [{ + "actor_id": 5, + "actor_type": "RepositoryRole", + "bypass_mode": "pull_request" + }], + "dismiss_stale_reviews_on_push": true, + "enforcement": "active", + "ref_name": { + "exclude": [], + "include": ["refs/heads/main"] + }, + "require_code_owner_review": true, + "require_extra_approval_for_unattributed_changes": true, + "require_last_push_approval": true, + "required_approving_review_count": 1, + "required_review_thread_resolution": true, + "required_reviewers": [] + }); + + Ok(RulesetProjection { + ruleset_id: REVIEW_RULESET_ID, + semantic_sha256: canonical_sha256(&semantic)?, + }) +} + +pub fn project_cf06(sources: [Cf06Source<'_>; 3]) -> Result { + let expected_paths = [ + "crates/commandf-pkg/src/oracle_model.rs", + "donors/hl7-fhir-validator-6.10.2.yaml", + ".github/workflows/cf06-oracle.yml", + ]; + for (source, expected_path) in sources.iter().zip(expected_paths) { + if source.path != expected_path { + return Err(AuthorityError::Mismatch(format!( + "CF-06 source order/path mismatch: expected {expected_path}, got {}", + source.path + ))); + } + validate_git_sha(source.git_blob_sha, source.path)?; + let observed_blob = git_blob_sha1_hex(source.bytes); + if observed_blob != source.git_blob_sha { + return Err(AuthorityError::Mismatch(format!( + "CF-06 source {} Git blob mismatch: expected {}, got {observed_blob}", + source.path, source.git_blob_sha + ))); + } + } + + let oracle = std::str::from_utf8(sources[0].bytes) + .map_err(|_| AuthorityError::Mismatch("oracle_model.rs is not UTF-8".to_owned()))?; + for required in [ + CF06_PROJECT, + CF06_RELEASE, + CF06_SOURCE_COMMIT, + CF06_VALIDATOR_SHA256, + ] { + if !oracle.contains(required) { + return Err(AuthorityError::Mismatch(format!( + "oracle_model.rs does not bind {required}" + ))); + } + } + + let donor = std::str::from_utf8(sources[1].bytes) + .map_err(|_| AuthorityError::Mismatch("CF-06 donor is not UTF-8".to_owned()))?; + for required in [ + "repository: https://github.com/hapifhir/org.hl7.fhir.core", + "tag: 6.10.2", + CF06_SOURCE_COMMIT, + CF06_VALIDATOR_SHA256, + ] { + if !donor.contains(required) { + return Err(AuthorityError::Mismatch(format!( + "CF-06 donor does not bind {required}" + ))); + } + } + + let workflow = std::str::from_utf8(sources[2].bytes) + .map_err(|_| AuthorityError::Mismatch("CF-06 workflow is not UTF-8".to_owned()))?; + for required in [ + "name: cf06-oracle", + CF06_R4_CONTEXT, + "oracle-proof:", + "test \"$SELF_SMOKE_RESULT\" = success", + "test \"$CHANGED_PROFILE_RESULT\" = success", + ] { + if !workflow.contains(required) { + return Err(AuthorityError::Mismatch(format!( + "CF-06 workflow does not bind {required}" + ))); + } + } + + let projection = json!({ + "project": CF06_PROJECT, + "r4_core_context": CF06_R4_CONTEXT, + "release": CF06_RELEASE, + "source_commit": CF06_SOURCE_COMMIT, + "validator_cli_jar_sha256": CF06_VALIDATOR_SHA256 + }); + + Ok(Cf06Baseline { + project: CF06_PROJECT.to_owned(), + release: CF06_RELEASE.to_owned(), + source_commit: CF06_SOURCE_COMMIT.to_owned(), + validator_cli_jar_sha256: CF06_VALIDATOR_SHA256.to_owned(), + r4_core_context: CF06_R4_CONTEXT.to_owned(), + projection_sha256: canonical_sha256(&projection)?, + source_files: sources + .into_iter() + .map(|source| SourceFileEvidence { + path: source.path.to_owned(), + git_blob_sha: source.git_blob_sha.to_owned(), + raw_sha256: sha256_hex(source.bytes), + }) + .collect(), + }) +} + +pub fn project_cf10(retained: RetainedProjection) -> Result { + if retained.retained_run_conclusion != "failure" { + return Err(AuthorityError::Mismatch( + "CF-10 retained run must remain failure".to_owned(), + )); + } + let semantic = serde_json::to_value(&retained)?; + let projection_sha256 = canonical_sha256(&semantic)?; + Ok(Cf10Baseline { + deltas: retained.deltas, + states: retained.states, + retained_pr: retained.retained_pr, + retained_head: retained.retained_head, + retained_base: retained.retained_base, + retained_run: retained.retained_run, + retained_run_conclusion: retained.retained_run_conclusion, + retained_artifact_id: retained.retained_artifact_id, + retained_artifact_name: retained.retained_artifact_name, + retained_artifact_sha256: retained.retained_artifact_sha256, + retained_manifest_blob_sha: retained.retained_manifest_blob_sha, + retained_manifest_sha256: retained.retained_manifest_sha256, + retained_donor_blob_sha: retained.retained_donor_blob_sha, + retained_donor_sha256: retained.retained_donor_sha256, + projection_sha256, + }) +} + +fn validate_ruleset_header(value: &Value, expected_id: u64) -> Result<(), AuthorityError> { + if value.get("id").and_then(Value::as_u64) != Some(expected_id) { + return Err(AuthorityError::Mismatch(format!( + "ruleset id mismatch, expected {expected_id}" + ))); + } + if value.get("enforcement").and_then(Value::as_str) != Some("active") + || value.get("target").and_then(Value::as_str) != Some("branch") + || value.get("source_type").and_then(Value::as_str) != Some("Repository") + || value.get("source").and_then(Value::as_str) != Some("TheHalfMoon/commandF") + { + return Err(AuthorityError::Mismatch(format!( + "ruleset {expected_id} header drifted" + ))); + } + let ref_name = value + .get("conditions") + .and_then(|value| value.get("ref_name")) + .ok_or_else(|| AuthorityError::Mismatch("ruleset ref_name condition missing".to_owned()))?; + let include = ref_name + .get("include") + .and_then(Value::as_array) + .ok_or_else(|| AuthorityError::Mismatch("ruleset include missing".to_owned()))?; + let exclude = ref_name + .get("exclude") + .and_then(Value::as_array) + .ok_or_else(|| AuthorityError::Mismatch("ruleset exclude missing".to_owned()))?; + if include.as_slice() != [Value::String("refs/heads/main".to_owned())] || !exclude.is_empty() { + return Err(AuthorityError::Mismatch(format!( + "ruleset {expected_id} ref scope drifted" + ))); + } + Ok(()) +} + +fn exactly_one_rule<'a>(rules: &'a [Value], kind: &str) -> Result<&'a Value, AuthorityError> { + let matching: Vec<&Value> = rules + .iter() + .filter(|rule| rule.get("type").and_then(Value::as_str) == Some(kind)) + .collect(); + if matching.len() != 1 { + return Err(AuthorityError::Mismatch(format!( + "expected exactly one {kind} rule, observed {}", + matching.len() + ))); + } + Ok(matching[0]) +} + +fn reject_parameters(rule: &Value, kind: &str) -> Result<(), AuthorityError> { + if rule.get("parameters").is_some() { + return Err(AuthorityError::Mismatch(format!( + "{kind} rule unexpectedly has parameters" + ))); + } + Ok(()) +} + +fn expect_bool( + parameters: &serde_json::Map, + field: &str, + expected: bool, +) -> Result<(), AuthorityError> { + if parameters.get(field).and_then(Value::as_bool) != Some(expected) { + return Err(AuthorityError::Mismatch(format!( + "{field} mismatch, expected {expected}" + ))); + } + Ok(()) +} + +fn expect_u64( + parameters: &serde_json::Map, + field: &str, + expected: u64, +) -> Result<(), AuthorityError> { + if parameters.get(field).and_then(Value::as_u64) != Some(expected) { + return Err(AuthorityError::Mismatch(format!( + "{field} mismatch, expected {expected}" + ))); + } + Ok(()) +} + +fn validate_git_sha(value: &str, field: &str) -> Result<(), AuthorityError> { + if value.len() != 40 + || !value + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + { + return Err(AuthorityError::Mismatch(format!( + "{field} is not a lowercase 40-hex Git SHA" + ))); + } + Ok(()) +} diff --git a/tools/af02-verifier/src/canonical.rs b/tools/af02-verifier/src/canonical.rs new file mode 100644 index 00000000..6a4c83e0 --- /dev/null +++ b/tools/af02-verifier/src/canonical.rs @@ -0,0 +1,297 @@ +use std::collections::BTreeSet; +use std::fmt; + +use serde::de::{self, DeserializeSeed, MapAccess, SeqAccess, Visitor}; +use serde_json::{Map, Number, Value}; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum CanonicalError { + #[error("floating-point JSON numbers are prohibited")] + FloatNumber, + #[error("failed to encode JSON string: {0}")] + StringEncoding(#[from] serde_json::Error), +} + +#[derive(Clone, Copy)] +struct NoDuplicateValue; + +struct NoDuplicateVisitor; + +impl<'de> DeserializeSeed<'de> for NoDuplicateValue { + type Value = Value; + + fn deserialize(self, deserializer: D) -> Result + where + D: de::Deserializer<'de>, + { + deserializer.deserialize_any(NoDuplicateVisitor) + } +} + +impl<'de> Visitor<'de> for NoDuplicateVisitor { + type Value = Value; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a JSON value without duplicate object keys") + } + + fn visit_bool(self, value: bool) -> Result { + Ok(Value::Bool(value)) + } + + fn visit_i64(self, value: i64) -> Result { + Ok(Value::Number(Number::from(value))) + } + + fn visit_u64(self, value: u64) -> Result { + Ok(Value::Number(Number::from(value))) + } + + fn visit_f64(self, value: f64) -> Result + where + E: de::Error, + { + Number::from_f64(value) + .map(Value::Number) + .ok_or_else(|| E::custom("non-finite JSON number")) + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + Ok(Value::String(value.to_owned())) + } + + fn visit_string(self, value: String) -> Result { + Ok(Value::String(value)) + } + + fn visit_none(self) -> Result { + Ok(Value::Null) + } + + fn visit_unit(self) -> Result { + Ok(Value::Null) + } + + fn visit_some(self, deserializer: D) -> Result + where + D: de::Deserializer<'de>, + { + NoDuplicateValue.deserialize(deserializer) + } + + fn visit_seq(self, mut sequence: A) -> Result + where + A: SeqAccess<'de>, + { + let mut values = Vec::new(); + while let Some(value) = sequence.next_element_seed(NoDuplicateValue)? { + values.push(value); + } + Ok(Value::Array(values)) + } + + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let mut keys = BTreeSet::new(); + let mut values = Map::new(); + while let Some(key) = map.next_key::()? { + if !keys.insert(key.clone()) { + return Err(de::Error::custom(format!( + "duplicate JSON object key {key:?}" + ))); + } + let value = map.next_value_seed(NoDuplicateValue)?; + values.insert(key, value); + } + Ok(Value::Object(values)) + } +} + +pub fn parse_json_no_duplicates(bytes: &[u8]) -> Result { + let mut deserializer = serde_json::Deserializer::from_slice(bytes); + let value = NoDuplicateValue.deserialize(&mut deserializer)?; + deserializer.end()?; + Ok(value) +} + +pub fn canonical_json_bytes(value: &Value) -> Result, CanonicalError> { + let mut out = Vec::new(); + write_value(value, &mut out)?; + Ok(out) +} + +pub fn canonical_sha256(value: &Value) -> Result { + Ok(sha256_hex(&canonical_json_bytes(value)?)) +} + +pub fn sha256_hex(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + format!("{digest:x}") +} + +pub fn git_blob_sha1_hex(bytes: &[u8]) -> String { + let header = format!("blob {}\0", bytes.len()); + let mut object = Vec::with_capacity(header.len() + bytes.len()); + object.extend_from_slice(header.as_bytes()); + object.extend_from_slice(bytes); + sha1_hex(&object) +} + +fn sha1_hex(bytes: &[u8]) -> String { + let mut message = bytes.to_vec(); + let bit_len = (message.len() as u64).wrapping_mul(8); + message.push(0x80); + while message.len() % 64 != 56 { + message.push(0); + } + message.extend_from_slice(&bit_len.to_be_bytes()); + + let mut h0 = 0x6745_2301u32; + let mut h1 = 0xefcd_ab89u32; + let mut h2 = 0x98ba_dcfeu32; + let mut h3 = 0x1032_5476u32; + let mut h4 = 0xc3d2_e1f0u32; + + for chunk in message.chunks_exact(64) { + let mut words = [0u32; 80]; + for (index, word) in words[..16].iter_mut().enumerate() { + let offset = index * 4; + *word = u32::from_be_bytes([ + chunk[offset], + chunk[offset + 1], + chunk[offset + 2], + chunk[offset + 3], + ]); + } + for index in 16..80 { + words[index] = + (words[index - 3] ^ words[index - 8] ^ words[index - 14] ^ words[index - 16]) + .rotate_left(1); + } + + let mut a = h0; + let mut b = h1; + let mut c = h2; + let mut d = h3; + let mut e = h4; + + for (index, word) in words.iter().enumerate() { + let (function, constant) = match index { + 0..=19 => ((b & c) | ((!b) & d), 0x5a82_7999), + 20..=39 => (b ^ c ^ d, 0x6ed9_eba1), + 40..=59 => ((b & c) | (b & d) | (c & d), 0x8f1b_bcdc), + _ => (b ^ c ^ d, 0xca62_c1d6), + }; + let temp = a + .rotate_left(5) + .wrapping_add(function) + .wrapping_add(e) + .wrapping_add(constant) + .wrapping_add(*word); + e = d; + d = c; + c = b.rotate_left(30); + b = a; + a = temp; + } + + h0 = h0.wrapping_add(a); + h1 = h1.wrapping_add(b); + h2 = h2.wrapping_add(c); + h3 = h3.wrapping_add(d); + h4 = h4.wrapping_add(e); + } + + format!("{h0:08x}{h1:08x}{h2:08x}{h3:08x}{h4:08x}") +} + +fn write_value(value: &Value, out: &mut Vec) -> Result<(), CanonicalError> { + match value { + Value::Null => out.extend_from_slice(b"null"), + Value::Bool(value) => { + out.extend_from_slice(if *value { b"true" } else { b"false" }); + } + Value::Number(number) => { + if number.is_i64() || number.is_u64() { + out.extend_from_slice(number.to_string().as_bytes()); + } else { + return Err(CanonicalError::FloatNumber); + } + } + Value::String(value) => { + out.extend_from_slice(serde_json::to_string(value)?.as_bytes()); + } + Value::Array(values) => { + out.push(b'['); + for (index, value) in values.iter().enumerate() { + if index != 0 { + out.push(b','); + } + write_value(value, out)?; + } + out.push(b']'); + } + Value::Object(values) => { + out.push(b'{'); + let mut keys: Vec<&String> = values.keys().collect(); + keys.sort_by(|left, right| left.as_bytes().cmp(right.as_bytes())); + for (index, key) in keys.into_iter().enumerate() { + if index != 0 { + out.push(b','); + } + out.extend_from_slice(serde_json::to_string(key)?.as_bytes()); + out.push(b':'); + write_value(&values[key], out)?; + } + out.push(b'}'); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn canonicalizes_recursive_object_keys_and_preserves_array_order() { + let value: Value = + parse_json_no_duplicates(br#"{"z":{"b":2,"a":1},"a":[{"y":2,"x":1},0]}"#).unwrap(); + assert_eq!( + canonical_json_bytes(&value).unwrap(), + br#"{"a":[{"x":1,"y":2},0],"z":{"a":1,"b":2}}"# + ); + } + + #[test] + fn rejects_floats() { + let value: Value = parse_json_no_duplicates(br#"{"n":1.5}"#).unwrap(); + assert!(matches!( + canonical_json_bytes(&value), + Err(CanonicalError::FloatNumber) + )); + } + + #[test] + fn rejects_duplicate_object_keys_at_any_depth() { + let error = parse_json_no_duplicates(br#"{"outer":{"id":1,"id":2}}"#).unwrap_err(); + assert!(error + .to_string() + .contains("duplicate JSON object key \"id\"")); + } + + #[test] + fn computes_git_blob_identity() { + assert_eq!( + git_blob_sha1_hex(b"test content\n"), + "d670460b4b4aece5915caf5c68d12f560a9fe3e4" + ); + } +} diff --git a/tools/af02-verifier/src/lib.rs b/tools/af02-verifier/src/lib.rs new file mode 100644 index 00000000..f96cb263 --- /dev/null +++ b/tools/af02-verifier/src/lib.rs @@ -0,0 +1,3 @@ +pub mod authority; +pub mod canonical; +pub mod retained; diff --git a/tools/af02-verifier/src/main.rs b/tools/af02-verifier/src/main.rs new file mode 100644 index 00000000..05c2ec6c --- /dev/null +++ b/tools/af02-verifier/src/main.rs @@ -0,0 +1,135 @@ +use std::fs; +use std::path::PathBuf; + +use commandf_af02_verifier::authority::{project_authority, Cf06Source}; +use commandf_af02_verifier::canonical::{canonical_json_bytes, parse_json_no_duplicates}; +use commandf_af02_verifier::retained::{ + locator_plan, project_retained, validate_and_parse, verify_artifacts, verify_workflow_run, +}; +use serde::Deserialize; +use serde_json::Value; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct AuthorityInput { + captured_from_main_sha: String, + captured_from_main_tree: String, + assurance_ruleset_path: PathBuf, + review_ruleset_path: PathBuf, + retained_sources_path: PathBuf, + retained_schema_path: PathBuf, + retained_manifest_path: PathBuf, + retained_donor_path: PathBuf, + retained_workflow_run_path: PathBuf, + retained_artifacts_path: PathBuf, + cf06_oracle_model: SourcePath, + cf06_donor: SourcePath, + cf06_workflow: SourcePath, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct SourcePath { + path: String, + git_blob_sha: String, + local_path: PathBuf, +} + +fn main() { + if let Err(error) = run() { + eprintln!("commandf-af02-verifier: {error}"); + std::process::exit(2); + } +} + +fn run() -> Result<(), Box> { + let mut args = std::env::args().skip(1); + let command = args.next().ok_or("missing entrypoint")?; + match command.as_str() { + "project-retained" => { + let retained_path = PathBuf::from(args.next().ok_or("missing retained authority path")?); + let schema_path = PathBuf::from(args.next().ok_or("missing retained schema path")?); + if args.next().is_some() { + return Err("project-retained accepts exactly two paths".into()); + } + let retained_bytes = fs::read(retained_path)?; + let schema_bytes = fs::read(schema_path)?; + let retained = validate_and_parse(&retained_bytes, &schema_bytes)?; + let plan = locator_plan(&retained)?; + let value = serde_json::to_value(plan)?; + std::io::Write::write_all( + &mut std::io::stdout().lock(), + &canonical_json_bytes(&value)?, + )?; + } + "project-authority" => { + let input_path = PathBuf::from(args.next().ok_or("missing authority input path")?); + if args.next().is_some() { + return Err("project-authority accepts exactly one input path".into()); + } + let input_value = parse_json_no_duplicates(&fs::read(input_path)?)?; + let input: AuthorityInput = serde_json::from_value(input_value)?; + let retained_bytes = fs::read(&input.retained_sources_path)?; + let retained_schema_bytes = fs::read(&input.retained_schema_path)?; + let retained = validate_and_parse(&retained_bytes, &retained_schema_bytes)?; + + let run: Value = + parse_json_no_duplicates(&fs::read(&input.retained_workflow_run_path)?)?; + verify_workflow_run(&retained, &run)?; + let artifacts: Value = + parse_json_no_duplicates(&fs::read(&input.retained_artifacts_path)?)?; + verify_artifacts(&retained, &artifacts)?; + + let retained_projection = project_retained( + &retained, + &fs::read(&input.retained_manifest_path)?, + &fs::read(&input.retained_donor_path)?, + )?; + let assurance: Value = + parse_json_no_duplicates(&fs::read(&input.assurance_ruleset_path)?)?; + let review: Value = + parse_json_no_duplicates(&fs::read(&input.review_ruleset_path)?)?; + + let oracle_model = fs::read(&input.cf06_oracle_model.local_path)?; + let donor = fs::read(&input.cf06_donor.local_path)?; + let workflow = fs::read(&input.cf06_workflow.local_path)?; + let baseline = project_authority( + &input.captured_from_main_sha, + &input.captured_from_main_tree, + &assurance, + &review, + [ + Cf06Source { + path: &input.cf06_oracle_model.path, + git_blob_sha: &input.cf06_oracle_model.git_blob_sha, + bytes: &oracle_model, + }, + Cf06Source { + path: &input.cf06_donor.path, + git_blob_sha: &input.cf06_donor.git_blob_sha, + bytes: &donor, + }, + Cf06Source { + path: &input.cf06_workflow.path, + git_blob_sha: &input.cf06_workflow.git_blob_sha, + bytes: &workflow, + }, + ], + retained_projection, + )?; + let value = serde_json::to_value(baseline)?; + std::io::Write::write_all( + &mut std::io::stdout().lock(), + &canonical_json_bytes(&value)?, + )?; + } + "verify-pr" => { + return Err( + "verify-pr is fail-closed until AF-02 T021-T025 semantic/input/base-gate enforcement is canonical" + .into(), + ); + } + other => return Err(format!("unknown entrypoint {other}").into()), + } + Ok(()) +} diff --git a/tools/af02-verifier/src/retained.rs b/tools/af02-verifier/src/retained.rs new file mode 100644 index 00000000..4710b04b --- /dev/null +++ b/tools/af02-verifier/src/retained.rs @@ -0,0 +1,623 @@ +use std::collections::BTreeSet; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use thiserror::Error; + +use crate::canonical::{git_blob_sha1_hex, parse_json_no_duplicates, sha256_hex, CanonicalError}; + +pub const RETAINED_SCHEMA_ID: &str = "commandf.af02-retained-authority-sources/v1"; + +#[derive(Debug, Error)] +pub enum RetainedError { + #[error("invalid JSON: {0}")] + Json(#[from] serde_json::Error), + #[error("canonicalization failed: {0}")] + Canonical(#[from] CanonicalError), + #[error("schema contract violation at {path}: {message}")] + Schema { path: String, message: String }, + #[error("retained authority mismatch: {0}")] + Mismatch(String), +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RetainedAuthoritySources { + pub schema: String, + pub repository: RepositoryIdentity, + pub planning_base: PlanningBase, + pub cf10: RetainedCf10, + pub reconstruction: ReconstructionPolicy, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RepositoryIdentity { + pub owner: String, + pub name: String, + pub full_name: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PlanningBase { + pub sha: String, + pub tree: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RetainedCf10 { + pub pull_request: PullRequestIdentity, + pub retained_head: String, + pub retained_base: String, + pub manifest: RetainedFile, + pub donor: RetainedFile, + pub workflow_run: WorkflowRunIdentity, + pub artifact: ArtifactIdentity, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PullRequestIdentity { + pub number: u64, + pub node_id_numeric: u64, + pub head_ref: String, + pub base_ref: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RetainedFile { + pub path: String, + pub git_blob_sha: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WorkflowRunIdentity { + pub id: u64, + pub name: String, + pub path: String, + pub event: String, + pub workflow_id: u64, + pub run_number: u64, + pub run_attempt: u64, + pub check_suite_id: u64, + pub head_sha: String, + pub base_sha: String, + pub conclusion: String, + pub pull_request_number: u64, + pub pull_request_head_sha: String, + pub pull_request_base_sha: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ArtifactIdentity { + pub id: u64, + pub name: String, + pub sha256: String, + pub workflow_run_id: u64, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ReconstructionPolicy { + pub supplied_urls_are_authority: bool, + pub api_urls_reconstructed_from_structured_fields: bool, + pub git_blob_verified_before_parse: bool, + pub raw_sha256_computed_after_git_identity: bool, + pub retained_failure_preserved: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct LocatorPlan { + pub pull_request: String, + pub retained_head_commit: String, + pub retained_base_commit: String, + pub manifest_contents: String, + pub manifest_blob: String, + pub donor_contents: String, + pub donor_blob: String, + pub workflow_run: String, + pub workflow_run_artifacts: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct Delta { + pub id: String, + pub package: String, + pub before: String, + pub after: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct State { + pub state_id: String, + pub case_id: String, + pub side: String, + pub package: String, + pub version: String, + pub archive_sha256: String, + pub archive_bytes: u64, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct RetainedProjection { + pub deltas: Vec, + pub states: Vec, + pub retained_pr: u64, + pub retained_head: String, + pub retained_base: String, + pub retained_run: u64, + pub retained_run_conclusion: String, + pub retained_artifact_id: u64, + pub retained_artifact_name: String, + pub retained_artifact_sha256: String, + pub retained_manifest_blob_sha: String, + pub retained_manifest_sha256: String, + pub retained_donor_blob_sha: String, + pub retained_donor_sha256: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct CorpusManifest { + schema: u64, + selection_policy: String, + cases: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct CorpusCase { + id: String, + package: String, + before: CorpusSide, + after: CorpusSide, + fhir_version: String, + publisher: String, + change_evidence_url: String, + rights_evidence_url: String, + rights_mode: String, + oracle_mode: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct CorpusSide { + version: String, + archive_sha256: String, + archive_bytes: u64, + publication_url: String, +} + +pub fn validate_and_parse( + instance_bytes: &[u8], + schema_bytes: &[u8], +) -> Result { + let instance = parse_json_no_duplicates(instance_bytes)?; + let schema = parse_json_no_duplicates(schema_bytes)?; + let schema_id = + schema + .get("$id") + .and_then(Value::as_str) + .ok_or_else(|| RetainedError::Schema { + path: "$".to_owned(), + message: "trusted schema is missing $id".to_owned(), + })?; + if schema_id != "https://commandf.dev/schemas/af02-retained-authority-sources-v1.schema.json" { + return Err(RetainedError::Schema { + path: "$".to_owned(), + message: format!("unexpected trusted schema id {schema_id}"), + }); + } + validate_schema_node(&instance, &schema, "$")?; + let parsed: RetainedAuthoritySources = serde_json::from_value(instance)?; + if parsed.schema != RETAINED_SCHEMA_ID { + return Err(RetainedError::Mismatch(format!( + "unexpected retained schema {}", + parsed.schema + ))); + } + Ok(parsed) +} + +pub fn locator_plan(retained: &RetainedAuthoritySources) -> Result { + if retained.repository.full_name + != format!("{}/{}", retained.repository.owner, retained.repository.name) + { + return Err(RetainedError::Mismatch( + "repository full_name does not match owner/name".to_owned(), + )); + } + if retained.reconstruction.supplied_urls_are_authority + || !retained + .reconstruction + .api_urls_reconstructed_from_structured_fields + || !retained.reconstruction.git_blob_verified_before_parse + || !retained + .reconstruction + .raw_sha256_computed_after_git_identity + || !retained.reconstruction.retained_failure_preserved + { + return Err(RetainedError::Mismatch( + "reconstruction policy weakens the closed retained-authority contract".to_owned(), + )); + } + + let repo = &retained.repository.full_name; + let cf10 = &retained.cf10; + Ok(LocatorPlan { + pull_request: format!( + "https://api.github.com/repos/{repo}/pulls/{}", + cf10.pull_request.number + ), + retained_head_commit: format!( + "https://api.github.com/repos/{repo}/commits/{}", + cf10.retained_head + ), + retained_base_commit: format!( + "https://api.github.com/repos/{repo}/commits/{}", + cf10.retained_base + ), + manifest_contents: format!( + "https://api.github.com/repos/{repo}/contents/{}?ref={}", + cf10.manifest.path, cf10.retained_head + ), + manifest_blob: format!( + "https://api.github.com/repos/{repo}/git/blobs/{}", + cf10.manifest.git_blob_sha + ), + donor_contents: format!( + "https://api.github.com/repos/{repo}/contents/{}?ref={}", + cf10.donor.path, cf10.retained_head + ), + donor_blob: format!( + "https://api.github.com/repos/{repo}/git/blobs/{}", + cf10.donor.git_blob_sha + ), + workflow_run: format!( + "https://api.github.com/repos/{repo}/actions/runs/{}", + cf10.workflow_run.id + ), + workflow_run_artifacts: format!( + "https://api.github.com/repos/{repo}/actions/runs/{}/artifacts", + cf10.workflow_run.id + ), + }) +} + +pub fn verify_workflow_run( + retained: &RetainedAuthoritySources, + run: &Value, +) -> Result<(), RetainedError> { + let expected = &retained.cf10.workflow_run; + expect_u64(run, "id", expected.id)?; + expect_str(run, "name", &expected.name)?; + expect_str(run, "path", &expected.path)?; + expect_str(run, "event", &expected.event)?; + expect_u64(run, "workflow_id", expected.workflow_id)?; + expect_u64(run, "run_number", expected.run_number)?; + expect_u64(run, "run_attempt", expected.run_attempt)?; + expect_u64(run, "check_suite_id", expected.check_suite_id)?; + expect_str(run, "head_sha", &expected.head_sha)?; + expect_str(run, "conclusion", &expected.conclusion)?; + + let pull_requests = run + .get("pull_requests") + .and_then(Value::as_array) + .ok_or_else(|| { + RetainedError::Mismatch("workflow run pull_requests is missing".to_owned()) + })?; + let matching: Vec<&Value> = pull_requests + .iter() + .filter(|item| { + item.get("number").and_then(Value::as_u64) == Some(expected.pull_request_number) + }) + .collect(); + if matching.len() != 1 { + return Err(RetainedError::Mismatch(format!( + "workflow run must bind exactly one PR {}, observed {}", + expected.pull_request_number, + matching.len() + ))); + } + let pr = matching[0]; + let head = pr + .get("head") + .ok_or_else(|| RetainedError::Mismatch("workflow run PR head is missing".to_owned()))?; + let base = pr + .get("base") + .ok_or_else(|| RetainedError::Mismatch("workflow run PR base is missing".to_owned()))?; + expect_str(head, "ref", &retained.cf10.pull_request.head_ref)?; + expect_str(head, "sha", &expected.pull_request_head_sha)?; + expect_str(base, "ref", &retained.cf10.pull_request.base_ref)?; + expect_str(base, "sha", &expected.pull_request_base_sha)?; + + if expected.head_sha != expected.pull_request_head_sha + || expected.base_sha != expected.pull_request_base_sha + || expected.head_sha != retained.cf10.retained_head + || expected.base_sha != retained.cf10.retained_base + { + return Err(RetainedError::Mismatch( + "retained run/head/base cross-binding is inconsistent".to_owned(), + )); + } + Ok(()) +} + +pub fn verify_artifacts( + retained: &RetainedAuthoritySources, + artifacts: &Value, +) -> Result<(), RetainedError> { + let items = artifacts + .get("artifacts") + .and_then(Value::as_array) + .ok_or_else(|| { + RetainedError::Mismatch("artifact collection is missing artifacts".to_owned()) + })?; + let expected = &retained.cf10.artifact; + let matching: Vec<&Value> = items + .iter() + .filter(|item| item.get("id").and_then(Value::as_u64) == Some(expected.id)) + .collect(); + if matching.len() != 1 { + return Err(RetainedError::Mismatch(format!( + "expected exactly one artifact {}, observed {}", + expected.id, + matching.len() + ))); + } + let artifact = matching[0]; + expect_str(artifact, "name", &expected.name)?; + let digest = artifact + .get("digest") + .and_then(Value::as_str) + .ok_or_else(|| RetainedError::Mismatch("artifact digest is missing".to_owned()))?; + if digest != format!("sha256:{}", expected.sha256) { + return Err(RetainedError::Mismatch(format!( + "artifact digest mismatch: expected sha256:{}, got {digest}", + expected.sha256 + ))); + } + let run = artifact + .get("workflow_run") + .ok_or_else(|| RetainedError::Mismatch("artifact workflow_run is missing".to_owned()))?; + expect_u64(run, "id", expected.workflow_run_id)?; + expect_str(run, "head_sha", &retained.cf10.retained_head)?; + expect_str(run, "head_branch", &retained.cf10.pull_request.head_ref)?; + Ok(()) +} + +pub fn project_retained( + retained: &RetainedAuthoritySources, + manifest_bytes: &[u8], + donor_bytes: &[u8], +) -> Result { + let manifest_blob = git_blob_sha1_hex(manifest_bytes); + if manifest_blob != retained.cf10.manifest.git_blob_sha { + return Err(RetainedError::Mismatch(format!( + "retained manifest Git blob mismatch: expected {}, got {manifest_blob}", + retained.cf10.manifest.git_blob_sha + ))); + } + let donor_blob = git_blob_sha1_hex(donor_bytes); + if donor_blob != retained.cf10.donor.git_blob_sha { + return Err(RetainedError::Mismatch(format!( + "retained donor Git blob mismatch: expected {}, got {donor_blob}", + retained.cf10.donor.git_blob_sha + ))); + } + + let manifest_value = parse_json_no_duplicates(manifest_bytes)?; + let manifest: CorpusManifest = serde_json::from_value(manifest_value)?; + if manifest.schema != 1 || manifest.selection_policy != "frozen_pre_result_v1" { + return Err(RetainedError::Mismatch( + "retained corpus manifest schema/selection policy drifted".to_owned(), + )); + } + if manifest.cases.len() != 3 { + return Err(RetainedError::Mismatch(format!( + "retained corpus must contain exactly three cases, observed {}", + manifest.cases.len() + ))); + } + + let expected_ids = ["C001", "C002", "C003"]; + for (case, expected_id) in manifest.cases.iter().zip(expected_ids) { + if case.id != expected_id { + return Err(RetainedError::Mismatch(format!( + "retained corpus order/id mismatch: expected {expected_id}, got {}", + case.id + ))); + } + validate_metadata_only_case(case)?; + } + + let mut deltas = Vec::with_capacity(3); + let mut states = Vec::with_capacity(6); + for case in &manifest.cases { + deltas.push(Delta { + id: case.id.clone(), + package: case.package.clone(), + before: case.before.version.clone(), + after: case.after.version.clone(), + }); + states.push(State { + state_id: format!("{}-after", case.id), + case_id: case.id.clone(), + side: "after".to_owned(), + package: case.package.clone(), + version: case.after.version.clone(), + archive_sha256: case.after.archive_sha256.clone(), + archive_bytes: case.after.archive_bytes, + }); + states.push(State { + state_id: format!("{}-before", case.id), + case_id: case.id.clone(), + side: "before".to_owned(), + package: case.package.clone(), + version: case.before.version.clone(), + archive_sha256: case.before.archive_sha256.clone(), + archive_bytes: case.before.archive_bytes, + }); + } + + Ok(RetainedProjection { + deltas, + states, + retained_pr: retained.cf10.pull_request.number, + retained_head: retained.cf10.retained_head.clone(), + retained_base: retained.cf10.retained_base.clone(), + retained_run: retained.cf10.workflow_run.id, + retained_run_conclusion: retained.cf10.workflow_run.conclusion.clone(), + retained_artifact_id: retained.cf10.artifact.id, + retained_artifact_name: retained.cf10.artifact.name.clone(), + retained_artifact_sha256: retained.cf10.artifact.sha256.clone(), + retained_manifest_blob_sha: retained.cf10.manifest.git_blob_sha.clone(), + retained_manifest_sha256: sha256_hex(manifest_bytes), + retained_donor_blob_sha: retained.cf10.donor.git_blob_sha.clone(), + retained_donor_sha256: sha256_hex(donor_bytes), + }) +} + +fn validate_metadata_only_case(case: &CorpusCase) -> Result<(), RetainedError> { + let allowed = [ + ("C001", "hl7.fhir.us.core", "8.0.1", "9.0.0"), + ("C002", "hl7.fhir.uv.ips", "1.1.0", "2.0.1"), + ("C003", "hl7.fhir.us.mcode", "3.0.0", "4.0.0"), + ]; + let expected = allowed + .iter() + .find(|entry| entry.0 == case.id) + .ok_or_else(|| RetainedError::Mismatch(format!("unknown retained case {}", case.id)))?; + if case.package != expected.1 + || case.before.version != expected.2 + || case.after.version != expected.3 + || case.fhir_version != "4.0.1" + || case.publisher != "HL7 International" + || case.rights_mode != "metadata_only_no_redistribution" + || case.oracle_mode != "changed_structure_definitions_only" + { + return Err(RetainedError::Mismatch(format!( + "retained case {} semantic identity drifted", + case.id + ))); + } + for value in [ + &case.before.publication_url, + &case.after.publication_url, + &case.change_evidence_url, + &case.rights_evidence_url, + ] { + if !value.starts_with("https://hl7.org/") { + return Err(RetainedError::Mismatch(format!( + "retained case {} contains unexpected publication authority", + case.id + ))); + } + } + Ok(()) +} + +fn validate_schema_node(instance: &Value, schema: &Value, path: &str) -> Result<(), RetainedError> { + if let Some(expected) = schema.get("const") { + if instance != expected { + return Err(RetainedError::Schema { + path: path.to_owned(), + message: "value does not equal trusted const".to_owned(), + }); + } + return Ok(()); + } + + if let Some(kind) = schema.get("type").and_then(Value::as_str) { + let matches = match kind { + "object" => instance.is_object(), + "array" => instance.is_array(), + "string" => instance.is_string(), + "integer" => instance.as_i64().is_some() || instance.as_u64().is_some(), + "boolean" => instance.is_boolean(), + "null" => instance.is_null(), + other => { + return Err(RetainedError::Schema { + path: path.to_owned(), + message: format!("unsupported trusted schema type {other}"), + }); + } + }; + if !matches { + return Err(RetainedError::Schema { + path: path.to_owned(), + message: format!("expected type {kind}"), + }); + } + } + + if let Some(object) = instance.as_object() { + let properties = schema + .get("properties") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + if schema.get("additionalProperties") == Some(&Value::Bool(false)) { + let allowed: BTreeSet<&str> = properties.keys().map(String::as_str).collect(); + for key in object.keys() { + if !allowed.contains(key.as_str()) { + return Err(RetainedError::Schema { + path: format!("{path}.{key}"), + message: "unknown field".to_owned(), + }); + } + } + } + if let Some(required) = schema.get("required").and_then(Value::as_array) { + for key in required { + let key = key.as_str().ok_or_else(|| RetainedError::Schema { + path: path.to_owned(), + message: "trusted schema required entry is not a string".to_owned(), + })?; + if !object.contains_key(key) { + return Err(RetainedError::Schema { + path: format!("{path}.{key}"), + message: "required field is missing".to_owned(), + }); + } + } + } + for (key, child) in object { + if let Some(child_schema) = properties.get(key) { + validate_schema_node(child, child_schema, &format!("{path}.{key}"))?; + } + } + } + Ok(()) +} + +fn expect_str(value: &Value, field: &str, expected: &str) -> Result<(), RetainedError> { + let observed = value + .get(field) + .and_then(Value::as_str) + .ok_or_else(|| RetainedError::Mismatch(format!("{field} is missing or not a string")))?; + if observed != expected { + return Err(RetainedError::Mismatch(format!( + "{field} mismatch: expected {expected}, got {observed}" + ))); + } + Ok(()) +} + +fn expect_u64(value: &Value, field: &str, expected: u64) -> Result<(), RetainedError> { + let observed = value + .get(field) + .and_then(Value::as_u64) + .ok_or_else(|| RetainedError::Mismatch(format!("{field} is missing or not an integer")))?; + if observed != expected { + return Err(RetainedError::Mismatch(format!( + "{field} mismatch: expected {expected}, got {observed}" + ))); + } + Ok(()) +} diff --git a/tools/af02-verifier/tests/fixtures/README.md b/tools/af02-verifier/tests/fixtures/README.md new file mode 100644 index 00000000..9b5714a8 --- /dev/null +++ b/tools/af02-verifier/tests/fixtures/README.md @@ -0,0 +1,3 @@ +# AF-02 authority reconstruction fixtures + +These fixtures are test-only copies/projections of canonical or retained authority inputs. They do not establish authority. Runtime authority is reconstructed by the base-controlled verifier from GitHub/canonical-base identities and raw bytes. diff --git a/tools/af02-verifier/tests/fixtures/assurance-ruleset.json b/tools/af02-verifier/tests/fixtures/assurance-ruleset.json new file mode 100644 index 00000000..d5485a85 --- /dev/null +++ b/tools/af02-verifier/tests/fixtures/assurance-ruleset.json @@ -0,0 +1,31 @@ +{ + "id": 21652953, + "name": "commandF main assurance", + "target": "branch", + "source_type": "Repository", + "source": "TheHalfMoon/commandF", + "enforcement": "active", + "conditions": { + "ref_name": { + "exclude": [], + "include": ["refs/heads/main"] + } + }, + "rules": [ + {"type": "deletion"}, + {"type": "non_fast_forward"}, + { + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": true, + "do_not_enforce_on_create": false, + "required_status_checks": [ + {"context": "rust", "integration_id": 15368}, + {"context": "assurance-proof", "integration_id": 15368}, + {"context": "scorecard", "integration_id": 15368} + ] + } + } + ], + "bypass_actors": [] +} diff --git a/tools/af02-verifier/tests/fixtures/cf10-artifacts.json b/tools/af02-verifier/tests/fixtures/cf10-artifacts.json new file mode 100644 index 00000000..f18cddfc --- /dev/null +++ b/tools/af02-verifier/tests/fixtures/cf10-artifacts.json @@ -0,0 +1,16 @@ +{ + "total_count": 1, + "artifacts": [ + { + "id": 9255732702, + "name": "cf10-real-corpus-evidence", + "expired": true, + "digest": "sha256:9fdde985bb5abbe53ec2bce2dadc5f65c95557f8848c9af68755fc81a45af612", + "workflow_run": { + "id": 31916124080, + "head_branch": "feat/cf-10-real-ig-delta-corpus", + "head_sha": "5fe10d9859407272acf6649fc3e868d3eb2fbd12" + } + } + ] +} diff --git a/tools/af02-verifier/tests/fixtures/cf10-corpus.json b/tools/af02-verifier/tests/fixtures/cf10-corpus.json new file mode 100644 index 00000000..655949a8 --- /dev/null +++ b/tools/af02-verifier/tests/fixtures/cf10-corpus.json @@ -0,0 +1,72 @@ +{ + "schema": 1, + "selection_policy": "frozen_pre_result_v1", + "cases": [ + { + "id": "C001", + "package": "hl7.fhir.us.core", + "before": { + "version": "8.0.1", + "archive_sha256": "3c02eef48ef10617021bee95e58cbc66d596ceda8cada24b72000d33ad67c464", + "archive_bytes": 2713046, + "publication_url": "https://hl7.org/fhir/us/core/STU8.0.1/" + }, + "after": { + "version": "9.0.0", + "archive_sha256": "d7b54d2ec2a48cea94ffea5d939ad67a681f80b94d69594a08cebac36da9e059", + "archive_bytes": 2749959, + "publication_url": "https://hl7.org/fhir/us/core/STU9/" + }, + "fhir_version": "4.0.1", + "publisher": "HL7 International", + "change_evidence_url": "https://hl7.org/fhir/us/core/STU9/changes.html", + "rights_evidence_url": "https://hl7.org/fhir/us/core/STU9/ImplementationGuide-hl7.fhir.us.core.html", + "rights_mode": "metadata_only_no_redistribution", + "oracle_mode": "changed_structure_definitions_only" + }, + { + "id": "C002", + "package": "hl7.fhir.uv.ips", + "before": { + "version": "1.1.0", + "archive_sha256": "403c4141101810e924f2928287985084819d8a5cc3a62e2b3840a557129840ef", + "archive_bytes": 1065103, + "publication_url": "https://hl7.org/fhir/uv/ips/STU1.1/" + }, + "after": { + "version": "2.0.1", + "archive_sha256": "7183242b70fb2a9058aa3701fb607517a3c2fd0e3100d1d8c538d744c2adf799", + "archive_bytes": 725312, + "publication_url": "https://hl7.org/fhir/uv/ips/en/" + }, + "fhir_version": "4.0.1", + "publisher": "HL7 International", + "change_evidence_url": "https://hl7.org/fhir/uv/ips/en/changes.html", + "rights_evidence_url": "https://hl7.org/fhir/uv/ips/en/terminology.html", + "rights_mode": "metadata_only_no_redistribution", + "oracle_mode": "changed_structure_definitions_only" + }, + { + "id": "C003", + "package": "hl7.fhir.us.mcode", + "before": { + "version": "3.0.0", + "archive_sha256": "c94c91971747efeae760aa037d168e4df992cefb6dacece08217c464b9d39214", + "archive_bytes": 1014084, + "publication_url": "https://hl7.org/fhir/us/mcode/STU3/" + }, + "after": { + "version": "4.0.0", + "archive_sha256": "e603283bafa508a3705ad022bce95bba1fbd0b8b3b87b978e7412813b7bc1778", + "archive_bytes": 1003918, + "publication_url": "https://hl7.org/fhir/us/mcode/STU4/" + }, + "fhir_version": "4.0.1", + "publisher": "HL7 International", + "change_evidence_url": "https://hl7.org/fhir/us/mcode/STU4/changes.html", + "rights_evidence_url": "https://hl7.org/fhir/us/mcode/STU4/terminology.html", + "rights_mode": "metadata_only_no_redistribution", + "oracle_mode": "changed_structure_definitions_only" + } + ] +} diff --git a/tools/af02-verifier/tests/fixtures/cf10-donor.yaml b/tools/af02-verifier/tests/fixtures/cf10-donor.yaml new file mode 100644 index 00000000..566b46f4 --- /dev/null +++ b/tools/af02-verifier/tests/fixtures/cf10-donor.yaml @@ -0,0 +1,131 @@ +schema: commandf.donor-manifest/v1 +updated: 2026-08-15 + +discovery_evidence: + head: d8cb88ba2bb3696f44322eb302957c8c50e1c8f4 + workflow: cf10-digest-discovery + run: 31890014888 + artifact_id: 9248341586 + artifact_digest: sha256:02f08bced4c30665a40c7967611057753bf56330a5e7e10e85081619b433dd8c + independent_resolutions_per_state: 2 + selection_frozen_before_results: true + eligibility_sweep_complete: true + eligible_states: 6 + selected_states: 6 + +sources: + - id: us-core-real-delta + project: HL7 US Core Implementation Guide + package: hl7.fhir.us.core + before_version: 8.0.1 + after_version: 9.0.0 + before_attestation: + sha256: 3c02eef48ef10617021bee95e58cbc66d596ceda8cada24b72000d33ad67c464 + archive_bytes: 2713046 + byte_identical_across_independent_resolutions: true + after_attestation: + sha256: d7b54d2ec2a48cea94ffea5d939ad67a681f80b94d69594a08cebac36da9e059 + archive_bytes: 2749959 + byte_identical_across_independent_resolutions: true + before_publication: https://hl7.org/fhir/us/core/STU8.0.1/ + after_publication: https://hl7.org/fhir/us/core/STU9/ + change_evidence: https://hl7.org/fhir/us/core/STU9/changes.html + rights_evidence: + - https://hl7.org/fhir/us/core/STU8.0.1/ImplementationGuide-hl7.fhir.us.core.html + - https://hl7.org/fhir/us/core/STU9/ImplementationGuide-hl7.fhir.us.core.html + fhir_version: 4.0.1 + mode: [PUBLIC_RUNTIME_INPUT, BENCHMARK_METADATA] + redistribution: NONE + rights_note: >- + Publication metadata identifies HL7 copyright/legal Creative Commons terms. CF-10 does not + vendor or redistribute the NPM package or its terminology/content; package bytes are ephemeral + public runtime inputs and retain all upstream rights/terms. + adopted_patterns: + - published exact package/version identity + - public change-history evidence + - runtime digest attestation through commandF + exclusions: + - no NPM archive copied into the repository + - no examples or terminology expansions copied into the repository + - no ballot/CI build selected for corpus v1 + + - id: ips-real-delta + project: HL7 International Patient Summary Implementation Guide + package: hl7.fhir.uv.ips + before_version: 1.1.0 + after_version: 2.0.1 + before_attestation: + sha256: 403c4141101810e924f2928287985084819d8a5cc3a62e2b3840a557129840ef + archive_bytes: 1065103 + byte_identical_across_independent_resolutions: true + after_attestation: + sha256: 7183242b70fb2a9058aa3701fb607517a3c2fd0e3100d1d8c538d744c2adf799 + archive_bytes: 725312 + byte_identical_across_independent_resolutions: true + before_publication: https://hl7.org/fhir/uv/ips/STU1.1/ + after_publication: https://hl7.org/fhir/uv/ips/en/ + change_evidence: https://hl7.org/fhir/uv/ips/en/changes.html + rights_evidence: + - https://hl7.org/fhir/uv/ips/STU1.1/terminology.html + - https://hl7.org/fhir/uv/ips/en/terminology.html + fhir_version: 4.0.1 + mode: [PUBLIC_RUNTIME_INPUT, BENCHMARK_METADATA] + redistribution: NONE + rights_note: >- + IPS includes mixed upstream IP/terminology statements, including SNOMED CT and other code-system + rights. CF-10 intentionally records metadata only and does not redistribute package content, + terminology expansions, examples, or imply that commandF's repository license grants rights to + those upstream materials. + adopted_patterns: + - published exact package/version identity + - explicit upstream non-compatible/compatible change evidence + - runtime digest attestation through commandF + exclusions: + - no NPM archive copied into the repository + - no SNOMED CT/RxNorm/ISO/EDQM payload copied into the repository + - no ballot/continuous-build package selected for corpus v1 + + - id: mcode-real-delta + project: HL7 minimal Common Oncology Data Elements (mCODE) Implementation Guide + package: hl7.fhir.us.mcode + before_version: 3.0.0 + after_version: 4.0.0 + before_attestation: + sha256: c94c91971747efeae760aa037d168e4df992cefb6dacece08217c464b9d39214 + archive_bytes: 1014084 + byte_identical_across_independent_resolutions: true + after_attestation: + sha256: e603283bafa508a3705ad022bce95bba1fbd0b8b3b87b978e7412813b7bc1778 + archive_bytes: 1003918 + byte_identical_across_independent_resolutions: true + before_publication: https://hl7.org/fhir/us/mcode/STU3/ + after_publication: https://hl7.org/fhir/us/mcode/STU4/ + change_evidence: https://hl7.org/fhir/us/mcode/STU4/changes.html + rights_evidence: + - https://hl7.org/fhir/us/mcode/STU3/downloads.html + - https://hl7.org/fhir/us/mcode/STU4/downloads.html + - https://hl7.org/fhir/us/mcode/STU4/terminology.html + fhir_version: 4.0.1 + mode: [PUBLIC_RUNTIME_INPUT, BENCHMARK_METADATA] + redistribution: NONE + rights_note: >- + The publication records HL7/Creative Commons-publication terms while individual terminology + artifacts may carry separate upstream terms such as SNOMED CT. CF-10 does not redistribute + package or terminology content and retains those upstream obligations. + adopted_patterns: + - published exact package/version identity + - specialty-oncology version-delta evidence + - runtime digest attestation through commandF + exclusions: + - no NPM archive copied into the repository + - no terminology expansion copied into the repository + - no license simplification from mixed artifact-level IP statements + +rules: + - corpus v1 selection criteria and version pairs are frozen before commandF result discovery + - selected cases cannot be removed because commandF reports an unfavorable or divergent result + - exact package digests and byte sizes must come from two independent clean commandF resolutions + - digest metadata is adopted only after byte-for-byte equality and CF-01 verification + - package bytes remain ephemeral and never enter git history + - repository license must not be presented as relicensing upstream IG or terminology content + - a rights/access failure may block a case but must be documented rather than silently substituted diff --git a/tools/af02-verifier/tests/fixtures/cf10-run.json b/tools/af02-verifier/tests/fixtures/cf10-run.json new file mode 100644 index 00000000..82be7135 --- /dev/null +++ b/tools/af02-verifier/tests/fixtures/cf10-run.json @@ -0,0 +1,25 @@ +{ + "id": 31916124080, + "name": "cf10-real-corpus", + "path": ".github/workflows/cf10-real-corpus.yml", + "event": "pull_request", + "workflow_id": 335093310, + "run_number": 45, + "run_attempt": 1, + "check_suite_id": 86559814682, + "head_sha": "5fe10d9859407272acf6649fc3e868d3eb2fbd12", + "conclusion": "failure", + "pull_requests": [ + { + "number": 11, + "head": { + "ref": "feat/cf-10-real-ig-delta-corpus", + "sha": "5fe10d9859407272acf6649fc3e868d3eb2fbd12" + }, + "base": { + "ref": "main", + "sha": "5cb1a4c3445c0ebd86654cfb467a5e008e801c3e" + } + } + ] +} diff --git a/tools/af02-verifier/tests/fixtures/review-ruleset.json b/tools/af02-verifier/tests/fixtures/review-ruleset.json new file mode 100644 index 00000000..98a04038 --- /dev/null +++ b/tools/af02-verifier/tests/fixtures/review-ruleset.json @@ -0,0 +1,32 @@ +{ + "id": 21652974, + "name": "commandF main review governance", + "target": "branch", + "source_type": "Repository", + "source": "TheHalfMoon/commandF", + "enforcement": "active", + "conditions": { + "ref_name": { + "exclude": [], + "include": ["refs/heads/main"] + } + }, + "rules": [ + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 1, + "dismiss_stale_reviews_on_push": true, + "required_reviewers": [], + "require_code_owner_review": true, + "require_last_push_approval": true, + "required_review_thread_resolution": true, + "require_extra_approval_for_unattributed_changes": true, + "allowed_merge_methods": ["merge"] + } + } + ], + "bypass_actors": [ + {"actor_id": 5, "actor_type": "RepositoryRole", "bypass_mode": "pull_request"} + ] +}