cli

Command-line interface for Radroots
git clone https://radroots.dev/git/cli.git
Log | Files | Refs | README | LICENSE

rshr_202_step_302_gate.rs (13833B)


      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 = 302;
     10 const GATE_DIGEST: &str = "79dc2cfbe14c07aeefba9779d61823291c7101d93fece3935909fc13f02261d0";
     11 const LIB_REVISION: &str = "055096853fca95e15d0f813d33a14aca13be3881";
     12 const NIX_SHA256: &str = "a59ab70f97f6d571642d13c7506aafec0a4275520d53daee2d8451be7c495cd1";
     13 const NIX_VERSION_SHA256: &str = "6db806391ffaea4cdb08ade0031feac399c0cd08474b3bfde8cb33f88a36c8e1";
     14 const MAX_OUTPUT_BYTES: usize = 32 * 1024 * 1024;
     15 
     16 const EXACT_SOURCES: &[(&str, &str)] = &[
     17     (
     18         "Cargo.lock",
     19         "e0d86121841778d6ebb802bbfb807e7f74490a03b2a9593b15a7ac39020300ac",
     20     ),
     21     (
     22         "Cargo.toml",
     23         "9ac785043c27dcd5580efd7a4b7bd360706501f11737ce73ad76d8ba99ad9d1e",
     24     ),
     25     (
     26         "flake.lock",
     27         "5d5b11622c341292f429a5f93e55f38a3506431b139720ff62f983b35bf7d55f",
     28     ),
     29     (
     30         "flake.nix",
     31         "376f25cb79d6dfab7a250c44a550a56b136207cde4ef282a71321e0e076fcb63",
     32     ),
     33 ];
     34 
     35 pub(crate) struct Arguments {
     36     pub(crate) step: u16,
     37     pub(crate) check_id: String,
     38     pub(crate) source_revision: String,
     39     pub(crate) source_tree: String,
     40     pub(crate) candidate_digest: String,
     41     pub(crate) platform: String,
     42     pub(crate) execution_request_sha256: String,
     43 }
     44 
     45 fn root() -> PathBuf {
     46     Path::new(env!("CARGO_MANIFEST_DIR"))
     47         .parent()
     48         .and_then(Path::parent)
     49         .expect("xtask must remain under tools/xtask")
     50         .to_path_buf()
     51 }
     52 
     53 fn sha256(bytes: &[u8]) -> String {
     54     hex::encode(Sha256::digest(bytes))
     55 }
     56 
     57 fn canonical(value: &Value) -> Result<Vec<u8>, String> {
     58     serde_json::to_vec(value).map_err(|_| "Step 302 JSON encoding failed".to_owned())
     59 }
     60 
     61 fn execute(command: &mut Command, label: &str) -> Result<Output, String> {
     62     let output = command
     63         .current_dir(root())
     64         .env("CARGO_NET_OFFLINE", "true")
     65         .env("CARGO_TERM_COLOR", "never")
     66         .output()
     67         .map_err(|_| format!("{label} could not start"))?;
     68     if output.stdout.len() > MAX_OUTPUT_BYTES || output.stderr.len() > MAX_OUTPUT_BYTES {
     69         return Err(format!("{label} exceeded its output bound"));
     70     }
     71     Ok(output)
     72 }
     73 
     74 fn bounded(command: &mut Command, label: &str) -> Result<Output, String> {
     75     let output = execute(command, label)?;
     76     if !output.status.success() {
     77         return Err(format!("{label} failed"));
     78     }
     79     Ok(output)
     80 }
     81 
     82 fn rejected(command: &mut Command, label: &str) -> Result<(), String> {
     83     if execute(command, label)?.status.success() {
     84         return Err(format!("{label} unexpectedly succeeded"));
     85     }
     86     Ok(())
     87 }
     88 
     89 fn resolve_nix() -> Result<PathBuf, String> {
     90     if let Some(explicit) = env::var_os("RSHR_NIX_EXECUTABLE") {
     91         return fs::canonicalize(explicit)
     92             .map_err(|_| "Step 302 Nix client is unavailable".to_owned());
     93     }
     94     let path = env::var_os("PATH").ok_or_else(|| "Step 302 PATH is absent".to_owned())?;
     95     env::split_paths(&path)
     96         .map(|directory| directory.join("nix"))
     97         .find(|candidate| candidate.is_file())
     98         .and_then(|candidate| fs::canonicalize(candidate).ok())
     99         .ok_or_else(|| "Step 302 Nix client is unavailable".to_owned())
    100 }
    101 
    102 fn object_keys(value: &Value, label: &str) -> Result<Vec<String>, String> {
    103     let mut keys = value
    104         .as_object()
    105         .ok_or_else(|| format!("Step 302 {label} is not an object"))?
    106         .keys()
    107         .cloned()
    108         .collect::<Vec<_>>();
    109     keys.sort_unstable();
    110     Ok(keys)
    111 }
    112 
    113 fn require_lock() -> Result<(), String> {
    114     let lock: Value = serde_json::from_slice(
    115         &fs::read(root().join("flake.lock"))
    116             .map_err(|_| "Step 302 flake lock is unreadable".to_owned())?,
    117     )
    118     .map_err(|_| "Step 302 flake lock is invalid".to_owned())?;
    119     if lock.pointer("/nodes/root/inputs/lib") != Some(&json!("lib"))
    120         || lock.pointer("/nodes/lib/locked/rev") != Some(&json!(LIB_REVISION))
    121         || lock.pointer("/nodes/lib/original/rev") != Some(&json!(LIB_REVISION))
    122     {
    123         return Err("Step 302 exact Nix tooling input differs".to_owned());
    124     }
    125     Ok(())
    126 }
    127 
    128 fn require_outputs(nix: &Path) -> Result<(), String> {
    129     let show = bounded(
    130         Command::new(nix).args([
    131             "--offline",
    132             "flake",
    133             "show",
    134             "--json",
    135             "--all-systems",
    136             "--no-write-lock-file",
    137         ]),
    138         "Step 302 Nix output inventory",
    139     )?;
    140     let inventory: Value = serde_json::from_slice(&show.stdout)
    141         .map_err(|_| "Step 302 Nix output inventory is invalid".to_owned())?;
    142     if object_keys(&inventory, "root output inventory")? != ["apps", "checks", "packages"] {
    143         return Err("Step 302 root output inventory differs".to_owned());
    144     }
    145     let systems = ["aarch64-darwin", "x86_64-linux"];
    146     for family in ["apps", "checks", "packages"] {
    147         if object_keys(&inventory[family], family)? != systems {
    148             return Err(format!("Step 302 {family} systems differ"));
    149         }
    150     }
    151     for system in systems {
    152         if object_keys(&inventory["apps"][system], "apps")? != ["default"]
    153             || object_keys(&inventory["checks"][system], "checks")? != ["default"]
    154             || object_keys(&inventory["packages"][system], "packages")? != ["default"]
    155             || inventory["apps"][system]["default"]["description"] != "Run the built radroots CLI"
    156             || inventory["checks"][system]["default"]["name"] != "radroots-cli-check-1"
    157             || inventory["packages"][system]["default"]["name"] != "radroots_cli-0.1.0"
    158         {
    159             return Err("Step 302 CLI output inventory differs".to_owned());
    160         }
    161     }
    162     for system in ["x86_64-darwin", "aarch64-linux", "x86_64-windows"] {
    163         rejected(
    164             Command::new(nix).args([
    165                 "--offline",
    166                 "eval",
    167                 "--raw",
    168                 &format!(".#packages.{system}.default.name"),
    169             ]),
    170             "Step 302 excluded-system evaluation",
    171         )?;
    172     }
    173     for attribute in [
    174         ".#devShells.aarch64-darwin.default.name",
    175         ".#nixosModules.default",
    176         ".#packages.x86_64-linux.oci.name",
    177     ] {
    178         rejected(
    179             Command::new(nix).args(["--offline", "eval", "--raw", attribute]),
    180             "Step 302 unowned output evaluation",
    181         )?;
    182     }
    183     Ok(())
    184 }
    185 
    186 fn require_nix() -> Result<(), String> {
    187     let executable = resolve_nix()?;
    188     if sha256(&fs::read(&executable).map_err(|_| "Step 302 Nix client is unreadable")?)
    189         != NIX_SHA256
    190     {
    191         return Err("Step 302 Nix client identity differs".to_owned());
    192     }
    193     let version = bounded(
    194         Command::new(&executable).arg("--version"),
    195         "Step 302 Nix version",
    196     )?;
    197     if sha256(&version.stdout) != NIX_VERSION_SHA256 {
    198         return Err("Step 302 Nix version differs".to_owned());
    199     }
    200     bounded(
    201         Command::new(&executable).args([
    202             "--offline",
    203             "flake",
    204             "check",
    205             "--all-systems",
    206             "--no-build",
    207             "--no-write-lock-file",
    208         ]),
    209         "Step 302 Nix evaluation",
    210     )?;
    211     require_outputs(&executable)
    212 }
    213 
    214 fn expected_contract(verifier_sha256: &str) -> Value {
    215     json!({
    216         "argv_template": [
    217             "cargo", "extbuild", "run", "--", "cargo", "run", "--offline", "--locked",
    218             "-q", "-p", "radroots_cli_xtask", "--", "rshr-step-302-gate", "--step={step}",
    219             "--check-id={check_id}", "--source-revision={source_revision}",
    220             "--source-tree={source_tree}", "--candidate-digest={candidate_digest}",
    221             "--platform=macos_aarch64",
    222             "--execution-request-sha256={execution_request_sha256}"
    223         ],
    224         "assertion_id": [format!("step_302_gate_01_{GATE_DIGEST}")],
    225         "check_id": format!("gate-01-{GATE_DIGEST}"),
    226         "environment_authority": {
    227             "cache_policy_id": "rshr-200-step-287-cache-policy.v1",
    228             "cache_policy_sha256": "3e81d178bce97b6c349dfbb00c68fd6f620ac00b1a1c8d37b12e9998f3c9eaaa",
    229             "cadence_policy_id": "rshr-200-step-287-cadence-policy.v1",
    230             "cadence_policy_sha256": "d24903df8659ee3772297c84994911efe7d21cb8b988320ddc6ddce0431892a1",
    231             "isolation": "extbuild_host_constrained",
    232             "network": "disabled",
    233             "network_policy_id": "none",
    234             "network_policy_sha256": "none",
    235             "resource_policy_id": "rshr-200-step-287-resource-policy.v1",
    236             "resource_policy_sha256": "05d3c7a89185d3c55678d97955193fce2ed92b1eee5af99083d77ea64c98d14e"
    237         },
    238         "environment_names": [
    239             "EXT_BUILD_CONFIG", "EXT_BUILD_MACHINE_CONFIG", "EXT_BUILD_ROOT", "HOME", "PATH",
    240             "RUSTUP_TOOLCHAIN", "TMPDIR"
    241         ],
    242         "gate_definition_sha256": GATE_DIGEST,
    243         "required_platforms": ["macos_aarch64"],
    244         "required_tools": ["rustc"],
    245         "result_schema": "radroots.services-hardening.rshr-200-step-check-result.v1",
    246         "schema": "radroots.services-hardening.rshr-200-step-check-command.v1",
    247         "step": STEP,
    248         "verifier_path": "tools/xtask/src/rshr_202_step_302_gate.rs",
    249         "verifier_sha256": verifier_sha256
    250     })
    251 }
    252 
    253 pub(crate) fn run(arguments: Arguments) -> Result<(), String> {
    254     let check_id = format!("gate-01-{GATE_DIGEST}");
    255     if arguments.step != STEP
    256         || arguments.check_id != check_id
    257         || arguments.candidate_digest != "none"
    258         || arguments.platform != "macos_aarch64"
    259         || arguments.source_revision.len() != 40
    260         || arguments.source_tree.len() != 40
    261         || arguments.execution_request_sha256.len() != 64
    262         || !arguments
    263             .source_revision
    264             .bytes()
    265             .chain(arguments.source_tree.bytes())
    266             .chain(arguments.execution_request_sha256.bytes())
    267             .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
    268     {
    269         return Err("Step 302 gate arguments differ".to_owned());
    270     }
    271     let root = root();
    272     if root.join(".github").exists() || root.join(".act").exists() || root.join("docs").exists() {
    273         return Err("Step 302 forbidden repository root is present".to_owned());
    274     }
    275     for (relative, expected) in EXACT_SOURCES {
    276         let bytes = fs::read(root.join(relative))
    277             .map_err(|_| "Step 302 governed source is unreadable".to_owned())?;
    278         if sha256(&bytes) != *expected {
    279             return Err("Step 302 governed source bytes differ".to_owned());
    280         }
    281     }
    282     let flake_source = fs::read_to_string(root.join("flake.nix"))
    283         .map_err(|_| "Step 302 flake source is unreadable".to_owned())?;
    284     for forbidden in [
    285         "writeShellApplication",
    286         "git rev-parse",
    287         "repo_root",
    288         "devShells",
    289         "nixosModules",
    290         "aarch64-linux",
    291         "x86_64-darwin",
    292     ] {
    293         if flake_source.contains(forbidden) {
    294             return Err("Step 302 checkout wrapper or unowned output is present".to_owned());
    295         }
    296     }
    297 
    298     let verifier_path = root.join("tools/xtask/src/rshr_202_step_302_gate.rs");
    299     let verifier_sha256 =
    300         sha256(&fs::read(verifier_path).map_err(|_| "Step 302 verifier is unreadable")?);
    301     let authority_path = root.join("contracts/rshr-202-step-302-gates.v1.json");
    302     let authority_bytes =
    303         fs::read(authority_path).map_err(|_| "Step 302 gate authority is unreadable")?;
    304     let authority: Value = serde_json::from_slice(&authority_bytes)
    305         .map_err(|_| "Step 302 gate authority is invalid".to_owned())?;
    306     let mut canonical_authority = canonical(&authority)?;
    307     canonical_authority.push(b'\n');
    308     let contracts = authority
    309         .get("gate_command_contract")
    310         .and_then(Value::as_array)
    311         .ok_or_else(|| "Step 302 gate contract is absent".to_owned())?;
    312     if authority_bytes != canonical_authority
    313         || authority.get("schema")
    314             != Some(&Value::String(
    315                 "radroots.cli.rshr-202-step-302-gates.v1".to_owned(),
    316             ))
    317         || authority.get("step") != Some(&json!([STEP]))
    318         || contracts.as_slice() != [expected_contract(&verifier_sha256)]
    319     {
    320         return Err("Step 302 gate authority differs".to_owned());
    321     }
    322 
    323     require_lock()?;
    324     bounded(
    325         Command::new("cargo").args(["+1.97.1", "fmt", "--all", "--", "--check"]),
    326         "Step 302 formatting",
    327     )?;
    328     bounded(
    329         Command::new("cargo").args([
    330             "+1.97.1",
    331             "check",
    332             "--offline",
    333             "--locked",
    334             "--workspace",
    335             "--all-targets",
    336         ]),
    337         "Step 302 Cargo check",
    338     )?;
    339     require_nix()?;
    340 
    341     let contract = &contracts[0];
    342     let assertion = json!([{
    343         "id": format!("step_302_gate_01_{GATE_DIGEST}"),
    344         "result": "pass"
    345     }]);
    346     let result = json!({
    347         "schema": "radroots.services-hardening.rshr-200-step-check-result.v1",
    348         "step": STEP,
    349         "check_id": check_id,
    350         "gate_definition_sha256": GATE_DIGEST,
    351         "source_revision": arguments.source_revision,
    352         "source_tree": arguments.source_tree,
    353         "candidate_generation": 0,
    354         "candidate_digest": "none",
    355         "command_contract_sha256": sha256(&canonical(contract)?),
    356         "verifier_sha256": verifier_sha256,
    357         "execution_request": [{
    358             "platform": arguments.platform,
    359             "sha256": arguments.execution_request_sha256
    360         }],
    361         "assertion_inventory_sha256": sha256(&canonical(&assertion)?),
    362         "assertion": assertion,
    363         "result": "pass"
    364     });
    365     let mut bytes = canonical(&result)?;
    366     bytes.push(b'\n');
    367     std::io::Write::write_all(&mut std::io::stdout().lock(), &bytes)
    368         .map_err(|_| "Step 302 result write failed".to_owned())
    369 }