rshr_202_step_299_gate.rs (22306B)
1 use std::env; 2 use std::fs; 3 use std::path::{Path, PathBuf}; 4 use std::process::{Command, Output}; 5 6 use serde_json::{Value, json}; 7 use sha2::{Digest, Sha256}; 8 9 const STEP: u16 = 299; 10 const GATE_DIGEST: &str = "b76f61a9a5365ca088d4b00eb140ed30b0b51f418cba68e26e3f3cf1c0ff3a15"; 11 const NIX_SHA256: &str = "a59ab70f97f6d571642d13c7506aafec0a4275520d53daee2d8451be7c495cd1"; 12 const NIX_VERSION_SHA256: &str = "6db806391ffaea4cdb08ade0031feac399c0cd08474b3bfde8cb33f88a36c8e1"; 13 const MAX_OUTPUT_BYTES: usize = 32 * 1024 * 1024; 14 15 const EXACT_SOURCES: &[(&str, &str)] = &[ 16 ( 17 "flake.nix", 18 "de3d2258f2c48ae8b1493b676ab3d1466cd5f1a59015f65283efbfe12447e501", 19 ), 20 ( 21 "build/nix/library.nix", 22 "ff425fe6361bad78c105e36d3e900950ce22b0034e26add55e5ed287aa868765", 23 ), 24 ( 25 "build/nix/service/native-inputs.nix", 26 "09aed688d6ad6c5c824aa771b871cc0c4857d8378d1330221c8d5a05509d9391", 27 ), 28 ( 29 "build/nix/service/nixos-module.nix", 30 "20b42dd6972c59fcaaf5a282219644d83822ea03a2805bfe8e6e9b8f7b628622", 31 ), 32 ( 33 "build/nix/service/fixture.nix", 34 "f3fa0cbef4f6a86feee9b2319165d97c51526236b1426ffa83ffb435ed90cd97", 35 ), 36 ( 37 "build/nix/service/systems.nix", 38 "d16e21827022a2315234f4c5e4b485017a36ecd90a5559b01d23331cdd505e46", 39 ), 40 ]; 41 42 pub(crate) struct Arguments { 43 pub(crate) step: u16, 44 pub(crate) check_id: String, 45 pub(crate) source_revision: String, 46 pub(crate) source_tree: String, 47 pub(crate) candidate_digest: String, 48 pub(crate) platform: String, 49 pub(crate) execution_request_sha256: String, 50 } 51 52 fn root() -> PathBuf { 53 Path::new(env!("CARGO_MANIFEST_DIR")) 54 .parent() 55 .and_then(Path::parent) 56 .expect("xtask must remain under tools/xtask") 57 .to_path_buf() 58 } 59 60 fn sha256(bytes: &[u8]) -> String { 61 hex::encode(Sha256::digest(bytes)) 62 } 63 64 fn canonical(value: &Value) -> Result<Vec<u8>, String> { 65 serde_json::to_vec(value).map_err(|_| "Step 299 JSON encoding failed".to_owned()) 66 } 67 68 fn execute(command: &mut Command, label: &str) -> Result<Output, String> { 69 let output = command 70 .current_dir(root()) 71 .env("CARGO_NET_OFFLINE", "true") 72 .env("CARGO_TERM_COLOR", "never") 73 .output() 74 .map_err(|_| format!("{label} could not start"))?; 75 if output.stdout.len() > MAX_OUTPUT_BYTES || output.stderr.len() > MAX_OUTPUT_BYTES { 76 return Err(format!("{label} exceeded its output bound")); 77 } 78 Ok(output) 79 } 80 81 fn bounded(command: &mut Command, label: &str) -> Result<Output, String> { 82 let output = execute(command, label)?; 83 if !output.status.success() { 84 return Err(format!("{label} failed")); 85 } 86 Ok(output) 87 } 88 89 fn rejected(command: &mut Command, label: &str) -> Result<(), String> { 90 if execute(command, label)?.status.success() { 91 return Err(format!("{label} unexpectedly succeeded")); 92 } 93 Ok(()) 94 } 95 96 fn resolve_nix() -> Result<PathBuf, String> { 97 if let Some(explicit) = env::var_os("RSHR_NIX_EXECUTABLE") { 98 return fs::canonicalize(explicit) 99 .map_err(|_| "Step 299 Nix client is unavailable".to_owned()); 100 } 101 let path = env::var_os("PATH").ok_or_else(|| "Step 299 PATH is absent".to_owned())?; 102 env::split_paths(&path) 103 .map(|directory| directory.join("nix")) 104 .find(|candidate| candidate.is_file()) 105 .and_then(|candidate| fs::canonicalize(candidate).ok()) 106 .ok_or_else(|| "Step 299 Nix client is unavailable".to_owned()) 107 } 108 109 fn object_keys(value: &Value, label: &str) -> Result<Vec<String>, String> { 110 let mut keys = value 111 .as_object() 112 .ok_or_else(|| format!("Step 299 {label} is not an object"))? 113 .keys() 114 .cloned() 115 .collect::<Vec<_>>(); 116 keys.sort_unstable(); 117 Ok(keys) 118 } 119 120 fn require_outputs(nix: &Path) -> Result<(), String> { 121 let show = bounded( 122 Command::new(nix).args([ 123 "--offline", 124 "flake", 125 "show", 126 "--json", 127 "--all-systems", 128 "--no-write-lock-file", 129 ]), 130 "Step 299 Nix output inventory", 131 )?; 132 let inventory: Value = serde_json::from_slice(&show.stdout) 133 .map_err(|_| "Step 299 Nix output inventory is invalid".to_owned())?; 134 validate_output_inventory(&inventory)?; 135 136 let supported = bounded( 137 Command::new(nix).args(["--offline", "eval", "--json", ".#lib.supportedSystems"]), 138 "Step 299 shared-helper systems", 139 )?; 140 if supported.stdout != b"[\"aarch64-darwin\",\"x86_64-linux\"]\n" { 141 return Err("Step 299 shared-helper systems differ".to_owned()); 142 } 143 let helper_type = bounded( 144 Command::new(nix).args([ 145 "--offline", 146 "eval", 147 "--raw", 148 "--apply", 149 "f: builtins.typeOf f", 150 ".#lib.mkServiceHelpers", 151 ]), 152 "Step 299 shared-helper export", 153 )?; 154 if helper_type.stdout != b"lambda" { 155 return Err("Step 299 shared-helper export differs".to_owned()); 156 } 157 158 for system in ["x86_64-darwin", "aarch64-linux", "x86_64-windows"] { 159 rejected( 160 Command::new(nix).args([ 161 "--offline", 162 "eval", 163 "--raw", 164 &format!(".#packages.{system}.default.name"), 165 ]), 166 "Step 299 excluded-system evaluation", 167 )?; 168 } 169 Ok(()) 170 } 171 172 fn validate_output_inventory(inventory: &Value) -> Result<(), String> { 173 let systems = ["aarch64-darwin", "x86_64-linux"]; 174 for family in ["apps", "checks", "devShells", "formatter", "packages"] { 175 if object_keys(&inventory[family], family)? != systems { 176 return Err(format!("Step 299 {family} systems differ")); 177 } 178 } 179 if inventory.pointer("/overlays/default/type") != Some(&json!("nixpkgs-overlay")) { 180 return Err("Step 299 default overlay is absent".to_owned()); 181 } 182 for system in systems { 183 let packages = &inventory["packages"][system]; 184 if object_keys(packages, "packages")? != ["default", "xtask"] 185 || packages["default"]["name"] != "radroots-lib-release-bundle-0.1.0-alpha" 186 { 187 return Err("Step 299 package inventory differs".to_owned()); 188 } 189 if inventory["apps"][system]["default"]["description"] 190 != "Inspect the installed Radroots Lib release bundle" 191 || inventory["devShells"][system].get("default").is_none() 192 || inventory["checks"][system].get("release-bundle").is_none() 193 { 194 return Err("Step 299 production output is absent".to_owned()); 195 } 196 for family in ["apps", "devShells", "packages"] { 197 if object_keys(&inventory[family][system], family)? 198 .iter() 199 .any(|name| name.contains("fixture")) 200 { 201 return Err("Step 299 fixture escaped its test-only surface".to_owned()); 202 } 203 } 204 let fixture_checks = object_keys(&inventory["checks"][system], "checks")? 205 .into_iter() 206 .filter(|name| name.contains("fixture")) 207 .collect::<Vec<_>>(); 208 if fixture_checks.is_empty() 209 || fixture_checks 210 .iter() 211 .any(|name| !name.starts_with("service-fixture-")) 212 { 213 return Err("Step 299 fixture check names differ".to_owned()); 214 } 215 } 216 217 Ok(()) 218 } 219 220 fn require_nix() -> Result<(), String> { 221 let executable = resolve_nix()?; 222 if sha256(&fs::read(&executable).map_err(|_| "Step 299 Nix client is unreadable")?) 223 != NIX_SHA256 224 { 225 return Err("Step 299 Nix client identity differs".to_owned()); 226 } 227 let version = bounded( 228 Command::new(&executable).arg("--version"), 229 "Step 299 Nix version", 230 )?; 231 if sha256(&version.stdout) != NIX_VERSION_SHA256 { 232 return Err("Step 299 Nix version differs".to_owned()); 233 } 234 bounded( 235 Command::new(&executable).args([ 236 "--offline", 237 "flake", 238 "check", 239 "--all-systems", 240 "--no-build", 241 "--no-write-lock-file", 242 ]), 243 "Step 299 Nix evaluation", 244 )?; 245 require_outputs(&executable) 246 } 247 248 fn expected_contract(verifier_sha256: &str) -> Value { 249 json!({ 250 "argv_template": [ 251 "cargo", "extbuild", "run", "--", "cargo", "run", "--offline", "--locked", 252 "-q", "-p", "xtask", "--", "rshr-step-299-gate", "--step={step}", 253 "--check-id={check_id}", "--source-revision={source_revision}", 254 "--source-tree={source_tree}", "--candidate-digest={candidate_digest}", 255 "--platform=macos_aarch64", 256 "--execution-request-sha256={execution_request_sha256}" 257 ], 258 "assertion_id": [format!("step_299_gate_01_{GATE_DIGEST}")], 259 "check_id": format!("gate-01-{GATE_DIGEST}"), 260 "environment_authority": { 261 "cache_policy_id": "rshr-200-step-287-cache-policy.v1", 262 "cache_policy_sha256": "3e81d178bce97b6c349dfbb00c68fd6f620ac00b1a1c8d37b12e9998f3c9eaaa", 263 "cadence_policy_id": "rshr-200-step-287-cadence-policy.v1", 264 "cadence_policy_sha256": "d24903df8659ee3772297c84994911efe7d21cb8b988320ddc6ddce0431892a1", 265 "isolation": "extbuild_host_constrained", 266 "network": "disabled", 267 "network_policy_id": "none", 268 "network_policy_sha256": "none", 269 "resource_policy_id": "rshr-200-step-287-resource-policy.v1", 270 "resource_policy_sha256": "05d3c7a89185d3c55678d97955193fce2ed92b1eee5af99083d77ea64c98d14e" 271 }, 272 "environment_names": [ 273 "EXT_BUILD_CONFIG", "EXT_BUILD_MACHINE_CONFIG", "EXT_BUILD_ROOT", "HOME", "PATH", 274 "RUSTUP_TOOLCHAIN", "TMPDIR" 275 ], 276 "gate_definition_sha256": GATE_DIGEST, 277 "required_platforms": ["macos_aarch64"], 278 "required_tools": ["rustc"], 279 "result_schema": "radroots.services-hardening.rshr-200-step-check-result.v1", 280 "schema": "radroots.services-hardening.rshr-200-step-check-command.v1", 281 "step": STEP, 282 "verifier_path": "tools/xtask/src/rshr_202_step_299_gate.rs", 283 "verifier_sha256": verifier_sha256 284 }) 285 } 286 287 fn validate_arguments(arguments: &Arguments) -> Result<String, String> { 288 let check_id = format!("gate-01-{GATE_DIGEST}"); 289 if arguments.step != STEP 290 || arguments.check_id != check_id 291 || arguments.candidate_digest != "none" 292 || arguments.platform != "macos_aarch64" 293 || arguments.source_revision.len() != 40 294 || arguments.source_tree.len() != 40 295 || arguments.execution_request_sha256.len() != 64 296 || !arguments 297 .source_revision 298 .bytes() 299 .chain(arguments.source_tree.bytes()) 300 .chain(arguments.execution_request_sha256.bytes()) 301 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) 302 { 303 return Err("Step 299 gate arguments differ".to_owned()); 304 } 305 Ok(check_id) 306 } 307 308 pub(crate) fn run(arguments: Arguments) -> Result<(), String> { 309 let check_id = validate_arguments(&arguments)?; 310 let root = root(); 311 if root.join(".github").exists() { 312 return Err("forbidden .github surface is present".to_owned()); 313 } 314 for (relative, expected) in EXACT_SOURCES { 315 let bytes = fs::read(root.join(relative)) 316 .map_err(|_| "Step 299 governed source is unreadable".to_owned())?; 317 if sha256(&bytes) != *expected { 318 return Err("Step 299 governed source bytes differ".to_owned()); 319 } 320 } 321 322 let verifier_path = root.join("tools/xtask/src/rshr_202_step_299_gate.rs"); 323 let verifier_sha256 = 324 sha256(&fs::read(verifier_path).map_err(|_| "Step 299 verifier is unreadable".to_owned())?); 325 let authority_path = root.join("contracts/rshr-202-step-299-gates.v1.json"); 326 let authority_bytes = 327 fs::read(authority_path).map_err(|_| "Step 299 gate authority is unreadable".to_owned())?; 328 let contract = validate_authority(&authority_bytes, &verifier_sha256)?; 329 330 bounded( 331 Command::new("cargo").args(["+1.97.1", "fmt", "--all", "--", "--check"]), 332 "Step 299 formatting", 333 )?; 334 bounded( 335 Command::new("cargo").args(["+1.97.1", "check", "--offline", "--locked", "-p", "xtask"]), 336 "Step 299 verifier check", 337 )?; 338 require_nix()?; 339 340 let bytes = result_bytes(&arguments, &check_id, &verifier_sha256, &contract)?; 341 std::io::Write::write_all(&mut std::io::stdout().lock(), &bytes) 342 .map_err(|_| "Step 299 result write failed".to_owned()) 343 } 344 345 fn validate_authority(authority_bytes: &[u8], verifier_sha256: &str) -> Result<Value, String> { 346 let authority: Value = serde_json::from_slice(authority_bytes) 347 .map_err(|_| "Step 299 gate authority is invalid".to_owned())?; 348 let mut canonical_authority = canonical(&authority)?; 349 canonical_authority.push(b'\n'); 350 let contracts = authority 351 .get("gate_command_contract") 352 .and_then(Value::as_array) 353 .ok_or_else(|| "Step 299 gate contract is absent".to_owned())?; 354 if authority_bytes != canonical_authority 355 || authority.get("schema") 356 != Some(&Value::String( 357 "radroots.lib.rshr-202-step-299-gates.v1".to_owned(), 358 )) 359 || authority.get("step") != Some(&json!([STEP])) 360 || contracts.as_slice() != [expected_contract(verifier_sha256)] 361 { 362 return Err("Step 299 gate authority differs".to_owned()); 363 } 364 Ok(contracts[0].clone()) 365 } 366 367 fn result_bytes( 368 arguments: &Arguments, 369 check_id: &str, 370 verifier_sha256: &str, 371 contract: &Value, 372 ) -> Result<Vec<u8>, String> { 373 let assertion = json!([{ 374 "id": format!("step_299_gate_01_{GATE_DIGEST}"), 375 "result": "pass" 376 }]); 377 let result = json!({ 378 "schema": "radroots.services-hardening.rshr-200-step-check-result.v1", 379 "step": STEP, 380 "check_id": check_id, 381 "gate_definition_sha256": GATE_DIGEST, 382 "source_revision": arguments.source_revision, 383 "source_tree": arguments.source_tree, 384 "candidate_generation": 0, 385 "candidate_digest": "none", 386 "command_contract_sha256": sha256(&canonical(contract)?), 387 "verifier_sha256": verifier_sha256, 388 "execution_request": [{ 389 "platform": arguments.platform, 390 "sha256": arguments.execution_request_sha256 391 }], 392 "assertion_inventory_sha256": sha256(&canonical(&assertion)?), 393 "assertion": assertion, 394 "result": "pass" 395 }); 396 let mut bytes = canonical(&result)?; 397 bytes.push(b'\n'); 398 Ok(bytes) 399 } 400 401 #[cfg(test)] 402 mod tests { 403 use super::*; 404 405 #[test] 406 fn command_capture_preserves_status_streams_and_enforces_both_output_limits() { 407 let output = bounded( 408 Command::new("/bin/sh").args(["-c", "printf output; printf diagnostic >&2"]), 409 "fixture", 410 ) 411 .unwrap(); 412 assert_eq!(output.stdout, b"output"); 413 assert_eq!(output.stderr, b"diagnostic"); 414 assert_eq!( 415 bounded(Command::new("/bin/sh").args(["-c", "exit 7"]), "fixture").unwrap_err(), 416 "fixture failed" 417 ); 418 let missing = tempfile::TempDir::new().unwrap(); 419 assert_eq!( 420 bounded(&mut Command::new(missing.path().join("absent")), "fixture").unwrap_err(), 421 "fixture could not start" 422 ); 423 for script in ["head -c \"$1\" /dev/zero", "head -c \"$1\" /dev/zero >&2"] { 424 let maximum = MAX_OUTPUT_BYTES.to_string(); 425 let output = bounded( 426 Command::new("/bin/sh").args(["-c", script, "fixture", &maximum]), 427 "fixture", 428 ) 429 .unwrap(); 430 assert_eq!(output.stdout.len() + output.stderr.len(), MAX_OUTPUT_BYTES); 431 let oversized = (MAX_OUTPUT_BYTES + 1).to_string(); 432 assert_eq!( 433 bounded( 434 Command::new("/bin/sh").args(["-c", script, "fixture", &oversized]), 435 "fixture" 436 ) 437 .unwrap_err(), 438 "fixture exceeded its output bound" 439 ); 440 } 441 } 442 443 #[test] 444 fn expected_command_rejection_requires_a_nonzero_exit() { 445 rejected(Command::new("/bin/sh").args(["-c", "exit 7"]), "fixture").unwrap(); 446 assert_eq!( 447 rejected(Command::new("/bin/sh").args(["-c", "exit 0"]), "fixture").unwrap_err(), 448 "fixture unexpectedly succeeded" 449 ); 450 } 451 452 fn arguments() -> Arguments { 453 Arguments { 454 step: STEP, 455 check_id: format!("gate-01-{GATE_DIGEST}"), 456 source_revision: "a".repeat(40), 457 source_tree: "0".repeat(40), 458 candidate_digest: "none".into(), 459 platform: "macos_aarch64".into(), 460 execution_request_sha256: "1".repeat(64), 461 } 462 } 463 464 #[test] 465 fn invalid_gate_arguments_are_rejected_before_external_work() { 466 assert_eq!( 467 validate_arguments(&arguments()).unwrap(), 468 format!("gate-01-{GATE_DIGEST}") 469 ); 470 for field in 0..8 { 471 let mut invalid = arguments(); 472 match field { 473 0 => invalid.step = 0, 474 1 => invalid.check_id.clear(), 475 2 => invalid.candidate_digest = "unbound".into(), 476 3 => invalid.platform = "linux".into(), 477 4 => invalid.source_revision.clear(), 478 5 => invalid.source_tree.clear(), 479 6 => invalid.execution_request_sha256.clear(), 480 _ => invalid.source_revision = "A".repeat(40), 481 } 482 assert_eq!(run(invalid).unwrap_err(), "Step 299 gate arguments differ"); 483 } 484 let mut invalid = arguments(); 485 invalid.source_tree = "g".repeat(40); 486 assert!(validate_arguments(&invalid).is_err()); 487 } 488 489 #[test] 490 fn authority_requires_canonical_bytes_and_exact_retained_bindings() { 491 let raw = include_bytes!("../../../contracts/rshr-202-step-299-gates.v1.json"); 492 let authority: Value = serde_json::from_slice(raw).unwrap(); 493 let verifier = authority["gate_command_contract"][0]["verifier_sha256"] 494 .as_str() 495 .unwrap(); 496 let contract = validate_authority(raw, verifier).unwrap(); 497 assert!(validate_authority(raw, &"f".repeat(64)).is_err()); 498 assert!(validate_authority(b"invalid", verifier).is_err()); 499 assert!(validate_authority(b"{}\n", verifier).is_err()); 500 assert!( 501 validate_authority(&serde_json::to_vec_pretty(&authority).unwrap(), verifier).is_err() 502 ); 503 for (pointer, value) in [ 504 ("/schema", json!("other")), 505 ("/step", json!([0])), 506 ("/gate_command_contract", json!([])), 507 ] { 508 let mut changed = authority.clone(); 509 *changed.pointer_mut(pointer).unwrap() = value; 510 let mut bytes = canonical(&changed).unwrap(); 511 bytes.push(b'\n'); 512 assert!(validate_authority(&bytes, verifier).is_err()); 513 } 514 // Encoding fixtures is not a historical gate execution or qualification. 515 let args = arguments(); 516 let bytes = result_bytes(&args, &args.check_id, verifier, &contract).unwrap(); 517 let result: Value = serde_json::from_slice(&bytes).unwrap(); 518 assert!(bytes.ends_with(b"\n")); 519 assert_eq!(result["source_revision"], args.source_revision); 520 assert_eq!(result["source_tree"], args.source_tree); 521 assert_eq!( 522 result["execution_request"][0]["sha256"], 523 args.execution_request_sha256 524 ); 525 assert_eq!( 526 result["command_contract_sha256"], 527 sha256(&canonical(&contract).unwrap()) 528 ); 529 assert_eq!( 530 result["assertion_inventory_sha256"], 531 sha256(&canonical(&result["assertion"]).unwrap()) 532 ); 533 } 534 535 #[test] 536 fn retained_inventory_rejects_platform_drift_and_fixture_escape_without_nix() { 537 let mut inventory = json!({"overlays":{"default":{"type":"nixpkgs-overlay"}}}); 538 for system in ["aarch64-darwin", "x86_64-linux"] { 539 inventory["packages"][system] = 540 json!({"default":{"name":"radroots-lib-release-bundle-0.1.0-alpha"},"xtask":{}}); 541 inventory["apps"][system] = json!({"default":{"description":"Inspect the installed Radroots Lib release bundle"}}); 542 inventory["devShells"][system] = json!({"default":{}}); 543 inventory["checks"][system] = 544 json!({"release-bundle":{}, "service-fixture-example":{}}); 545 inventory["formatter"][system] = json!({}); 546 } 547 validate_output_inventory(&inventory).unwrap(); 548 for family in ["apps", "checks", "devShells", "formatter", "packages"] { 549 let mut changed = inventory.clone(); 550 changed[family] 551 .as_object_mut() 552 .unwrap() 553 .remove("aarch64-darwin"); 554 assert!(validate_output_inventory(&changed).is_err()); 555 } 556 assert!(object_keys(&Value::Null, "test").is_err()); 557 for (pointer, value) in [ 558 ("/overlays/default/type", json!("wrong")), 559 ("/packages/aarch64-darwin/default/name", json!("wrong")), 560 ("/apps/aarch64-darwin/default/description", json!("wrong")), 561 ("/devShells/aarch64-darwin", json!({})), 562 ("/checks/aarch64-darwin", json!({})), 563 ("/checks/aarch64-darwin", json!({"release-bundle":{}})), 564 ( 565 "/checks/aarch64-darwin", 566 json!({"release-bundle":{},"bad-fixture":{}}), 567 ), 568 ] { 569 let mut changed = inventory.clone(); 570 *changed.pointer_mut(pointer).unwrap() = value; 571 assert!(validate_output_inventory(&changed).is_err()); 572 } 573 for family in ["apps", "devShells", "packages"] { 574 let mut changed = inventory.clone(); 575 changed[family]["aarch64-darwin"]["fixture-escape"] = json!({}); 576 assert!(validate_output_inventory(&changed).is_err()); 577 } 578 } 579 }