Skip to main content

runx_cli/
verify.rs

1// rust-style-allow: large-file - verify owns legacy receipt-tree checks plus the new single-receipt machine verdict.
2use std::collections::{BTreeMap, BTreeSet};
3use std::ffi::OsString;
4use std::fmt;
5use std::fs;
6use std::io::{self, Read};
7use std::path::{Path, PathBuf};
8
9use base64::Engine;
10use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
11use ring::signature::{ED25519, UnparsedPublicKey};
12use runx_contracts::{Receipt, Reference, ReferenceType, sha256_prefixed};
13use runx_receipts::{
14    ReceiptProofContext, ReceiptVerifySignatureMode, ReceiptVerifyVerdict,
15    SignatureVerificationFailure, SignatureVerifier, verify_receipt_document_verdict,
16};
17use runx_runtime::{
18    Ed25519ReceiptVerifier, ReceiptPathInputs, ReceiptTreeConfig, RuntimeReceiptConfig,
19    RuntimeReceiptSignaturePolicy, resolve_receipt_path, verify_runtime_receipt_tree_with_policy,
20};
21use serde::Serialize;
22
23use crate::history::{
24    RUNX_RECEIPT_VERIFY_ED25519_PUBLIC_KEY_BASE64_ENV, RUNX_RECEIPT_VERIFY_KID_ENV,
25};
26
27const RECEIPT_REFERENCE_PREFIX: &str = "runx:receipt:";
28const SINGLE_RECEIPT_MAX_BYTES: usize = 10 * 1024 * 1024;
29
30#[derive(Debug)]
31pub enum VerifyCliError {
32    InvalidArgs(String),
33    InvalidReceiptVerifier(String),
34    Store(String),
35    Serialize(serde_json::Error),
36}
37
38impl fmt::Display for VerifyCliError {
39    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
40        match self {
41            Self::InvalidArgs(message) | Self::InvalidReceiptVerifier(message) => {
42                formatter.write_str(message)
43            }
44            Self::Store(message) => formatter.write_str(message),
45            Self::Serialize(error) => write!(formatter, "failed to serialize report: {error}"),
46        }
47    }
48}
49
50impl std::error::Error for VerifyCliError {}
51
52#[derive(Clone, Debug, PartialEq, Eq)]
53pub struct VerifyCliResult {
54    pub output: String,
55    pub failed: bool,
56}
57
58#[derive(Clone, Debug, Default, PartialEq, Eq)]
59struct ParsedVerifyArgs {
60    receipt_id: Option<String>,
61    receipt_dir: Option<PathBuf>,
62    receipt: Option<ReceiptInput>,
63    notary: Option<ReceiptInput>,
64    notary_keys: Vec<PathBuf>,
65    allow_local_development_signatures: bool,
66    json: bool,
67}
68
69#[derive(Clone, Debug, PartialEq, Eq)]
70enum ReceiptInput {
71    Path(PathBuf),
72    Stdin,
73}
74
75#[derive(Clone, Debug, Serialize)]
76struct VerifyReport {
77    receipt_dir: String,
78    signature_mode: &'static str,
79    trees: Vec<TreeReport>,
80    unreadable_files: Vec<FileIssue>,
81    valid: bool,
82}
83
84#[derive(Clone, Debug, Serialize)]
85struct TreeReport {
86    root_receipt_id: String,
87    receipt_count: usize,
88    parent_missing: Option<String>,
89    valid: bool,
90    findings: Vec<FindingReport>,
91}
92
93#[derive(Clone, Debug, Serialize)]
94struct FindingReport {
95    code: String,
96    path: String,
97    message: String,
98}
99
100#[derive(Clone, Debug, Serialize)]
101struct FileIssue {
102    file: String,
103    message: String,
104}
105
106#[derive(Clone, Debug, Serialize)]
107struct NotaryVerifyVerdict {
108    schema: &'static str,
109    valid: bool,
110    counter_seal: NotaryCounterSealReport,
111    findings: Vec<FindingReport>,
112}
113
114#[derive(Clone, Debug, Serialize)]
115struct NotaryCounterSealReport {
116    schema: Option<String>,
117    digest_status: &'static str,
118    signature_status: &'static str,
119    trusted_key_count: usize,
120}
121
122pub fn run_verify_command(
123    args: &[OsString],
124    env: &BTreeMap<String, String>,
125    cwd: &Path,
126) -> Result<VerifyCliResult, VerifyCliError> {
127    run_verify_command_with_stdin(args, env, cwd, io::empty())
128}
129
130// rust-style-allow: long-function - argument dispatch keeps tree and single-receipt modes mutually exclusive in one parser.
131pub fn run_verify_command_with_stdin<R: Read>(
132    args: &[OsString],
133    env: &BTreeMap<String, String>,
134    cwd: &Path,
135    stdin: R,
136) -> Result<VerifyCliResult, VerifyCliError> {
137    let parsed = parse_verify_args(args)?;
138    if let Some(input) = parsed.notary.as_ref() {
139        return run_notary_verify(input, &parsed.notary_keys, parsed.json, cwd, stdin);
140    }
141    if let Some(input) = parsed.receipt.as_ref() {
142        return run_single_receipt_verify(
143            input,
144            parsed.json,
145            parsed.allow_local_development_signatures,
146            env,
147            cwd,
148            stdin,
149        );
150    }
151    let receipt_config = RuntimeReceiptConfig::default();
152    let resolved = resolve_receipt_path(ReceiptPathInputs {
153        explicit_dir: parsed.receipt_dir.as_deref(),
154        runtime_config: Some(&receipt_config),
155        env,
156        cwd,
157    });
158    let verifier = receipt_verifier(env, parsed.allow_local_development_signatures)?;
159    let signature_mode = if verifier.is_some() {
160        "production"
161    } else {
162        "local-development"
163    };
164
165    let (receipts, unreadable_files) = load_receipts(&resolved.path)?;
166    let trees = group_trees(&receipts);
167
168    let selected: Vec<&ReceiptTree> = match parsed.receipt_id.as_deref() {
169        Some(receipt_id) => {
170            let tree = trees
171                .iter()
172                .find(|tree| tree.member_ids.contains(receipt_id))
173                .ok_or_else(|| {
174                    VerifyCliError::InvalidArgs(format!(
175                        "receipt {receipt_id} was not found in {}",
176                        resolved.path.display()
177                    ))
178                })?;
179            vec![tree]
180        }
181        None => trees.iter().collect(),
182    };
183
184    let mut tree_reports = Vec::new();
185    for tree in selected {
186        let policy = match verifier.as_ref() {
187            Some(verifier) => RuntimeReceiptSignaturePolicy::production(verifier),
188            None => RuntimeReceiptSignaturePolicy::local_development(),
189        };
190        // Supplied receipts are the candidate children; the root itself is the
191        // traversal anchor and must not be offered as its own child.
192        let members: Vec<Receipt> = tree
193            .member_ids
194            .iter()
195            .filter(|id| id.as_str() != tree.root.id.as_str())
196            .filter_map(|id| receipts.iter().find(|receipt| receipt.id.as_str() == id))
197            .cloned()
198            .collect();
199        let verification = verify_runtime_receipt_tree_with_policy(
200            &tree.root,
201            members,
202            ReceiptTreeConfig::default(),
203            policy,
204        );
205        let valid = verification.valid && tree.parent_missing.is_none();
206        tree_reports.push(TreeReport {
207            root_receipt_id: tree.root.id.to_string(),
208            receipt_count: tree.member_ids.len(),
209            parent_missing: tree.parent_missing.clone(),
210            valid,
211            findings: verification
212                .findings
213                .into_iter()
214                .map(|finding| FindingReport {
215                    code: format!("{:?}", finding.code),
216                    path: finding.path,
217                    message: finding.message,
218                })
219                .collect(),
220        });
221    }
222
223    let valid = tree_reports.iter().all(|tree| tree.valid) && unreadable_files.is_empty();
224    let report = VerifyReport {
225        receipt_dir: resolved.path.display().to_string(),
226        signature_mode,
227        trees: tree_reports,
228        unreadable_files,
229        valid,
230    };
231
232    let output = if parsed.json {
233        format!(
234            "{}\n",
235            serde_json::to_string_pretty(&report).map_err(VerifyCliError::Serialize)?
236        )
237    } else {
238        render_report(&report)
239    };
240    Ok(VerifyCliResult {
241        output,
242        failed: !report.valid,
243    })
244}
245
246// rust-style-allow: long-function - verify accepts legacy receipt-tree flags and
247// the single-receipt machine surface in one mutually-exclusive parser.
248fn parse_verify_args(args: &[OsString]) -> Result<ParsedVerifyArgs, VerifyCliError> {
249    let mut parsed = ParsedVerifyArgs::default();
250    let mut iter = args.iter().skip(1);
251    while let Some(arg) = iter.next() {
252        let Some(text) = arg.to_str() else {
253            return Err(invalid_args("arguments must be valid UTF-8"));
254        };
255        match text {
256            "--json" | "-j" => parsed.json = true,
257            "--allow-local-development-signatures" => {
258                parsed.allow_local_development_signatures = true;
259            }
260            "--receipt-dir" => {
261                let value = iter
262                    .next()
263                    .ok_or_else(|| invalid_args("--receipt-dir requires a directory"))?;
264                parsed.receipt_dir = Some(PathBuf::from(value));
265            }
266            "--receipt" => {
267                let value = iter
268                    .next()
269                    .ok_or_else(|| invalid_args("--receipt requires a path or -"))?;
270                parsed.receipt = Some(parse_receipt_input(value)?);
271            }
272            "--notary" => {
273                let value = iter
274                    .next()
275                    .ok_or_else(|| invalid_args("--notary requires a path or -"))?;
276                parsed.notary = Some(parse_receipt_input(value)?);
277            }
278            "--notary-key" => {
279                let value = iter.next().ok_or_else(|| {
280                    invalid_args("--notary-key requires a trusted public key PEM path")
281                })?;
282                parsed.notary_keys.push(PathBuf::from(value));
283            }
284            other if other.starts_with("--receipt=") => {
285                let value = other.trim_start_matches("--receipt=");
286                parsed.receipt = Some(parse_receipt_input_text(value)?);
287            }
288            other if other.starts_with("--notary=") => {
289                let value = other.trim_start_matches("--notary=");
290                parsed.notary = Some(parse_receipt_input_text(value)?);
291            }
292            other if other.starts_with("--notary-key=") => {
293                let value = other.trim_start_matches("--notary-key=");
294                if value.is_empty() {
295                    return Err(invalid_args(
296                        "--notary-key requires a trusted public key PEM path",
297                    ));
298                }
299                parsed.notary_keys.push(PathBuf::from(value));
300            }
301            other if other.starts_with("--") => {
302                return Err(invalid_args(format!("unknown verify flag {other}")));
303            }
304            other => {
305                if parsed.receipt_id.is_some() {
306                    return Err(invalid_args("verify accepts at most one receipt id"));
307                }
308                parsed.receipt_id = Some(other.to_owned());
309            }
310        }
311    }
312    if parsed.receipt.is_some() && (parsed.receipt_id.is_some() || parsed.receipt_dir.is_some()) {
313        return Err(invalid_args(
314            "--receipt cannot be combined with a receipt id or --receipt-dir",
315        ));
316    }
317    if parsed.notary.is_some()
318        && (parsed.receipt.is_some() || parsed.receipt_id.is_some() || parsed.receipt_dir.is_some())
319    {
320        return Err(invalid_args(
321            "--notary cannot be combined with --receipt, a receipt id, or --receipt-dir",
322        ));
323    }
324    if parsed.notary.is_none() && !parsed.notary_keys.is_empty() {
325        return Err(invalid_args("--notary-key requires --notary"));
326    }
327    if parsed.notary.is_some() && parsed.notary_keys.is_empty() {
328        return Err(invalid_args(
329            "--notary requires at least one external trusted public key via --notary-key",
330        ));
331    }
332    if parsed.notary.is_some() && parsed.allow_local_development_signatures {
333        return Err(invalid_args(
334            "--allow-local-development-signatures is only valid for receipt verification",
335        ));
336    }
337    Ok(parsed)
338}
339
340fn parse_receipt_input(value: &OsString) -> Result<ReceiptInput, VerifyCliError> {
341    let Some(text) = value.to_str() else {
342        return Err(invalid_args("--receipt path must be valid UTF-8"));
343    };
344    parse_receipt_input_text(text)
345}
346
347fn parse_receipt_input_text(value: &str) -> Result<ReceiptInput, VerifyCliError> {
348    if value.is_empty() {
349        return Err(invalid_args("--receipt requires a path or -"));
350    }
351    Ok(if value == "-" {
352        ReceiptInput::Stdin
353    } else {
354        ReceiptInput::Path(PathBuf::from(value))
355    })
356}
357
358fn run_single_receipt_verify<R: Read>(
359    input: &ReceiptInput,
360    json: bool,
361    allow_local_development_signatures: bool,
362    env: &BTreeMap<String, String>,
363    cwd: &Path,
364    stdin: R,
365) -> Result<VerifyCliResult, VerifyCliError> {
366    let document = read_single_receipt_input(input, cwd, stdin)?;
367    // A malformed document yields a parse-error verdict without requiring trust
368    // keys: you do not need a verifier to report that bytes are not a receipt.
369    // Keys are required only to verify the signature of a well-formed receipt.
370    let verifier = if serde_json::from_slice::<Receipt>(&document).is_ok() {
371        receipt_verifier(env, allow_local_development_signatures)?
372    } else {
373        None
374    };
375    let local_verifier = LocalDevelopmentReceiptVerifier;
376    let (signature_mode, signature_verifier): (ReceiptVerifySignatureMode, &dyn SignatureVerifier) =
377        match verifier.as_ref() {
378            Some(verifier) => (ReceiptVerifySignatureMode::Production, verifier),
379            None => (
380                ReceiptVerifySignatureMode::LocalDevelopment,
381                &local_verifier,
382            ),
383        };
384    let context = ReceiptProofContext {
385        signature_verifier: Some(signature_verifier),
386        authority_verified: false,
387        external_attestations_verified: false,
388        verified_redaction_refs: BTreeSet::new(),
389        verified_hash_commitments: BTreeSet::new(),
390    };
391    let verdict = verify_receipt_document_verdict(&document, &context, signature_mode);
392    let output = if json {
393        format!(
394            "{}\n",
395            serde_json::to_string_pretty(&verdict).map_err(VerifyCliError::Serialize)?
396        )
397    } else {
398        render_single_receipt_verdict(&verdict)
399    };
400    Ok(VerifyCliResult {
401        output,
402        failed: !verdict.valid,
403    })
404}
405
406/// Resolve a possibly-relative user-supplied path against `cwd`. The single
407/// resolver for receipt and notary-key file inputs, so both stay consistent.
408fn resolve_under_cwd(path: &Path, cwd: &Path) -> PathBuf {
409    if path.is_absolute() {
410        path.to_path_buf()
411    } else {
412        cwd.join(path)
413    }
414}
415
416fn read_single_receipt_input<R: Read>(
417    input: &ReceiptInput,
418    cwd: &Path,
419    stdin: R,
420) -> Result<Vec<u8>, VerifyCliError> {
421    match input {
422        ReceiptInput::Path(path) => {
423            let path = resolve_under_cwd(path, cwd);
424            if let Ok(metadata) = fs::metadata(&path) {
425                if metadata.len() > SINGLE_RECEIPT_MAX_BYTES as u64 {
426                    return Err(single_receipt_too_large());
427                }
428            }
429            let document = fs::read(&path).map_err(|error| {
430                VerifyCliError::Store(format!(
431                    "failed to read receipt {}: {error}",
432                    path.display()
433                ))
434            })?;
435            if document.len() > SINGLE_RECEIPT_MAX_BYTES {
436                return Err(single_receipt_too_large());
437            }
438            Ok(document)
439        }
440        ReceiptInput::Stdin => read_limited_stdin(stdin),
441    }
442}
443
444fn read_limited_stdin<R: Read>(stdin: R) -> Result<Vec<u8>, VerifyCliError> {
445    let mut limited = stdin.take((SINGLE_RECEIPT_MAX_BYTES + 1) as u64);
446    let mut document = Vec::new();
447    limited.read_to_end(&mut document).map_err(|error| {
448        VerifyCliError::Store(format!("failed to read receipt from stdin: {error}"))
449    })?;
450    if document.len() > SINGLE_RECEIPT_MAX_BYTES {
451        return Err(single_receipt_too_large());
452    }
453    Ok(document)
454}
455
456fn run_notary_verify<R: Read>(
457    input: &ReceiptInput,
458    trusted_key_paths: &[PathBuf],
459    json: bool,
460    cwd: &Path,
461    stdin: R,
462) -> Result<VerifyCliResult, VerifyCliError> {
463    let document = read_single_receipt_input(input, cwd, stdin)?;
464    let trusted_keys = trusted_notary_keys_from_paths(trusted_key_paths, cwd)?;
465    let verdict = verify_notary_document(&document, &trusted_keys);
466    let output = if json {
467        format!(
468            "{}\n",
469            serde_json::to_string_pretty(&verdict).map_err(VerifyCliError::Serialize)?
470        )
471    } else {
472        render_notary_verdict(&verdict)
473    };
474    Ok(VerifyCliResult {
475        output,
476        failed: !verdict.valid,
477    })
478}
479
480// rust-style-allow: long-function - hosted notary verification traverses one
481// public projection and accumulates all findings for operator diagnostics.
482fn verify_notary_document(document: &[u8], trusted_keys: &[Vec<u8>]) -> NotaryVerifyVerdict {
483    let mut findings = Vec::new();
484    let root = match serde_json::from_slice::<serde_json::Value>(document) {
485        Ok(value) => value,
486        Err(error) => {
487            findings.push(finding(
488                "notary_parse_error",
489                "$",
490                format!("notary verification document is not valid JSON: {error}"),
491            ));
492            return notary_verdict(None, "missing", "missing", 0, findings);
493        }
494    };
495    let Some(notary) = locate_notary_verification(&root) else {
496        findings.push(finding(
497            "notary_verification_missing",
498            "$",
499            "notary verification document must contain notary_verification or receipt.notary_verification",
500        ));
501        return notary_verdict(None, "missing", "missing", 0, findings);
502    };
503    let Some(counter_seal) = notary
504        .get("counter_seal")
505        .and_then(serde_json::Value::as_object)
506    else {
507        findings.push(finding(
508            "counter_seal_missing",
509            "notary_verification.counter_seal",
510            "notary verification is missing counter_seal",
511        ));
512        return notary_verdict(None, "missing", "missing", trusted_keys.len(), findings);
513    };
514    let schema = counter_seal
515        .get("schema")
516        .and_then(serde_json::Value::as_str)
517        .map(ToOwned::to_owned);
518    let Some(payload) = counter_seal.get("payload") else {
519        findings.push(finding(
520            "counter_seal_payload_missing",
521            "notary_verification.counter_seal.payload",
522            "counter seal is missing its signed payload",
523        ));
524        return notary_verdict(schema, "missing", "missing", trusted_keys.len(), findings);
525    };
526    let canonical_payload = match serde_json::to_string(payload) {
527        Ok(value) => value,
528        Err(error) => {
529            findings.push(finding(
530                "counter_seal_payload_invalid",
531                "notary_verification.counter_seal.payload",
532                format!("counter seal payload cannot be canonicalized: {error}"),
533            ));
534            return notary_verdict(schema, "invalid", "missing", trusted_keys.len(), findings);
535        }
536    };
537    let expected_digest = sha256_prefixed(canonical_payload.as_bytes());
538    let digest_status = match counter_seal
539        .get("digest")
540        .and_then(serde_json::Value::as_str)
541    {
542        Some(actual) if actual == expected_digest => "valid",
543        Some(_) => {
544            findings.push(finding(
545                "counter_seal_digest_mismatch",
546                "notary_verification.counter_seal.digest",
547                "counter seal digest does not match the canonical payload",
548            ));
549            "invalid"
550        }
551        None => {
552            findings.push(finding(
553                "counter_seal_digest_missing",
554                "notary_verification.counter_seal.digest",
555                "counter seal is missing digest",
556            ));
557            "missing"
558        }
559    };
560    bind_counter_seal_to_projection(&root, payload, counter_seal, &mut findings);
561    let signature_status = verify_counter_seal_signature(
562        notary,
563        counter_seal,
564        &canonical_payload,
565        trusted_keys,
566        &mut findings,
567    );
568    let trusted_key_count = trusted_keys.len();
569    notary_verdict(
570        schema,
571        digest_status,
572        signature_status,
573        trusted_key_count,
574        findings,
575    )
576}
577
578fn locate_notary_verification(
579    root: &serde_json::Value,
580) -> Option<&serde_json::Map<String, serde_json::Value>> {
581    root.get("receipt")
582        .and_then(|receipt| receipt.get("notary_verification"))
583        .or_else(|| root.get("notary_verification"))
584        .and_then(serde_json::Value::as_object)
585}
586
587// rust-style-allow: long-function - counter-seal validation intentionally binds
588// digest, public key, signature, and trust-key diagnostics in one check.
589fn verify_counter_seal_signature(
590    notary: &serde_json::Map<String, serde_json::Value>,
591    counter_seal: &serde_json::Map<String, serde_json::Value>,
592    canonical_payload: &str,
593    trusted_keys: &[Vec<u8>],
594    findings: &mut Vec<FindingReport>,
595) -> &'static str {
596    let Some(signature) = counter_seal
597        .get("signature")
598        .and_then(serde_json::Value::as_object)
599    else {
600        findings.push(finding(
601            "counter_seal_signature_missing",
602            "notary_verification.counter_seal.signature",
603            "counter seal is missing signature",
604        ));
605        return "missing";
606    };
607    if signature.get("alg").and_then(serde_json::Value::as_str) != Some("Ed25519") {
608        findings.push(finding(
609            "counter_seal_signature_algorithm_unsupported",
610            "notary_verification.counter_seal.signature.alg",
611            "counter seal signature algorithm must be Ed25519",
612        ));
613        return "invalid";
614    }
615    let Some(signature_value) = signature.get("value").and_then(serde_json::Value::as_str) else {
616        findings.push(finding(
617            "counter_seal_signature_value_missing",
618            "notary_verification.counter_seal.signature.value",
619            "counter seal signature value is missing",
620        ));
621        return "missing";
622    };
623    let signature_bytes = match decode_signature(signature_value) {
624        Ok(bytes) if bytes.len() == 64 => bytes,
625        Ok(_) | Err(_) => {
626            findings.push(finding(
627                "counter_seal_signature_malformed",
628                "notary_verification.counter_seal.signature.value",
629                "counter seal signature is not a valid Ed25519 signature",
630            ));
631            return "invalid";
632        }
633    };
634    if trusted_keys.is_empty() {
635        findings.push(finding(
636            "notary_trusted_key_missing",
637            "--notary-key",
638            "notary verification requires at least one external trusted Ed25519 public key",
639        ));
640        return "missing";
641    }
642    record_embedded_key_mismatch(notary, trusted_keys, findings);
643    if trusted_keys.iter().any(|key| {
644        UnparsedPublicKey::new(&ED25519, key)
645            .verify(canonical_payload.as_bytes(), &signature_bytes)
646            .is_ok()
647    }) {
648        return "valid";
649    }
650    findings.push(finding(
651        "counter_seal_signature_mismatch",
652        "notary_verification.counter_seal.signature",
653        "counter seal signature did not verify against any external trusted notary key",
654    ));
655    "invalid"
656}
657
658fn trusted_notary_keys_from_paths(
659    paths: &[PathBuf],
660    cwd: &Path,
661) -> Result<Vec<Vec<u8>>, VerifyCliError> {
662    let mut keys = Vec::with_capacity(paths.len());
663    for path in paths {
664        let path = resolve_under_cwd(path, cwd);
665        let pem = fs::read_to_string(&path).map_err(|error| {
666            VerifyCliError::Store(format!(
667                "failed to read notary key {}: {error}",
668                path.display()
669            ))
670        })?;
671        keys.push(ed25519_public_key_from_spki_pem(&pem).map_err(|message| {
672            VerifyCliError::InvalidReceiptVerifier(format!(
673                "invalid notary key {}: {message}",
674                path.display()
675            ))
676        })?);
677    }
678    Ok(keys)
679}
680
681fn embedded_notary_keys(
682    notary: &serde_json::Map<String, serde_json::Value>,
683    findings: &mut Vec<FindingReport>,
684) -> Vec<Vec<u8>> {
685    let Some(keys) = notary
686        .get("signer_public_keys")
687        .and_then(serde_json::Value::as_array)
688    else {
689        return Vec::new();
690    };
691    let mut decoded = Vec::new();
692    for (index, key) in keys.iter().enumerate() {
693        let Some(pem) = key
694            .get("public_key_pem")
695            .and_then(serde_json::Value::as_str)
696        else {
697            findings.push(finding(
698                "notary_trusted_key_missing_pem",
699                format!("notary_verification.signer_public_keys[{index}].public_key_pem"),
700                "trusted notary key is missing public_key_pem",
701            ));
702            continue;
703        };
704        match ed25519_public_key_from_spki_pem(pem) {
705            Ok(raw) => decoded.push(raw),
706            Err(message) => findings.push(finding(
707                "notary_trusted_key_malformed",
708                format!("notary_verification.signer_public_keys[{index}].public_key_pem"),
709                message,
710            )),
711        }
712    }
713    decoded
714}
715
716fn record_embedded_key_mismatch(
717    notary: &serde_json::Map<String, serde_json::Value>,
718    trusted_keys: &[Vec<u8>],
719    findings: &mut Vec<FindingReport>,
720) {
721    let embedded = embedded_notary_keys(notary, findings);
722    if embedded.is_empty() {
723        return;
724    }
725    let has_trusted_embedded_key = embedded
726        .iter()
727        .any(|candidate| trusted_keys.iter().any(|trusted| trusted == candidate));
728    if !has_trusted_embedded_key {
729        findings.push(finding(
730            "notary_embedded_key_untrusted",
731            "notary_verification.signer_public_keys",
732            "embedded notary keys do not include any externally trusted key",
733        ));
734    }
735}
736
737fn bind_counter_seal_to_projection(
738    root: &serde_json::Value,
739    payload: &serde_json::Value,
740    counter_seal: &serde_json::Map<String, serde_json::Value>,
741    findings: &mut Vec<FindingReport>,
742) {
743    let Some(receipt) = root.get("receipt").and_then(serde_json::Value::as_object) else {
744        return;
745    };
746    require_matching_text(
747        receipt,
748        "digest",
749        payload,
750        "digest",
751        "receipt.digest",
752        "notary_verification.counter_seal.payload.digest",
753        findings,
754    );
755    require_matching_text(
756        receipt,
757        "mode",
758        payload,
759        "mode",
760        "receipt.mode",
761        "notary_verification.counter_seal.payload.mode",
762        findings,
763    );
764    require_matching_text(
765        receipt,
766        "binary_version",
767        payload,
768        "binary_version",
769        "receipt.binary_version",
770        "notary_verification.counter_seal.payload.binary_version",
771        findings,
772    );
773    if let Some(projected_payload_digest) = counter_seal
774        .get("payload_digest")
775        .and_then(serde_json::Value::as_str)
776    {
777        match serde_json::to_string(payload) {
778            Ok(canonical_payload) => {
779                let actual_payload_digest = sha256_prefixed(canonical_payload.as_bytes());
780                if projected_payload_digest != actual_payload_digest {
781                    findings.push(finding(
782                        "counter_seal_payload_digest_mismatch",
783                        "notary_verification.counter_seal.payload_digest",
784                        "projected counter-seal payload digest does not match the signed payload",
785                    ));
786                }
787            }
788            Err(error) => findings.push(finding(
789                "counter_seal_payload_digest_invalid",
790                "notary_verification.counter_seal.payload_digest",
791                format!("projected counter-seal payload cannot be canonicalized: {error}"),
792            )),
793        }
794    }
795}
796
797fn require_matching_text(
798    left: &serde_json::Map<String, serde_json::Value>,
799    left_key: &str,
800    right: &serde_json::Value,
801    right_key: &str,
802    left_path: &str,
803    right_path: &str,
804    findings: &mut Vec<FindingReport>,
805) {
806    let left_value = left.get(left_key).and_then(serde_json::Value::as_str);
807    let right_value = right.get(right_key).and_then(serde_json::Value::as_str);
808    if left_value != right_value {
809        findings.push(finding(
810            "notary_projection_binding_mismatch",
811            format!("{left_path} <-> {right_path}"),
812            "public projection field does not match the signed notary payload",
813        ));
814    }
815}
816
817fn ed25519_public_key_from_spki_pem(pem: &str) -> Result<Vec<u8>, String> {
818    const ED25519_SPKI_PREFIX: &[u8] = &[
819        0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
820    ];
821    let body = pem
822        .lines()
823        .map(str::trim)
824        .filter(|line| !line.starts_with("-----BEGIN ") && !line.starts_with("-----END "))
825        .collect::<String>();
826    let der = STANDARD
827        .decode(body)
828        .map_err(|_| "trusted notary key PEM is not valid base64".to_owned())?;
829    if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(ED25519_SPKI_PREFIX) {
830        return Err("trusted notary key PEM is not an Ed25519 SPKI public key".to_owned());
831    }
832    Ok(der[ED25519_SPKI_PREFIX.len()..].to_vec())
833}
834
835fn decode_signature(value: &str) -> Result<Vec<u8>, base64::DecodeError> {
836    let encoded = value.strip_prefix("base64:").unwrap_or(value);
837    URL_SAFE_NO_PAD
838        .decode(encoded)
839        .or_else(|_| STANDARD.decode(encoded))
840}
841
842fn notary_verdict(
843    schema: Option<String>,
844    digest_status: &'static str,
845    signature_status: &'static str,
846    trusted_key_count: usize,
847    findings: Vec<FindingReport>,
848) -> NotaryVerifyVerdict {
849    NotaryVerifyVerdict {
850        schema: "runx.notary_verify_verdict.v1",
851        valid: findings.is_empty() && digest_status == "valid" && signature_status == "valid",
852        counter_seal: NotaryCounterSealReport {
853            schema,
854            digest_status,
855            signature_status,
856            trusted_key_count,
857        },
858        findings,
859    }
860}
861
862fn finding(
863    code: impl Into<String>,
864    path: impl Into<String>,
865    message: impl Into<String>,
866) -> FindingReport {
867    FindingReport {
868        code: code.into(),
869        path: path.into(),
870        message: message.into(),
871    }
872}
873
874fn render_notary_verdict(verdict: &NotaryVerifyVerdict) -> String {
875    let mut output = String::new();
876    output.push_str(&format!(
877        "notary counter-seal: {}\n",
878        if verdict.valid { "ok" } else { "INVALID" }
879    ));
880    output.push_str(&format!(
881        "digest: {}\nsignature: {}\ntrusted keys: {}\n",
882        verdict.counter_seal.digest_status,
883        verdict.counter_seal.signature_status,
884        verdict.counter_seal.trusted_key_count
885    ));
886    for finding in &verdict.findings {
887        output.push_str(&format!(
888            "finding {} at {}: {}\n",
889            finding.code, finding.path, finding.message
890        ));
891    }
892    output
893}
894
895fn production_verifier(
896    env: &BTreeMap<String, String>,
897) -> Result<Option<Ed25519ReceiptVerifier>, VerifyCliError> {
898    let kid = non_empty_env(env, RUNX_RECEIPT_VERIFY_KID_ENV);
899    let public_key = non_empty_env(env, RUNX_RECEIPT_VERIFY_ED25519_PUBLIC_KEY_BASE64_ENV);
900    match (kid, public_key) {
901        (None, None) => Ok(None),
902        (Some(kid), Some(public_key)) => {
903            Ed25519ReceiptVerifier::from_public_key_base64(kid.to_owned(), public_key)
904                .map(Some)
905                .map_err(|_| {
906                    VerifyCliError::InvalidReceiptVerifier(format!(
907                        "{RUNX_RECEIPT_VERIFY_ED25519_PUBLIC_KEY_BASE64_ENV} is not valid Ed25519 public key material"
908                    ))
909                })
910        }
911        _ => Err(VerifyCliError::InvalidReceiptVerifier(format!(
912            "set both {RUNX_RECEIPT_VERIFY_KID_ENV} and {RUNX_RECEIPT_VERIFY_ED25519_PUBLIC_KEY_BASE64_ENV} for production verification"
913        ))),
914    }
915}
916
917fn receipt_verifier(
918    env: &BTreeMap<String, String>,
919    allow_local_development_signatures: bool,
920) -> Result<Option<Ed25519ReceiptVerifier>, VerifyCliError> {
921    let verifier = production_verifier(env)?;
922    if verifier.is_none() && !allow_local_development_signatures {
923        return Err(VerifyCliError::InvalidReceiptVerifier(format!(
924            "runx verify requires trusted receipt verification keys. Set both {RUNX_RECEIPT_VERIFY_KID_ENV} and {RUNX_RECEIPT_VERIFY_ED25519_PUBLIC_KEY_BASE64_ENV}, or pass --allow-local-development-signatures for local fixture receipts only."
925        )));
926    }
927    Ok(verifier)
928}
929
930fn load_receipts(root: &Path) -> Result<(Vec<Receipt>, Vec<FileIssue>), VerifyCliError> {
931    let mut receipts = Vec::new();
932    let mut issues = Vec::new();
933    let entries = match fs::read_dir(root) {
934        Ok(entries) => entries,
935        Err(error) => {
936            return Err(VerifyCliError::Store(format!(
937                "failed to read receipt dir {}: {error}",
938                root.display()
939            )));
940        }
941    };
942    for entry in entries {
943        let entry = entry.map_err(|error| {
944            VerifyCliError::Store(format!(
945                "failed to read receipt dir {}: {error}",
946                root.display()
947            ))
948        })?;
949        let path = entry.path();
950        if path.extension().and_then(|value| value.to_str()) != Some("json")
951            || path.file_name().and_then(|value| value.to_str()) == Some("index.json")
952        {
953            continue;
954        }
955        match fs::read_to_string(&path) {
956            Ok(contents) => match serde_json::from_str::<Receipt>(&contents) {
957                Ok(receipt) => receipts.push(receipt),
958                Err(error) => issues.push(FileIssue {
959                    file: path.display().to_string(),
960                    message: format!("not a valid receipt: {error}"),
961                }),
962            },
963            Err(error) => issues.push(FileIssue {
964                file: path.display().to_string(),
965                message: format!("unreadable: {error}"),
966            }),
967        }
968    }
969    receipts.sort_by(|left, right| left.id.cmp(&right.id));
970    Ok((receipts, issues))
971}
972
973#[derive(Clone, Debug)]
974struct ReceiptTree {
975    root: Receipt,
976    member_ids: BTreeSet<String>,
977    /// Set when the chain above this root points at a receipt id that is not
978    /// present in the store; the tree is then verified from the highest
979    /// available node but reported as incomplete.
980    parent_missing: Option<String>,
981}
982
983fn group_trees(receipts: &[Receipt]) -> Vec<ReceiptTree> {
984    let by_id: BTreeMap<&str, &Receipt> = receipts
985        .iter()
986        .map(|receipt| (receipt.id.as_str(), receipt))
987        .collect();
988
989    let mut trees: BTreeMap<String, ReceiptTree> = BTreeMap::new();
990    for receipt in receipts {
991        let (root_id, parent_missing) = resolve_root(receipt, &by_id);
992        let root = by_id
993            .get(root_id.as_str())
994            .copied()
995            .unwrap_or(receipt)
996            .clone();
997        let tree = trees.entry(root_id).or_insert_with(|| ReceiptTree {
998            root,
999            member_ids: BTreeSet::new(),
1000            parent_missing: None,
1001        });
1002        tree.member_ids.insert(receipt.id.to_string());
1003        if let Some(missing) = parent_missing {
1004            tree.parent_missing.get_or_insert(missing);
1005        }
1006    }
1007    trees.into_values().collect()
1008}
1009
1010/// Follow lineage parents to the highest receipt available in the store.
1011/// Returns the root id plus the first missing parent id, if the chain breaks.
1012fn resolve_root(receipt: &Receipt, by_id: &BTreeMap<&str, &Receipt>) -> (String, Option<String>) {
1013    let mut current = receipt;
1014    let mut seen = BTreeSet::new();
1015    loop {
1016        if !seen.insert(current.id.to_string()) {
1017            // Cycles are reported by tree verification; anchor on the starting
1018            // receipt so the walk terminates.
1019            return (receipt.id.to_string(), None);
1020        }
1021        let Some(parent_id) = current
1022            .lineage
1023            .as_ref()
1024            .and_then(|lineage| lineage.parent.as_ref())
1025            .and_then(referenced_receipt_id)
1026        else {
1027            return (current.id.to_string(), None);
1028        };
1029        match by_id.get(parent_id) {
1030            Some(parent) => current = parent,
1031            None => return (current.id.to_string(), Some(parent_id.to_owned())),
1032        }
1033    }
1034}
1035
1036fn referenced_receipt_id(reference: &Reference) -> Option<&str> {
1037    if reference.reference_type != ReferenceType::Receipt {
1038        return None;
1039    }
1040    reference
1041        .uri
1042        .strip_prefix(RECEIPT_REFERENCE_PREFIX)
1043        .filter(|id| !id.is_empty())
1044}
1045
1046fn render_report(report: &VerifyReport) -> String {
1047    let mut output = String::new();
1048    output.push_str(&format!(
1049        "receipt dir: {}\nsignature mode: {}\n",
1050        report.receipt_dir, report.signature_mode
1051    ));
1052    if report.signature_mode == "local-development" {
1053        output.push_str(
1054            "note: local-development signatures were accepted only because --allow-local-development-signatures was set; set RUNX_RECEIPT_VERIFY_KID and RUNX_RECEIPT_VERIFY_ED25519_PUBLIC_KEY_BASE64 to verify production signatures\n",
1055        );
1056    }
1057    if report.trees.is_empty() {
1058        output.push_str("no receipts found\n");
1059    }
1060    for tree in &report.trees {
1061        let status = if tree.valid { "ok" } else { "INVALID" };
1062        output.push_str(&format!(
1063            "tree {} ({} receipt{}): {status}\n",
1064            tree.root_receipt_id,
1065            tree.receipt_count,
1066            if tree.receipt_count == 1 { "" } else { "s" },
1067        ));
1068        if let Some(missing) = &tree.parent_missing {
1069            output.push_str(&format!("  missing parent receipt: {missing}\n"));
1070        }
1071        for finding in &tree.findings {
1072            output.push_str(&format!(
1073                "  {} at {}: {}\n",
1074                finding.code,
1075                if finding.path.is_empty() {
1076                    "<root>"
1077                } else {
1078                    &finding.path
1079                },
1080                finding.message
1081            ));
1082        }
1083    }
1084    for issue in &report.unreadable_files {
1085        output.push_str(&format!("unreadable {}: {}\n", issue.file, issue.message));
1086    }
1087    output.push_str(if report.valid {
1088        "verification: ok\n"
1089    } else {
1090        "verification: FAILED\n"
1091    });
1092    output
1093}
1094
1095fn render_single_receipt_verdict(verdict: &ReceiptVerifyVerdict) -> String {
1096    let mut output = String::new();
1097    output.push_str("receipt verification\n");
1098    output.push_str(&format!(
1099        "receipt: {}\n",
1100        verdict.receipt_id.as_deref().unwrap_or("<unparsed>")
1101    ));
1102    output.push_str(&format!(
1103        "signature: {} ({})\n",
1104        verdict.signature.status, verdict.signature.mode
1105    ));
1106    output.push_str(&format!("digest: {}\n", verdict.digest.status));
1107    output.push_str(&format!(
1108        "content address: {}\n",
1109        verdict.content_address.status
1110    ));
1111    output.push_str(&format!("lineage: {}\n", verdict.lineage.status));
1112    for finding in &verdict.findings {
1113        output.push_str(&format!(
1114            "  {} at {}: {}\n",
1115            finding.code,
1116            if finding.path.is_empty() {
1117                "<root>"
1118            } else {
1119                &finding.path
1120            },
1121            finding.message
1122        ));
1123    }
1124    output.push_str(if verdict.valid {
1125        "verification: ok\n"
1126    } else {
1127        "verification: FAILED\n"
1128    });
1129    output
1130}
1131
1132struct LocalDevelopmentReceiptVerifier;
1133
1134impl SignatureVerifier for LocalDevelopmentReceiptVerifier {
1135    fn verify(
1136        &self,
1137        _issuer: &runx_contracts::ReceiptIssuer,
1138        signature: &runx_contracts::ReceiptSignature,
1139        body_digest: &str,
1140    ) -> Result<(), SignatureVerificationFailure> {
1141        if !signature.value.starts_with("sig:sha256:") {
1142            return Err(SignatureVerificationFailure::MalformedSignature);
1143        }
1144        if signature.value == format!("sig:{body_digest}") {
1145            Ok(())
1146        } else {
1147            Err(SignatureVerificationFailure::SignatureMismatch)
1148        }
1149    }
1150}
1151
1152fn non_empty_env<'a>(env: &'a BTreeMap<String, String>, key: &str) -> Option<&'a str> {
1153    env.get(key)
1154        .map(|value| value.trim())
1155        .filter(|value| !value.is_empty())
1156}
1157
1158fn single_receipt_too_large() -> VerifyCliError {
1159    invalid_args(format!(
1160        "--receipt input exceeds {SINGLE_RECEIPT_MAX_BYTES} bytes"
1161    ))
1162}
1163
1164fn invalid_args(message: impl Into<String>) -> VerifyCliError {
1165    VerifyCliError::InvalidArgs(message.into())
1166}
1167
1168#[cfg(test)]
1169mod tests {
1170    use std::fs;
1171    use std::io;
1172
1173    use super::*;
1174    use ring::signature::KeyPair;
1175    use runx_contracts::ReceiptIssuerType;
1176    use runx_runtime::receipts::step_receipt_with_signature_policy;
1177    use runx_runtime::{
1178        Ed25519ReceiptSigner, InvocationStatus, LocalReceiptStore, RuntimeError, SkillOutput,
1179    };
1180    use serde::Deserialize;
1181    use serde_json as test_json;
1182
1183    type JsonValue = test_json::Value;
1184
1185    const CORPUS_ROOT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../fixtures/receipt-verify");
1186    const FIXTURE_KID: &str = "runx-cli-verify-fixture-key";
1187    const FIXTURE_SEED: [u8; 32] = [0x47; 32];
1188
1189    #[derive(Debug, Deserialize)]
1190    struct CorpusVerifier {
1191        kid: String,
1192        public_key_base64: String,
1193    }
1194
1195    #[derive(Debug, Deserialize)]
1196    struct CorpusCase {
1197        name: String,
1198        receipt: String,
1199        expected: String,
1200        signature_mode: String,
1201    }
1202
1203    #[test]
1204    fn verifies_production_signed_receipt_store() -> Result<(), io::Error> {
1205        let temp = tempfile_dir()?;
1206        let receipt_dir = temp.join("receipts");
1207        let signer = fixture_signer().map_err(io::Error::other)?;
1208        let verifier = Ed25519ReceiptVerifier::new([signer.production_key()]);
1209        let receipt = production_signed_receipt(&signer)
1210            .map_err(|error| io::Error::other(error.to_string()))?;
1211        LocalReceiptStore::new(&receipt_dir)
1212            .write_receipt_with_policy(
1213                &receipt,
1214                RuntimeReceiptSignaturePolicy::production(&verifier),
1215            )
1216            .map_err(|error| io::Error::other(error.to_string()))?;
1217
1218        let env = verifier_env(&signer);
1219        let result = run_verify_command(
1220            &[
1221                "verify".into(),
1222                "--receipt-dir".into(),
1223                receipt_dir.clone().into_os_string(),
1224                "--json".into(),
1225            ],
1226            &env,
1227            &temp,
1228        )
1229        .map_err(|error| io::Error::other(error.to_string()))?;
1230
1231        assert!(
1232            !result.failed,
1233            "expected clean verification: {}",
1234            result.output
1235        );
1236        let report: JsonValue = serde_json::from_str(&result.output).map_err(io::Error::other)?;
1237        assert_eq!(report["valid"], JsonValue::Bool(true));
1238        assert_eq!(report["signature_mode"], "production");
1239        Ok(())
1240    }
1241
1242    #[test]
1243    fn flags_tampered_receipt_body() -> Result<(), io::Error> {
1244        let temp = tempfile_dir()?;
1245        let receipt_dir = temp.join("receipts");
1246        let signer = fixture_signer().map_err(io::Error::other)?;
1247        let verifier = Ed25519ReceiptVerifier::new([signer.production_key()]);
1248        let receipt = production_signed_receipt(&signer)
1249            .map_err(|error| io::Error::other(error.to_string()))?;
1250        LocalReceiptStore::new(&receipt_dir)
1251            .write_receipt_with_policy(
1252                &receipt,
1253                RuntimeReceiptSignaturePolicy::production(&verifier),
1254            )
1255            .map_err(|error| io::Error::other(error.to_string()))?;
1256
1257        // Tamper with the sealed body after signing.
1258        let receipt_file = LocalReceiptStore::new(&receipt_dir)
1259            .receipt_path(&receipt.id)
1260            .map_err(|error| io::Error::other(error.to_string()))?;
1261        let tampered = fs::read_to_string(&receipt_file)?
1262            .replace("production-verified", "production-tampered");
1263        fs::write(&receipt_file, tampered)?;
1264
1265        let env = verifier_env(&signer);
1266        let result = run_verify_command(
1267            &[
1268                "verify".into(),
1269                "--receipt-dir".into(),
1270                receipt_dir.into_os_string(),
1271            ],
1272            &env,
1273            &temp,
1274        )
1275        .map_err(|error| io::Error::other(error.to_string()))?;
1276
1277        assert!(
1278            result.failed,
1279            "tampered receipt must fail: {}",
1280            result.output
1281        );
1282        assert!(result.output.contains("verification: FAILED"));
1283        Ok(())
1284    }
1285
1286    #[test]
1287    fn missing_receipt_id_is_a_usage_error() -> Result<(), io::Error> {
1288        let temp = tempfile_dir()?;
1289        let receipt_dir = temp.join("receipts");
1290        fs::create_dir_all(&receipt_dir)?;
1291        let error = match run_verify_command(
1292            &[
1293                "verify".into(),
1294                "--allow-local-development-signatures".into(),
1295                "receipt_missing".into(),
1296                "--receipt-dir".into(),
1297                receipt_dir.into_os_string(),
1298            ],
1299            &BTreeMap::new(),
1300            &temp,
1301        ) {
1302            Ok(result) => {
1303                return Err(io::Error::other(format!(
1304                    "unknown receipt id must error: {}",
1305                    result.output
1306                )));
1307            }
1308            Err(error) => error,
1309        };
1310        assert!(matches!(error, VerifyCliError::InvalidArgs(_)));
1311        Ok(())
1312    }
1313
1314    #[test]
1315    fn verifies_single_receipt_file_as_machine_verdict() -> Result<(), io::Error> {
1316        let temp = tempfile_dir()?;
1317        let signer = fixture_signer().map_err(io::Error::other)?;
1318        let receipt = production_signed_receipt(&signer)
1319            .map_err(|error| io::Error::other(error.to_string()))?;
1320        let receipt_file = temp.join("receipt.json");
1321        fs::write(
1322            &receipt_file,
1323            serde_json::to_vec_pretty(&receipt).map_err(io::Error::other)?,
1324        )?;
1325
1326        let result = run_verify_command(
1327            &[
1328                "verify".into(),
1329                "--receipt".into(),
1330                receipt_file.into_os_string(),
1331                "--json".into(),
1332            ],
1333            &verifier_env(&signer),
1334            &temp,
1335        )
1336        .map_err(|error| io::Error::other(error.to_string()))?;
1337
1338        assert!(
1339            !result.failed,
1340            "expected clean single receipt verdict: {}",
1341            result.output
1342        );
1343        let verdict: JsonValue = serde_json::from_str(&result.output).map_err(io::Error::other)?;
1344        assert_eq!(verdict["schema"], "runx.verify_verdict.v1");
1345        assert_eq!(verdict["valid"], JsonValue::Bool(true));
1346        assert_eq!(
1347            verdict["receipt_id"],
1348            JsonValue::String(receipt.id.to_string())
1349        );
1350        assert_eq!(verdict["signature"]["mode"], "production");
1351        assert_eq!(verdict["signature"]["status"], "valid");
1352        assert_eq!(verdict["digest"]["status"], "valid");
1353        assert_eq!(verdict["content_address"]["status"], "valid");
1354        assert_eq!(verdict["lineage"]["status"], "unverified");
1355        assert!(verdict["findings"].as_array().is_some_and(Vec::is_empty));
1356        Ok(())
1357    }
1358
1359    #[test]
1360    fn verifies_single_receipt_from_stdin() -> Result<(), io::Error> {
1361        let temp = tempfile_dir()?;
1362        let signer = fixture_signer().map_err(io::Error::other)?;
1363        let receipt = production_signed_receipt(&signer)
1364            .map_err(|error| io::Error::other(error.to_string()))?;
1365        let input = serde_json::to_vec(&receipt).map_err(io::Error::other)?;
1366
1367        let result = run_verify_command_with_stdin(
1368            &[
1369                "verify".into(),
1370                "--receipt".into(),
1371                "-".into(),
1372                "--json".into(),
1373            ],
1374            &verifier_env(&signer),
1375            &temp,
1376            io::Cursor::new(input),
1377        )
1378        .map_err(|error| io::Error::other(error.to_string()))?;
1379
1380        assert!(!result.failed, "stdin verdict failed: {}", result.output);
1381        let verdict: JsonValue = serde_json::from_str(&result.output).map_err(io::Error::other)?;
1382        assert_eq!(
1383            verdict["receipt_id"],
1384            JsonValue::String(receipt.id.to_string())
1385        );
1386        assert_eq!(verdict["valid"], JsonValue::Bool(true));
1387        Ok(())
1388    }
1389
1390    #[test]
1391    fn single_receipt_requires_trusted_keys_by_default() -> Result<(), io::Error> {
1392        let temp = tempfile_dir()?;
1393        let signer = fixture_signer().map_err(io::Error::other)?;
1394        let receipt = production_signed_receipt(&signer)
1395            .map_err(|error| io::Error::other(error.to_string()))?;
1396        let receipt_file = temp.join("receipt.json");
1397        fs::write(
1398            &receipt_file,
1399            serde_json::to_vec_pretty(&receipt).map_err(io::Error::other)?,
1400        )?;
1401
1402        let error = match run_verify_command(
1403            &[
1404                "verify".into(),
1405                "--receipt".into(),
1406                receipt_file.into_os_string(),
1407                "--json".into(),
1408            ],
1409            &BTreeMap::new(),
1410            &temp,
1411        ) {
1412            Ok(result) => {
1413                return Err(io::Error::other(format!(
1414                    "receipt verification must fail closed without trusted keys: {}",
1415                    result.output
1416                )));
1417            }
1418            Err(error) => error,
1419        };
1420        assert!(
1421            matches!(error, VerifyCliError::InvalidReceiptVerifier(message) if message.contains("requires trusted receipt verification keys"))
1422        );
1423        Ok(())
1424    }
1425
1426    #[test]
1427    fn verifies_hosted_notary_counter_seal_from_stdin() -> Result<(), io::Error> {
1428        let temp = tempfile_dir()?;
1429        let key_pair = ring::signature::Ed25519KeyPair::from_seed_unchecked(&FIXTURE_SEED)
1430            .map_err(|_| io::Error::other("fixture key must be valid"))?;
1431        let public_key_path = temp.join("trusted-notary.pem");
1432        fs::write(
1433            &public_key_path,
1434            ed25519_spki_pem(key_pair.public_key().as_ref()),
1435        )?;
1436        let payload = test_json::json!({
1437            "binary_version": "runx-test",
1438            "digest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
1439            "issued_at": "2026-06-10T00:00:00Z",
1440            "mode": "full",
1441            "schema": "runx.hosted_notary_counter_seal_payload.v1",
1442            "verdict_digest": "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
1443        });
1444        let canonical_payload = serde_json::to_string(&payload).map_err(io::Error::other)?;
1445        let signature = key_pair.sign(canonical_payload.as_bytes());
1446        let document = test_json::json!({
1447            "notary_verification": {
1448                "counter_seal": {
1449                    "schema": "runx.hosted_notary_counter_seal.v1",
1450                    "payload": payload,
1451                    "digest": sha256_prefixed(canonical_payload.as_bytes()),
1452                    "signature": {
1453                        "alg": "Ed25519",
1454                        "value": format!("base64:{}", base64_standard(signature.as_ref()))
1455                    }
1456                },
1457                "signer_public_keys": [{
1458                    "kid": "trusted-hosted-receipt-notary-1",
1459                    "public_key_pem": ed25519_spki_pem(key_pair.public_key().as_ref())
1460                }]
1461            }
1462        });
1463
1464        let result = run_verify_command_with_stdin(
1465            &[
1466                "verify".into(),
1467                "--notary".into(),
1468                "-".into(),
1469                "--notary-key".into(),
1470                public_key_path.into_os_string(),
1471                "--json".into(),
1472            ],
1473            &BTreeMap::new(),
1474            &temp,
1475            io::Cursor::new(serde_json::to_vec(&document).map_err(io::Error::other)?),
1476        )
1477        .map_err(|error| io::Error::other(error.to_string()))?;
1478
1479        assert!(!result.failed, "notary verifier failed: {}", result.output);
1480        let verdict: JsonValue = serde_json::from_str(&result.output).map_err(io::Error::other)?;
1481        assert_eq!(verdict["schema"], "runx.notary_verify_verdict.v1");
1482        assert_eq!(verdict["valid"], JsonValue::Bool(true));
1483        assert_eq!(verdict["counter_seal"]["digest_status"], "valid");
1484        assert_eq!(verdict["counter_seal"]["signature_status"], "valid");
1485        Ok(())
1486    }
1487
1488    #[test]
1489    fn hosted_notary_flags_object_document_without_notary_verification() -> Result<(), io::Error> {
1490        let temp = tempfile_dir()?;
1491        let key_pair = ring::signature::Ed25519KeyPair::from_seed_unchecked(&FIXTURE_SEED)
1492            .map_err(|_| io::Error::other("fixture key must be valid"))?;
1493        let public_key_path = temp.join("trusted-notary.pem");
1494        fs::write(
1495            &public_key_path,
1496            ed25519_spki_pem(key_pair.public_key().as_ref()),
1497        )?;
1498        // An object document that carries no notary_verification (or
1499        // receipt.notary_verification) wrapper must surface the missing finding,
1500        // not fall through to a misleading counter_seal_missing diagnostic.
1501        let document = test_json::json!({ "counter_seal": "not-a-notary-block" });
1502
1503        let result = run_verify_command_with_stdin(
1504            &[
1505                "verify".into(),
1506                "--notary".into(),
1507                "-".into(),
1508                "--notary-key".into(),
1509                public_key_path.into_os_string(),
1510                "--json".into(),
1511            ],
1512            &BTreeMap::new(),
1513            &temp,
1514            io::Cursor::new(serde_json::to_vec(&document).map_err(io::Error::other)?),
1515        )
1516        .map_err(|error| io::Error::other(error.to_string()))?;
1517
1518        assert!(
1519            result.failed,
1520            "document without notary_verification must fail: {}",
1521            result.output
1522        );
1523        let verdict: JsonValue = serde_json::from_str(&result.output).map_err(io::Error::other)?;
1524        assert_eq!(verdict["valid"], JsonValue::Bool(false));
1525        let codes = finding_codes(&verdict);
1526        assert!(
1527            codes.contains(&"notary_verification_missing".to_owned()),
1528            "expected notary_verification_missing, got {codes:?}"
1529        );
1530        assert!(
1531            !codes.contains(&"counter_seal_missing".to_owned()),
1532            "must not fall through to counter_seal_missing: {codes:?}"
1533        );
1534        Ok(())
1535    }
1536
1537    #[test]
1538    // rust-style-allow: long-function - this notary negative fixture builds the
1539    // untrusted projection and verifies each emitted finding together.
1540    fn hosted_notary_rejects_embedded_key_without_external_trust() -> Result<(), io::Error> {
1541        let temp = tempfile_dir()?;
1542        let signer = ring::signature::Ed25519KeyPair::from_seed_unchecked(&FIXTURE_SEED)
1543            .map_err(|_| io::Error::other("fixture key must be valid"))?;
1544        let untrusted = ring::signature::Ed25519KeyPair::from_seed_unchecked(&[7u8; 32])
1545            .map_err(|_| io::Error::other("fixture key must be valid"))?;
1546        let untrusted_key_path = temp.join("untrusted-notary.pem");
1547        fs::write(
1548            &untrusted_key_path,
1549            ed25519_spki_pem(untrusted.public_key().as_ref()),
1550        )?;
1551        let payload = test_json::json!({
1552            "binary_version": "runx-test",
1553            "digest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
1554            "issued_at": "2026-06-10T00:00:00Z",
1555            "mode": "full",
1556            "schema": "runx.hosted_notary_counter_seal_payload.v1",
1557            "verdict_digest": "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
1558        });
1559        let canonical_payload = serde_json::to_string(&payload).map_err(io::Error::other)?;
1560        let signature = signer.sign(canonical_payload.as_bytes());
1561        let document = test_json::json!({
1562            "notary_verification": {
1563                "counter_seal": {
1564                    "schema": "runx.hosted_notary_counter_seal.v1",
1565                    "payload": payload,
1566                    "digest": sha256_prefixed(canonical_payload.as_bytes()),
1567                    "signature": {
1568                        "alg": "Ed25519",
1569                        "value": format!("base64:{}", base64_standard(signature.as_ref()))
1570                    }
1571                },
1572                "signer_public_keys": [{
1573                    "kid": "self-attested-hosted-receipt-notary",
1574                    "public_key_pem": ed25519_spki_pem(signer.public_key().as_ref())
1575                }]
1576            }
1577        });
1578
1579        let result = run_verify_command_with_stdin(
1580            &[
1581                "verify".into(),
1582                "--notary".into(),
1583                "-".into(),
1584                "--notary-key".into(),
1585                untrusted_key_path.into_os_string(),
1586                "--json".into(),
1587            ],
1588            &BTreeMap::new(),
1589            &temp,
1590            io::Cursor::new(serde_json::to_vec(&document).map_err(io::Error::other)?),
1591        )
1592        .map_err(|error| io::Error::other(error.to_string()))?;
1593
1594        assert!(
1595            result.failed,
1596            "notary verifier should fail: {}",
1597            result.output
1598        );
1599        let verdict: JsonValue = serde_json::from_str(&result.output).map_err(io::Error::other)?;
1600        assert_eq!(verdict["valid"], JsonValue::Bool(false));
1601        assert_eq!(verdict["counter_seal"]["signature_status"], "invalid");
1602        assert!(finding_codes(&verdict).contains(&"counter_seal_signature_mismatch".to_owned()));
1603        assert!(finding_codes(&verdict).contains(&"notary_embedded_key_untrusted".to_owned()));
1604        Ok(())
1605    }
1606
1607    #[test]
1608    // rust-style-allow: long-function - this notary fixture keeps the signed
1609    // payload, projection, and verification readback in one regression.
1610    fn hosted_notary_binds_signed_payload_to_public_projection() -> Result<(), io::Error> {
1611        let temp = tempfile_dir()?;
1612        let key_pair = ring::signature::Ed25519KeyPair::from_seed_unchecked(&FIXTURE_SEED)
1613            .map_err(|_| io::Error::other("fixture key must be valid"))?;
1614        let public_key_path = temp.join("trusted-notary.pem");
1615        fs::write(
1616            &public_key_path,
1617            ed25519_spki_pem(key_pair.public_key().as_ref()),
1618        )?;
1619        let payload = test_json::json!({
1620            "binary_version": "runx-test",
1621            "digest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
1622            "issued_at": "2026-06-10T00:00:00Z",
1623            "mode": "full",
1624            "schema": "runx.hosted_notary_counter_seal_payload.v1",
1625            "verdict_digest": "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
1626        });
1627        let canonical_payload = serde_json::to_string(&payload).map_err(io::Error::other)?;
1628        let signature = key_pair.sign(canonical_payload.as_bytes());
1629        let document = test_json::json!({
1630            "receipt": {
1631                "digest": "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
1632                "mode": "full",
1633                "binary_version": "runx-test",
1634                "notary_verification": {
1635                    "counter_seal": {
1636                        "schema": "runx.hosted_notary_counter_seal.v1",
1637                        "payload": payload,
1638                        "payload_digest": sha256_prefixed(canonical_payload.as_bytes()),
1639                        "digest": sha256_prefixed(canonical_payload.as_bytes()),
1640                        "signature": {
1641                            "alg": "Ed25519",
1642                            "value": format!("base64:{}", base64_standard(signature.as_ref()))
1643                        }
1644                    },
1645                    "signer_public_keys": [{
1646                        "kid": "trusted-hosted-receipt-notary-1",
1647                        "public_key_pem": ed25519_spki_pem(key_pair.public_key().as_ref())
1648                    }]
1649                }
1650            }
1651        });
1652
1653        let result = run_verify_command_with_stdin(
1654            &[
1655                "verify".into(),
1656                "--notary".into(),
1657                "-".into(),
1658                "--notary-key".into(),
1659                public_key_path.into_os_string(),
1660                "--json".into(),
1661            ],
1662            &BTreeMap::new(),
1663            &temp,
1664            io::Cursor::new(serde_json::to_vec(&document).map_err(io::Error::other)?),
1665        )
1666        .map_err(|error| io::Error::other(error.to_string()))?;
1667
1668        assert!(
1669            result.failed,
1670            "projection binding should fail: {}",
1671            result.output
1672        );
1673        let verdict: JsonValue = serde_json::from_str(&result.output).map_err(io::Error::other)?;
1674        assert_eq!(verdict["counter_seal"]["signature_status"], "valid");
1675        assert!(finding_codes(&verdict).contains(&"notary_projection_binding_mismatch".to_owned()));
1676        Ok(())
1677    }
1678
1679    // rust-style-allow: long-function - malformed receipt regression covers capped stdin, invalid JSON, and machine verdict fields together.
1680    #[test]
1681    fn malformed_single_receipt_returns_invalid_verdict() -> Result<(), io::Error> {
1682        let temp = tempfile_dir()?;
1683
1684        let result = run_verify_command_with_stdin(
1685            &[
1686                "verify".into(),
1687                "--receipt".into(),
1688                "-".into(),
1689                "--json".into(),
1690            ],
1691            &BTreeMap::new(),
1692            &temp,
1693            io::Cursor::new(br#"{"schema":"runx.receipt.v1","#.to_vec()),
1694        )
1695        .map_err(|error| io::Error::other(error.to_string()))?;
1696
1697        assert!(result.failed, "malformed receipt must fail");
1698        let verdict: JsonValue = serde_json::from_str(&result.output).map_err(io::Error::other)?;
1699        assert_eq!(verdict["schema"], "runx.verify_verdict.v1");
1700        assert_eq!(verdict["valid"], JsonValue::Bool(false));
1701        assert_eq!(verdict["receipt_id"], JsonValue::Null);
1702        assert_eq!(verdict["findings"][0]["code"], "receipt_parse_error");
1703        Ok(())
1704    }
1705
1706    #[test]
1707    fn single_receipt_rejects_store_selection_flags() -> Result<(), io::Error> {
1708        let temp = tempfile_dir()?;
1709        let error = match run_verify_command(
1710            &[
1711                "verify".into(),
1712                "receipt_1".into(),
1713                "--receipt".into(),
1714                "receipt.json".into(),
1715            ],
1716            &BTreeMap::new(),
1717            &temp,
1718        ) {
1719            Ok(result) => {
1720                return Err(io::Error::other(format!(
1721                    "--receipt must be mutually exclusive with receipt ids: {}",
1722                    result.output
1723                )));
1724            }
1725            Err(error) => error,
1726        };
1727        assert!(matches!(error, VerifyCliError::InvalidArgs(_)));
1728        Ok(())
1729    }
1730
1731    #[test]
1732    fn single_receipt_stdin_is_size_capped() -> Result<(), io::Error> {
1733        let temp = tempfile_dir()?;
1734        let error = match run_verify_command_with_stdin(
1735            &["verify".into(), "--receipt".into(), "-".into()],
1736            &BTreeMap::new(),
1737            &temp,
1738            io::Cursor::new(vec![b' '; SINGLE_RECEIPT_MAX_BYTES + 1]),
1739        ) {
1740            Ok(result) => {
1741                return Err(io::Error::other(format!(
1742                    "oversized stdin must be a usage error: {}",
1743                    result.output
1744                )));
1745            }
1746            Err(error) => error,
1747        };
1748        assert!(matches!(error, VerifyCliError::InvalidArgs(_)));
1749        Ok(())
1750    }
1751
1752    #[test]
1753    fn receipt_verify_corpus_replays_through_cli_surface() -> Result<(), io::Error> {
1754        let root = PathBuf::from(CORPUS_ROOT);
1755        let production_env = corpus_production_env(&root)?;
1756        for (case_dir, case) in corpus_cases(&root)? {
1757            let env = if case.signature_mode == "production" {
1758                production_env.clone()
1759            } else {
1760                BTreeMap::new()
1761            };
1762            let mut args = vec!["verify".into()];
1763            if case.signature_mode == "local-development" {
1764                args.push("--allow-local-development-signatures".into());
1765            }
1766            args.extend([
1767                "--receipt".into(),
1768                case_dir.join(&case.receipt).into_os_string(),
1769                "--json".into(),
1770            ]);
1771            let result = run_verify_command(&args, &env, &root)
1772                .map_err(|error| io::Error::other(error.to_string()))?;
1773            let actual: JsonValue =
1774                serde_json::from_str(&result.output).map_err(io::Error::other)?;
1775            let expected = expected_verdict(&case_dir, &case)?;
1776
1777            assert_eq!(actual, expected, "corpus case {} drifted", case.name);
1778            assert_eq!(
1779                result.failed,
1780                !expected["valid"].as_bool().unwrap_or(false),
1781                "corpus case {} had inconsistent exit status",
1782                case.name
1783            );
1784        }
1785        Ok(())
1786    }
1787
1788    fn corpus_production_env(root: &Path) -> Result<BTreeMap<String, String>, io::Error> {
1789        let verifier: CorpusVerifier =
1790            serde_json::from_str(&fs::read_to_string(root.join("verifier.json"))?)
1791                .map_err(io::Error::other)?;
1792        Ok(BTreeMap::from([
1793            (RUNX_RECEIPT_VERIFY_KID_ENV.to_owned(), verifier.kid),
1794            (
1795                RUNX_RECEIPT_VERIFY_ED25519_PUBLIC_KEY_BASE64_ENV.to_owned(),
1796                verifier.public_key_base64,
1797            ),
1798        ]))
1799    }
1800
1801    fn corpus_cases(root: &Path) -> Result<Vec<(PathBuf, CorpusCase)>, io::Error> {
1802        let mut cases = Vec::new();
1803        for entry in fs::read_dir(root)? {
1804            let path = entry?.path();
1805            if !path.is_dir() {
1806                continue;
1807            }
1808            let case_path = path.join("case.json");
1809            if !case_path.exists() {
1810                continue;
1811            }
1812            let case: CorpusCase =
1813                serde_json::from_str(&fs::read_to_string(case_path)?).map_err(io::Error::other)?;
1814            cases.push((path, case));
1815        }
1816        cases.sort_by(|left, right| left.1.name.cmp(&right.1.name));
1817        Ok(cases)
1818    }
1819
1820    fn expected_verdict(case_dir: &Path, case: &CorpusCase) -> Result<JsonValue, io::Error> {
1821        serde_json::from_str(&fs::read_to_string(case_dir.join(&case.expected))?)
1822            .map_err(io::Error::other)
1823    }
1824
1825    fn verifier_env(signer: &Ed25519ReceiptSigner) -> BTreeMap<String, String> {
1826        BTreeMap::from([
1827            (
1828                RUNX_RECEIPT_VERIFY_KID_ENV.to_owned(),
1829                FIXTURE_KID.to_owned(),
1830            ),
1831            (
1832                RUNX_RECEIPT_VERIFY_ED25519_PUBLIC_KEY_BASE64_ENV.to_owned(),
1833                base64_standard(signer.public_key()),
1834            ),
1835        ])
1836    }
1837
1838    fn fixture_signer() -> Result<Ed25519ReceiptSigner, runx_runtime::RuntimeReceiptSigningError> {
1839        Ed25519ReceiptSigner::from_seed(FIXTURE_KID, ReceiptIssuerType::Hosted, &FIXTURE_SEED)
1840    }
1841
1842    fn production_signed_receipt(signer: &Ed25519ReceiptSigner) -> Result<Receipt, RuntimeError> {
1843        let verifier = Ed25519ReceiptVerifier::new([signer.production_key()]);
1844        let output = SkillOutput {
1845            status: InvocationStatus::Success,
1846            stdout:
1847                r#"{"artifact":{"artifact_id":"artifact_cli_verify","artifact_type":"artifact"}}"#
1848                    .to_owned(),
1849            stderr: String::new(),
1850            exit_code: Some(0),
1851            duration_ms: 10,
1852            metadata: BTreeMap::new(),
1853        };
1854        step_receipt_with_signature_policy(
1855            "cli-verify",
1856            "production-verified",
1857            1,
1858            &output,
1859            "2026-06-10T00:00:00Z",
1860            RuntimeReceiptSignaturePolicy::production_signing(signer, &verifier),
1861        )
1862    }
1863
1864    fn base64_standard(bytes: &[u8]) -> String {
1865        const TABLE: &[u8; 64] =
1866            b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1867        let mut output = String::new();
1868        for chunk in bytes.chunks(3) {
1869            let b0 = chunk[0] as u32;
1870            let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
1871            let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
1872            let triple = (b0 << 16) | (b1 << 8) | b2;
1873            output.push(TABLE[(triple >> 18) as usize & 0x3f] as char);
1874            output.push(TABLE[(triple >> 12) as usize & 0x3f] as char);
1875            output.push(if chunk.len() > 1 {
1876                TABLE[(triple >> 6) as usize & 0x3f] as char
1877            } else {
1878                '='
1879            });
1880            output.push(if chunk.len() > 2 {
1881                TABLE[triple as usize & 0x3f] as char
1882            } else {
1883                '='
1884            });
1885        }
1886        output
1887    }
1888
1889    fn ed25519_spki_pem(raw_public_key: &[u8]) -> String {
1890        let mut der = Vec::from([
1891            0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
1892        ]);
1893        der.extend_from_slice(raw_public_key);
1894        format!(
1895            "-----BEGIN PUBLIC KEY-----\n{}\n-----END PUBLIC KEY-----",
1896            base64_standard(&der)
1897        )
1898    }
1899
1900    fn finding_codes(verdict: &JsonValue) -> Vec<String> {
1901        verdict["findings"]
1902            .as_array()
1903            .into_iter()
1904            .flatten()
1905            .filter_map(|finding| finding["code"].as_str().map(ToOwned::to_owned))
1906            .collect()
1907    }
1908
1909    fn tempfile_dir() -> Result<PathBuf, io::Error> {
1910        let path = std::env::temp_dir().join(format!(
1911            "runx-cli-verify-{}-{}",
1912            std::process::id(),
1913            std::time::SystemTime::now()
1914                .duration_since(std::time::UNIX_EPOCH)
1915                .map_err(|error| io::Error::other(error.to_string()))?
1916                .as_nanos()
1917        ));
1918        fs::create_dir_all(&path)?;
1919        Ok(path)
1920    }
1921}