use colored::Colorize;
use nono::Result;
use nono::trust::{self, Enforcement, TrustPolicy, VerificationOutcome, VerificationResult};
use std::path::{Path, PathBuf};
pub fn load_scan_policy(
root: &Path,
trust_override: bool,
skip_dirs: &[String],
) -> Result<TrustPolicy> {
let cwd_policy = root.join("trust-policy.json");
let project_policy_path = cwd_policy.exists().then_some(cwd_policy);
let project = if let Some(ref policy_path) = project_policy_path {
Some(trust::load_policy_from_file(policy_path)?)
} else {
None
};
let user_path = crate::trust_cmd::user_trust_policy_path();
let user_policy_path = user_path.as_ref().filter(|path| path.exists());
let user = if let Some(path) = user_policy_path {
Some(trust::load_policy_from_file(path)?)
} else {
None
};
let effective = match (user, project) {
(Some(u), Some(p)) => trust::merge_policies(&[u, p]),
(Some(u), None) => Ok(u),
(None, Some(p)) => {
eprintln!(
" {}",
"Warning: project-level trust-policy.json found but no user-level policy exists."
.yellow()
);
eprintln!(
" {}",
"Project policies are not authoritative without a user-level policy to anchor trust."
.yellow()
);
let policy_path = user_path
.as_deref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "~/.config/nono/trust-policy.json".to_string());
eprintln!(
" {}",
format!("Create a signed policy at {policy_path} to enforce verification.")
.yellow()
);
Ok(p)
}
(None, None) => Ok(TrustPolicy::default()),
}?;
if !trust_override && scan_has_signed_artifacts(root, &effective, skip_dirs)? {
verify_scan_policy_signatures(
project_policy_path.as_deref(),
user_policy_path.map(PathBuf::as_path),
)?;
}
Ok(effective)
}
fn scan_has_signed_artifacts(
scan_root: &Path,
policy: &TrustPolicy,
skip_dirs: &[String],
) -> Result<bool> {
if trust::multi_subject_bundle_path(scan_root).exists() {
return Ok(true);
}
if policy.includes.is_empty() {
return Ok(false);
}
let files = trust::find_included_files_with_skip_dirs(policy, scan_root, skip_dirs)?;
Ok(files
.iter()
.any(|file_path| trust::bundle_path_for(file_path).exists()))
}
fn verify_scan_policy_signatures(
project_policy_path: Option<&Path>,
user_policy_path: Option<&Path>,
) -> Result<()> {
if let Some(policy_path) = project_policy_path {
verify_policy_signature(policy_path)?;
}
if let Some(policy_path) = user_policy_path {
verify_policy_signature(policy_path)?;
}
Ok(())
}
pub fn verify_policy_signature(policy_path: &Path) -> Result<()> {
let bundle_path = trust::bundle_path_for(policy_path);
if !bundle_path.exists() {
let is_user_policy = crate::trust_cmd::user_trust_policy_path()
.map(|p| p == policy_path)
.unwrap_or(false);
let hint = if is_user_policy {
"Run 'nono trust sign-policy --user' to sign it.".to_string()
} else {
format!(
"Run 'nono trust sign-policy {}' to sign it.",
policy_path.display()
)
};
return Err(nono::NonoError::TrustVerification {
path: policy_path.display().to_string(),
reason: format!("trust policy is unsigned (no .bundle sidecar found). {hint}"),
});
}
let bundle =
trust::load_bundle(&bundle_path).map_err(|e| nono::NonoError::TrustVerification {
path: policy_path.display().to_string(),
reason: format!("invalid policy bundle: {e}"),
})?;
let predicate_type = trust::extract_predicate_type(&bundle, &bundle_path).map_err(|e| {
nono::NonoError::TrustVerification {
path: policy_path.display().to_string(),
reason: format!("failed to extract predicate type: {e}"),
}
})?;
if predicate_type != trust::NONO_POLICY_PREDICATE_TYPE {
return Err(nono::NonoError::TrustVerification {
path: policy_path.display().to_string(),
reason: format!(
"wrong bundle type: expected trust policy attestation, got {predicate_type}"
),
});
}
let file_digest = trust::file_digest(policy_path)?;
let bundle_digest = trust::extract_bundle_digest(&bundle, &bundle_path)?;
if bundle_digest != file_digest {
return Err(nono::NonoError::TrustVerification {
path: policy_path.display().to_string(),
reason: "trust policy has been modified since signing (digest mismatch)".to_string(),
});
}
let identity = trust::extract_signer_identity(&bundle, &bundle_path).map_err(|e| {
nono::NonoError::TrustVerification {
path: policy_path.display().to_string(),
reason: format!("no signer identity in policy bundle: {e}"),
}
})?;
match &identity {
trust::SignerIdentity::Keyed { key_id } => {
let pub_key_bytes = crate::trust_cmd::load_public_key_bytes(key_id).map_err(|e| {
nono::NonoError::TrustVerification {
path: policy_path.display().to_string(),
reason: format!(
"cannot load public key '{key_id}' for policy verification: {e}"
),
}
})?;
trust::verify_keyed_signature(&bundle, &pub_key_bytes, &bundle_path).map_err(|e| {
nono::NonoError::TrustVerification {
path: policy_path.display().to_string(),
reason: format!("policy signature verification failed: {e}"),
}
})?;
}
trust::SignerIdentity::Keyless { .. } => {
let trusted_root = trust::load_production_trusted_root().map_err(|e| {
nono::NonoError::TrustVerification {
path: policy_path.display().to_string(),
reason: format!("failed to load Sigstore trusted root: {e}"),
}
})?;
let sigstore_policy = trust::VerificationPolicy::default();
trust::verify_bundle_with_digest(
&file_digest,
&bundle,
&trusted_root,
&sigstore_policy,
policy_path,
)
.map_err(|e| nono::NonoError::TrustVerification {
path: policy_path.display().to_string(),
reason: format!("policy Sigstore verification failed: {e}"),
})?;
}
}
Ok(())
}
#[derive(Debug)]
pub struct ScanResult {
pub results: Vec<VerificationResult>,
pub verified: u32,
pub blocked: u32,
pub warned: u32,
}
impl ScanResult {
#[must_use]
pub fn should_proceed(&self) -> bool {
self.blocked == 0
}
#[must_use]
pub fn verified_paths(&self) -> Vec<PathBuf> {
self.results
.iter()
.filter(|r| r.outcome.is_verified())
.map(|r| r.path.clone())
.collect()
}
}
pub fn run_pre_exec_scan(
scan_root: &Path,
policy: &TrustPolicy,
silent: bool,
skip_dirs: &[String],
) -> Result<ScanResult> {
let multi_bundle = trust::multi_subject_bundle_path(scan_root);
let has_multi_bundle = multi_bundle.exists();
if policy.includes.is_empty() && !has_multi_bundle {
return Ok(ScanResult {
results: Vec::new(),
verified: 0,
blocked: 0,
warned: 0,
});
}
let files = trust::find_included_files_with_skip_dirs(policy, scan_root, skip_dirs)?;
check_missing_literal_patterns(policy, scan_root, &files, silent)?;
if files.is_empty() && !has_multi_bundle {
return Ok(ScanResult {
results: Vec::new(),
verified: 0,
blocked: 0,
warned: 0,
});
}
let total_hint = files
.len()
.saturating_add(if has_multi_bundle { 1 } else { 0 });
if !silent && total_hint > 0 {
eprintln!(
" Scanning {} instruction file(s) for trust verification...",
total_hint
);
}
let mut results = Vec::with_capacity(files.len());
let mut verified = 0u32;
let mut blocked = 0u32;
let mut warned = 0u32;
let mut multi_verified_paths: std::collections::HashSet<PathBuf> =
std::collections::HashSet::new();
if has_multi_bundle {
let multi_results = verify_multi_subject_bundle(&multi_bundle, scan_root, policy);
for result in &multi_results {
if !silent {
print_verification_line(&result.path, scan_root, result, policy.enforcement);
}
if result.outcome.is_verified() {
verified = verified.saturating_add(1);
if let Ok(canon) = std::fs::canonicalize(&result.path) {
multi_verified_paths.insert(canon);
}
} else if result.outcome.should_block(policy.enforcement) {
blocked = blocked.saturating_add(1);
} else {
warned = warned.saturating_add(1);
}
}
results.extend(multi_results);
}
for file_path in &files {
if multi_verified_paths.contains(file_path) {
continue;
}
let result = verify_instruction_file(file_path, policy);
if !silent {
print_verification_line(file_path, scan_root, &result, policy.enforcement);
}
if result.outcome.is_verified() {
verified = verified.saturating_add(1);
} else if result.outcome.should_block(policy.enforcement) {
blocked = blocked.saturating_add(1);
} else {
warned = warned.saturating_add(1);
}
results.push(result);
}
for raw_path in &policy.files {
let expanded = expand_home(raw_path);
let file_path = std::path::PathBuf::from(&expanded);
if let Ok(canon) = std::fs::canonicalize(&file_path)
&& multi_verified_paths.contains(&canon)
{
continue;
}
let result = verify_instruction_file(&file_path, policy);
if !silent {
print_verification_line(&file_path, scan_root, &result, policy.enforcement);
}
if result.outcome.is_verified() {
verified = verified.saturating_add(1);
} else if result.outcome.should_block(policy.enforcement) {
blocked = blocked.saturating_add(1);
} else {
warned = warned.saturating_add(1);
}
results.push(result);
}
if !silent && !results.is_empty() {
print_scan_summary(verified, blocked, warned, policy.enforcement);
}
Ok(ScanResult {
results,
verified,
blocked,
warned,
})
}
fn expand_home(path: &str) -> String {
if let Some(rest) = path.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
return home.join(rest).to_string_lossy().into_owned();
}
} else if path == "~"
&& let Some(home) = dirs::home_dir()
{
return home.to_string_lossy().into_owned();
}
path.to_string()
}
fn is_glob_pattern(pattern: &str) -> bool {
pattern.contains('*') || pattern.contains('?') || pattern.contains('[') || pattern.contains('{')
}
fn check_missing_literal_patterns(
policy: &TrustPolicy,
scan_root: &Path,
found_files: &[PathBuf],
silent: bool,
) -> Result<()> {
if cfg!(target_os = "linux") {
return Ok(());
}
let mut missing = Vec::new();
let mut has_globs = false;
for pattern in &policy.includes {
if is_glob_pattern(pattern) {
has_globs = true;
continue;
}
let expected = scan_root.join(pattern);
let matched = found_files.iter().any(|f| f == &expected);
if !matched && !expected.exists() {
missing.push(pattern.clone());
}
}
if has_globs && policy.enforcement.is_blocking() && !silent {
eprintln!(
" {}",
"Note: glob patterns in 'includes' only match files present at startup on macOS."
.yellow()
);
eprintln!(
" {}",
"Files created mid-session matching these patterns will not be verified.".yellow()
);
eprintln!(
" {}",
"Use literal paths for files that must always be verified, or use Linux for runtime interception."
.yellow()
);
}
if missing.is_empty() {
return Ok(());
}
match policy.enforcement {
nono::trust::Enforcement::Deny => Err(nono::NonoError::TrustVerification {
path: missing.join(", "),
reason: format!(
"literal pattern(s) in trust policy have no matching file. \
On macOS, missing files could be created mid-session with untrusted content \
(no runtime interception). \
Remove the pattern from includes or create and sign the file(s): {}",
missing.join(", ")
),
}),
_ => {
if !silent {
for m in &missing {
eprintln!(
" {} pattern '{}' has no matching file",
"Warning:".yellow(),
m
);
}
}
Ok(())
}
}
}
pub fn check_missing_literals(
policy: &TrustPolicy,
scan_root: &Path,
found_files: &[PathBuf],
silent: bool,
) -> Result<()> {
check_missing_literal_patterns(policy, scan_root, found_files, silent)
}
fn verify_instruction_file(file_path: &Path, policy: &TrustPolicy) -> VerificationResult {
let digest = match trust::file_digest(file_path) {
Ok(d) => d,
Err(e) => {
return VerificationResult {
path: file_path.to_path_buf(),
digest: String::new(),
outcome: VerificationOutcome::InvalidSignature {
detail: format!("failed to compute digest: {e}"),
},
};
}
};
let bundle_path = trust::bundle_path_for(file_path);
let signer = if bundle_path.exists() {
match load_and_extract_signer(file_path, &bundle_path, &digest, policy) {
Ok(identity) => Some(identity),
Err(outcome) => {
return VerificationResult {
path: file_path.to_path_buf(),
digest,
outcome,
};
}
}
} else {
None
};
trust::evaluate_file(policy, file_path, &digest, signer.as_ref())
}
fn load_and_extract_signer(
file_path: &Path,
bundle_path: &Path,
file_digest: &str,
policy: &TrustPolicy,
) -> std::result::Result<trust::SignerIdentity, VerificationOutcome> {
let bundle =
trust::load_bundle(bundle_path).map_err(|e| VerificationOutcome::InvalidSignature {
detail: format!("invalid bundle: {e}"),
})?;
let predicate_type = trust::extract_predicate_type(&bundle, bundle_path).map_err(|e| {
VerificationOutcome::InvalidSignature {
detail: format!("failed to extract predicate type: {e}"),
}
})?;
if predicate_type != trust::NONO_PREDICATE_TYPE {
return Err(VerificationOutcome::InvalidSignature {
detail: format!(
"wrong bundle type: expected instruction file attestation, got {predicate_type}"
),
});
}
trust::verify_bundle_subject_name(&bundle, file_path).map_err(|e| {
VerificationOutcome::InvalidSignature {
detail: format!("subject name mismatch: {e}"),
}
})?;
let identity = trust::extract_signer_identity(&bundle, bundle_path).map_err(|e| {
VerificationOutcome::InvalidSignature {
detail: format!("no signer identity: {e}"),
}
})?;
let bundle_digest = trust::extract_bundle_digest(&bundle, bundle_path).map_err(|e| {
VerificationOutcome::InvalidSignature {
detail: format!("{e}"),
}
})?;
if bundle_digest != file_digest {
return Err(VerificationOutcome::DigestMismatch {
expected: bundle_digest,
actual: file_digest.to_string(),
});
}
match &identity {
trust::SignerIdentity::Keyed { .. } => {
verify_keyed_crypto(&bundle, &identity, policy, bundle_path)?;
}
trust::SignerIdentity::Keyless { .. } => {
verify_keyless_crypto(file_path, file_digest, &bundle, bundle_path)?;
}
}
Ok(identity)
}
fn verify_keyed_crypto(
bundle: &trust::Bundle,
identity: &trust::SignerIdentity,
policy: &TrustPolicy,
bundle_path: &Path,
) -> std::result::Result<(), VerificationOutcome> {
let matching = policy.matching_publishers(identity);
let pub_key_b64 = matching.iter().find_map(|p| p.public_key.as_ref());
let key_bytes = if let Some(b64) = pub_key_b64 {
base64_decode(b64).map_err(|_| VerificationOutcome::InvalidSignature {
detail: "invalid base64 in publisher public_key".to_string(),
})?
} else if let trust::SignerIdentity::Keyed { key_id } = identity {
crate::trust_cmd::load_public_key_bytes(key_id).map_err(|e| {
VerificationOutcome::InvalidSignature {
detail: format!(
"no public_key in publisher and keystore lookup failed for '{key_id}': {e}"
),
}
})?
} else {
return Err(VerificationOutcome::InvalidSignature {
detail: "keyed bundle but no public_key in matching publisher".to_string(),
});
};
trust::verify_keyed_signature(bundle, &key_bytes, bundle_path).map_err(|e| {
VerificationOutcome::InvalidSignature {
detail: format!("{e}"),
}
})?;
Ok(())
}
fn verify_keyless_crypto(
file_path: &Path,
file_digest: &str,
bundle: &trust::Bundle,
bundle_path: &Path,
) -> std::result::Result<(), VerificationOutcome> {
let trusted_root = trust::load_production_trusted_root().map_err(|e| {
VerificationOutcome::InvalidSignature {
detail: format!("failed to load Sigstore trusted root: {e}"),
}
})?;
let policy = trust::VerificationPolicy::default();
trust::verify_bundle_with_digest(file_digest, bundle, &trusted_root, &policy, file_path)
.map_err(|e| VerificationOutcome::InvalidSignature {
detail: format!("Sigstore verification failed: {e}"),
})?;
let _ = bundle_path; Ok(())
}
pub(crate) fn safe_subject_path(
scan_root: &Path,
name: &str,
) -> std::result::Result<PathBuf, String> {
let name_path = std::path::Path::new(name);
if name_path.is_absolute() {
return Err(format!(
"subject name '{name}' rejected: absolute paths are not permitted in bundle subjects"
));
}
for component in name_path.components() {
if component == std::path::Component::ParentDir {
return Err(format!(
"subject name '{name}' rejected: '..' components are not permitted in bundle subjects"
));
}
}
let joined = scan_root.join(name_path);
let canon_root = std::fs::canonicalize(scan_root)
.map_err(|e| format!("failed to canonicalize scan root: {e}"))?;
if let Ok(canon_path) = std::fs::canonicalize(&joined)
&& !canon_path.starts_with(&canon_root)
{
return Err(format!(
"subject '{name}' resolves outside scan root via symlink"
));
}
Ok(joined)
}
fn verify_multi_subject_bundle(
bundle_path: &Path,
scan_root: &Path,
policy: &TrustPolicy,
) -> Vec<VerificationResult> {
let bundle = match trust::load_bundle(bundle_path) {
Ok(b) => b,
Err(e) => {
return vec![VerificationResult {
path: bundle_path.to_path_buf(),
digest: String::new(),
outcome: VerificationOutcome::InvalidSignature {
detail: format!("invalid bundle: {e}"),
},
}];
}
};
let predicate_type = match trust::extract_predicate_type(&bundle, bundle_path) {
Ok(pt) => pt,
Err(e) => {
return vec![VerificationResult {
path: bundle_path.to_path_buf(),
digest: String::new(),
outcome: VerificationOutcome::InvalidSignature {
detail: format!("failed to extract predicate type: {e}"),
},
}];
}
};
if predicate_type != trust::NONO_MULTI_SUBJECT_PREDICATE_TYPE {
return vec![VerificationResult {
path: bundle_path.to_path_buf(),
digest: String::new(),
outcome: VerificationOutcome::InvalidSignature {
detail: format!(
"wrong bundle type: expected multi-file attestation, got {predicate_type}"
),
},
}];
}
let identity = match trust::extract_signer_identity(&bundle, bundle_path) {
Ok(id) => id,
Err(e) => {
return vec![VerificationResult {
path: bundle_path.to_path_buf(),
digest: String::new(),
outcome: VerificationOutcome::InvalidSignature {
detail: format!("no signer identity: {e}"),
},
}];
}
};
let crypto_result = match &identity {
trust::SignerIdentity::Keyed { .. } => {
verify_keyed_crypto(&bundle, &identity, policy, bundle_path)
}
trust::SignerIdentity::Keyless { .. } => {
let subjects = match trust::extract_all_subjects(&bundle, bundle_path) {
Ok(s) => s,
Err(e) => {
return vec![VerificationResult {
path: bundle_path.to_path_buf(),
digest: String::new(),
outcome: VerificationOutcome::InvalidSignature {
detail: format!("failed to extract subjects: {e}"),
},
}];
}
};
if let Some((_, digest)) = subjects.first() {
verify_keyless_crypto(bundle_path, digest, &bundle, bundle_path)
} else {
Err(VerificationOutcome::InvalidSignature {
detail: "no subjects in multi-subject bundle".to_string(),
})
}
}
};
if let Err(outcome) = crypto_result {
return vec![VerificationResult {
path: bundle_path.to_path_buf(),
digest: String::new(),
outcome,
}];
}
let subjects = match trust::extract_all_subjects(&bundle, bundle_path) {
Ok(s) => s,
Err(e) => {
return vec![VerificationResult {
path: bundle_path.to_path_buf(),
digest: String::new(),
outcome: VerificationOutcome::InvalidSignature {
detail: format!("failed to extract subjects: {e}"),
},
}];
}
};
let publisher_name = format_identity(&identity);
let mut results = Vec::with_capacity(subjects.len());
for (name, expected_digest) in &subjects {
let file_path = match safe_subject_path(scan_root, name) {
Ok(p) => p,
Err(reason) => {
results.push(VerificationResult {
path: scan_root.to_path_buf(),
digest: String::new(),
outcome: VerificationOutcome::InvalidSignature { detail: reason },
});
continue;
}
};
let actual_digest = match trust::file_digest(&file_path) {
Ok(d) => d,
Err(e) => {
results.push(VerificationResult {
path: file_path,
digest: String::new(),
outcome: VerificationOutcome::InvalidSignature {
detail: format!("failed to read subject file: {e}"),
},
});
continue;
}
};
if actual_digest != *expected_digest {
results.push(VerificationResult {
path: file_path,
digest: actual_digest.clone(),
outcome: VerificationOutcome::DigestMismatch {
expected: expected_digest.clone(),
actual: actual_digest,
},
});
continue;
}
let matching = policy.matching_publishers(&identity);
if matching.is_empty() {
results.push(VerificationResult {
path: file_path,
digest: actual_digest,
outcome: VerificationOutcome::UntrustedPublisher {
identity: identity.clone(),
},
});
continue;
}
results.push(VerificationResult {
path: file_path,
digest: actual_digest,
outcome: VerificationOutcome::Verified {
publisher: publisher_name.clone(),
},
});
}
results
}
fn print_verification_line(
file_path: &Path,
scan_root: &Path,
result: &VerificationResult,
enforcement: Enforcement,
) {
let rel = file_path.strip_prefix(scan_root).unwrap_or(file_path);
match &result.outcome {
VerificationOutcome::Verified { publisher } => {
eprintln!(
" {} {} (publisher: {})",
"PASS".green(),
rel.display(),
publisher
);
}
VerificationOutcome::Blocked { reason } => {
eprintln!(
" {} {} (blocklisted: {})",
"BLOCK".red(),
rel.display(),
reason
);
}
outcome => {
let label = if outcome.should_block(enforcement) {
"FAIL".red()
} else {
"WARN".yellow()
};
let detail = match outcome {
VerificationOutcome::Unsigned => "no .bundle file".to_string(),
VerificationOutcome::InvalidSignature { detail } => detail.clone(),
VerificationOutcome::UntrustedPublisher { identity } => {
format!("untrusted signer: {}", format_identity(identity))
}
VerificationOutcome::DigestMismatch { .. } => {
"file content does not match bundle".to_string()
}
_ => "unknown".to_string(),
};
eprintln!(" {} {} ({})", label, rel.display(), detail);
}
}
}
fn print_scan_summary(verified: u32, blocked: u32, warned: u32, enforcement: Enforcement) {
eprintln!();
if blocked > 0 {
eprintln!(
" {}",
format!("Trust scan: {verified} verified, {blocked} blocked, {warned} warned").red()
);
if enforcement.is_blocking() {
eprintln!(
" {}",
"Aborting: instruction files failed trust verification (enforcement=deny).".red()
);
}
} else if warned > 0 {
eprintln!(
" {}",
format!("Trust scan: {verified} verified, {warned} warned (enforcement allows)")
.yellow()
);
} else if verified > 0 {
eprintln!(
" {}",
format!("Trust scan: {verified} file(s) verified.").green()
);
}
}
fn base64_decode(input: &str) -> std::result::Result<Vec<u8>, ()> {
nono::trust::base64::base64_decode(input).map_err(|_| ())
}
fn format_identity(identity: &trust::SignerIdentity) -> String {
match identity {
trust::SignerIdentity::Keyed { key_id } => format!("{key_id} (keyed)"),
trust::SignerIdentity::Keyless {
build_signer_uri, ..
} if !build_signer_uri.is_empty() => build_signer_uri.clone(),
trust::SignerIdentity::Keyless {
repository,
workflow,
..
} => format!("{repository} ({workflow})"),
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn scan_empty_dir_returns_empty_result() {
let dir = tempfile::tempdir().unwrap();
let policy = TrustPolicy::default();
let result = run_pre_exec_scan(dir.path(), &policy, true, &[]).unwrap();
assert!(result.should_proceed());
assert_eq!(result.verified, 0);
assert_eq!(result.blocked, 0);
assert_eq!(result.warned, 0);
assert!(result.results.is_empty());
}
#[test]
fn scan_has_signed_artifacts_ignores_unsigned_matches() {
let dir = tempfile::tempdir().unwrap();
let file_name = "arbitrary-instructions.txt";
std::fs::write(dir.path().join(file_name), "content").unwrap();
let policy = TrustPolicy {
includes: vec![file_name.to_string()],
..TrustPolicy::default()
};
let has_signed_artifacts = scan_has_signed_artifacts(dir.path(), &policy, &[]).unwrap();
assert!(!has_signed_artifacts);
}
#[test]
fn scan_has_signed_artifacts_empty_policy_returns_false() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("SKILLS.md"), "content").unwrap();
let has_signed_artifacts =
scan_has_signed_artifacts(dir.path(), &TrustPolicy::default(), &[]).unwrap();
assert!(!has_signed_artifacts);
}
#[test]
fn scan_has_signed_artifacts_detects_per_file_bundle() {
let dir = tempfile::tempdir().unwrap();
let file_name = "arbitrary-instructions.txt";
let file_path = dir.path().join(file_name);
std::fs::write(&file_path, "content").unwrap();
let key_pair = trust::generate_signing_key().unwrap();
let key_id = trust::key_id_hex(&key_pair).unwrap();
let bundle_json = trust::sign_instruction_file(&file_path, &key_pair, &key_id).unwrap();
std::fs::write(trust::bundle_path_for(&file_path), bundle_json).unwrap();
let policy = TrustPolicy {
includes: vec![file_name.to_string()],
..TrustPolicy::default()
};
let has_signed_artifacts = scan_has_signed_artifacts(dir.path(), &policy, &[]).unwrap();
assert!(has_signed_artifacts);
}
#[test]
fn run_pre_exec_scan_respects_skip_dirs() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("generated")).unwrap();
std::fs::write(dir.path().join("generated").join("SKILLS.md"), "generated").unwrap();
std::fs::write(dir.path().join("SKILLS.md"), "root").unwrap();
let policy = TrustPolicy {
includes: vec!["SKILLS*".to_string()],
enforcement: Enforcement::Audit,
..TrustPolicy::default()
};
let result =
run_pre_exec_scan(dir.path(), &policy, true, &[String::from("generated")]).unwrap();
assert_eq!(result.results.len(), 1);
assert_eq!(result.results[0].path, dir.path().join("SKILLS.md"));
}
#[test]
fn scan_unsigned_file_warn_enforcement_proceeds() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("SKILLS.md"), "# Skills").unwrap();
let policy = TrustPolicy {
includes: vec!["SKILLS.md".to_string()],
enforcement: Enforcement::Warn,
..TrustPolicy::default()
};
let result = run_pre_exec_scan(dir.path(), &policy, true, &[]).unwrap();
assert!(result.should_proceed());
assert_eq!(result.verified, 0);
assert_eq!(result.warned, 1);
}
#[test]
fn scan_unsigned_file_deny_enforcement_blocks() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("CLAUDE.md"), "# Claude").unwrap();
let policy = TrustPolicy {
includes: vec!["CLAUDE.md".to_string()],
enforcement: Enforcement::Deny,
..TrustPolicy::default()
};
let result = run_pre_exec_scan(dir.path(), &policy, true, &[]).unwrap();
assert!(!result.should_proceed());
assert_eq!(result.blocked, 1);
}
#[test]
fn scan_blocklisted_file_always_blocks() {
let dir = tempfile::tempdir().unwrap();
let content = b"malicious content";
std::fs::write(dir.path().join("SKILLS.md"), content).unwrap();
let digest = trust::bytes_digest(content);
let policy = TrustPolicy {
includes: vec!["SKILLS.md".to_string()],
enforcement: Enforcement::Audit, blocklist: trust::Blocklist {
digests: vec![trust::BlocklistEntry {
sha256: digest,
description: "known malicious".to_string(),
added: "2026-01-01".to_string(),
}],
publishers: Vec::new(),
},
..TrustPolicy::default()
};
let result = run_pre_exec_scan(dir.path(), &policy, true, &[]).unwrap();
assert!(!result.should_proceed());
assert_eq!(result.blocked, 1);
}
#[test]
fn scan_audit_enforcement_always_proceeds() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("SKILLS.md"), "# Skills").unwrap();
std::fs::write(dir.path().join("CLAUDE.md"), "# Claude").unwrap();
let policy = TrustPolicy {
includes: vec!["SKILLS.md".to_string(), "CLAUDE.md".to_string()],
enforcement: Enforcement::Audit,
..TrustPolicy::default()
};
let result = run_pre_exec_scan(dir.path(), &policy, true, &[]).unwrap();
assert!(result.should_proceed());
assert_eq!(result.warned, 2);
}
#[test]
fn verified_paths_returns_only_verified() {
let results = vec![
VerificationResult {
path: PathBuf::from("/tmp/SKILLS.md"),
digest: "abc".to_string(),
outcome: VerificationOutcome::Verified {
publisher: "test (keyed)".to_string(),
},
},
VerificationResult {
path: PathBuf::from("/tmp/CLAUDE.md"),
digest: "def".to_string(),
outcome: VerificationOutcome::Unsigned,
},
VerificationResult {
path: PathBuf::from("/tmp/AGENT.MD"),
digest: "ghi".to_string(),
outcome: VerificationOutcome::Verified {
publisher: "ci (keyless)".to_string(),
},
},
];
let scan = ScanResult {
results,
verified: 2,
blocked: 0,
warned: 1,
};
let paths = scan.verified_paths();
assert_eq!(paths.len(), 2);
assert_eq!(paths[0], PathBuf::from("/tmp/SKILLS.md"));
assert_eq!(paths[1], PathBuf::from("/tmp/AGENT.MD"));
}
#[test]
fn verified_paths_empty_when_none_verified() {
let scan = ScanResult {
results: vec![VerificationResult {
path: PathBuf::from("/tmp/SKILLS.md"),
digest: "abc".to_string(),
outcome: VerificationOutcome::Unsigned,
}],
verified: 0,
blocked: 0,
warned: 1,
};
assert!(scan.verified_paths().is_empty());
}
#[test]
fn load_scan_policy_with_trust_override_skips_verification() {
let _guard = match crate::test_env::ENV_LOCK.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
let dir = tempfile::tempdir().unwrap();
let xdg_dir = dir.path().join("xdg");
std::fs::create_dir_all(&xdg_dir).unwrap();
let _env = crate::test_env::EnvVarGuard::set_all(&[(
"XDG_CONFIG_HOME",
xdg_dir.to_str().unwrap(),
)]);
std::fs::write(
dir.path().join("trust-policy.json"),
r#"{"version":1,"includes":["SKILLS*","CLAUDE*"],"publishers":[],"blocklist":{"digests":[],"publishers":[]},"enforcement":"warn"}"#,
)
.unwrap();
let policy = load_scan_policy(dir.path(), true, &[]).unwrap();
assert_eq!(policy.enforcement, Enforcement::Warn);
}
#[test]
fn load_scan_policy_skips_policy_verification_without_signed_artifacts() {
let _guard = match crate::test_env::ENV_LOCK.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
let scan_dir = tempfile::tempdir().unwrap();
let include_pattern = "*.arbitrary";
let xdg_dir = scan_dir.path().join("xdg");
std::fs::create_dir_all(&xdg_dir).unwrap();
let _env = crate::test_env::EnvVarGuard::set_all(&[(
"XDG_CONFIG_HOME",
xdg_dir.to_str().unwrap(),
)]);
std::fs::write(scan_dir.path().join("notes.arbitrary"), "unsigned").unwrap();
let project_policy_path = scan_dir.path().join("trust-policy.json");
std::fs::write(
&project_policy_path,
format!(
r#"{{"version":1,"includes":["{include_pattern}"],"publishers":[],"blocklist":{{"digests":[],"publishers":[]}},"enforcement":"warn"}}"#
),
)
.unwrap();
let policy = load_scan_policy(scan_dir.path(), false, &[]).unwrap();
assert!(policy.includes.contains(&include_pattern.to_string()));
}
#[test]
fn verify_policy_signature_missing_bundle_returns_error() {
let dir = tempfile::tempdir().unwrap();
let policy_path = dir.path().join("trust-policy.json");
std::fs::write(&policy_path, "{}").unwrap();
let result = verify_policy_signature(&policy_path);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("unsigned"));
}
#[test]
fn multi_subject_bundle_detected_and_verified() {
let dir = tempfile::tempdir().unwrap();
let content_a = b"file A content";
let content_b = b"file B content";
std::fs::write(dir.path().join("a.md"), content_a).unwrap();
std::fs::write(dir.path().join("b.py"), content_b).unwrap();
let digest_a = trust::bytes_digest(content_a);
let digest_b = trust::bytes_digest(content_b);
let key_pair = trust::generate_signing_key().unwrap();
let key_id = trust::key_id_hex(&key_pair).unwrap();
let pub_key_bytes = trust::export_public_key(&key_pair).unwrap();
let pub_key_b64 = nono::trust::base64::base64_encode(pub_key_bytes.as_bytes());
let files = vec![
(std::path::PathBuf::from("a.md"), digest_a),
(std::path::PathBuf::from("b.py"), digest_b),
];
let bundle_json = trust::sign_files(&files, &key_pair, &key_id).unwrap();
let bundle_path = trust::multi_subject_bundle_path(dir.path());
std::fs::write(&bundle_path, &bundle_json).unwrap();
let policy = TrustPolicy {
includes: Vec::new(), enforcement: Enforcement::Deny,
publishers: vec![trust::Publisher {
name: "test".to_string(),
issuer: None,
repository: None,
workflow: None,
ref_pattern: None,
key_id: Some(key_id),
public_key: Some(pub_key_b64),
build_signer_uri: None,
}],
..TrustPolicy::default()
};
let result = run_pre_exec_scan(dir.path(), &policy, true, &[]).unwrap();
assert!(result.should_proceed());
assert_eq!(result.verified, 2);
assert_eq!(result.blocked, 0);
}
#[test]
fn multi_subject_bundle_detects_tampered_file() {
let dir = tempfile::tempdir().unwrap();
let content_a = b"file A content";
let content_b = b"file B content";
std::fs::write(dir.path().join("a.md"), content_a).unwrap();
std::fs::write(dir.path().join("b.py"), content_b).unwrap();
let digest_a = trust::bytes_digest(content_a);
let digest_b = trust::bytes_digest(content_b);
let key_pair = trust::generate_signing_key().unwrap();
let key_id = trust::key_id_hex(&key_pair).unwrap();
let pub_key_bytes = trust::export_public_key(&key_pair).unwrap();
let pub_key_b64 = nono::trust::base64::base64_encode(pub_key_bytes.as_bytes());
let files = vec![
(std::path::PathBuf::from("a.md"), digest_a),
(std::path::PathBuf::from("b.py"), digest_b),
];
let bundle_json = trust::sign_files(&files, &key_pair, &key_id).unwrap();
std::fs::write(trust::multi_subject_bundle_path(dir.path()), &bundle_json).unwrap();
std::fs::write(dir.path().join("b.py"), b"TAMPERED").unwrap();
let policy = TrustPolicy {
includes: Vec::new(),
enforcement: Enforcement::Deny,
publishers: vec![trust::Publisher {
name: "test".to_string(),
issuer: None,
repository: None,
workflow: None,
ref_pattern: None,
key_id: Some(key_id),
public_key: Some(pub_key_b64),
build_signer_uri: None,
}],
..TrustPolicy::default()
};
let result = run_pre_exec_scan(dir.path(), &policy, true, &[]).unwrap();
assert_eq!(result.verified, 1);
assert_eq!(result.blocked, 1);
assert!(!result.should_proceed());
}
#[test]
fn multi_subject_bundle_missing_file_fails() {
let dir = tempfile::tempdir().unwrap();
let content_a = b"file A content";
std::fs::write(dir.path().join("a.md"), content_a).unwrap();
let digest_a = trust::bytes_digest(content_a);
let digest_b = trust::bytes_digest(b"file B content");
let key_pair = trust::generate_signing_key().unwrap();
let key_id = trust::key_id_hex(&key_pair).unwrap();
let pub_key_bytes = trust::export_public_key(&key_pair).unwrap();
let pub_key_b64 = nono::trust::base64::base64_encode(pub_key_bytes.as_bytes());
let files = vec![
(std::path::PathBuf::from("a.md"), digest_a),
(std::path::PathBuf::from("b.py"), digest_b), ];
let bundle_json = trust::sign_files(&files, &key_pair, &key_id).unwrap();
std::fs::write(trust::multi_subject_bundle_path(dir.path()), &bundle_json).unwrap();
let policy = TrustPolicy {
includes: Vec::new(),
enforcement: Enforcement::Deny,
publishers: vec![trust::Publisher {
name: "test".to_string(),
issuer: None,
repository: None,
workflow: None,
ref_pattern: None,
key_id: Some(key_id),
public_key: Some(pub_key_b64),
build_signer_uri: None,
}],
..TrustPolicy::default()
};
let result = run_pre_exec_scan(dir.path(), &policy, true, &[]).unwrap();
assert_eq!(result.verified, 1); assert_eq!(result.blocked, 1); }
#[test]
fn multi_subject_verified_paths_included() {
let dir = tempfile::tempdir().unwrap();
let content_a = b"script content";
std::fs::write(dir.path().join("script.py"), content_a).unwrap();
let digest_a = trust::bytes_digest(content_a);
let key_pair = trust::generate_signing_key().unwrap();
let key_id = trust::key_id_hex(&key_pair).unwrap();
let pub_key_bytes = trust::export_public_key(&key_pair).unwrap();
let pub_key_b64 = nono::trust::base64::base64_encode(pub_key_bytes.as_bytes());
let files = vec![(std::path::PathBuf::from("script.py"), digest_a)];
let bundle_json = trust::sign_files(&files, &key_pair, &key_id).unwrap();
std::fs::write(trust::multi_subject_bundle_path(dir.path()), &bundle_json).unwrap();
let policy = TrustPolicy {
includes: Vec::new(),
enforcement: Enforcement::Deny,
publishers: vec![trust::Publisher {
name: "test".to_string(),
issuer: None,
repository: None,
workflow: None,
ref_pattern: None,
key_id: Some(key_id),
public_key: Some(pub_key_b64),
build_signer_uri: None,
}],
..TrustPolicy::default()
};
let result = run_pre_exec_scan(dir.path(), &policy, true, &[]).unwrap();
let paths = result.verified_paths();
assert_eq!(paths.len(), 1);
assert_eq!(paths[0], dir.path().join("script.py"));
}
#[test]
fn multi_subject_untrusted_publisher_blocks() {
let dir = tempfile::tempdir().unwrap();
let content = b"content";
std::fs::write(dir.path().join("a.md"), content).unwrap();
let digest = trust::bytes_digest(content);
let key_pair = trust::generate_signing_key().unwrap();
let key_id = trust::key_id_hex(&key_pair).unwrap();
let files = vec![(std::path::PathBuf::from("a.md"), digest)];
let bundle_json = trust::sign_files(&files, &key_pair, &key_id).unwrap();
std::fs::write(trust::multi_subject_bundle_path(dir.path()), &bundle_json).unwrap();
let other_key_pair = trust::generate_signing_key().unwrap();
let other_key_id = trust::key_id_hex(&other_key_pair).unwrap();
let other_pub_bytes = trust::export_public_key(&other_key_pair).unwrap();
let other_pub_b64 = nono::trust::base64::base64_encode(other_pub_bytes.as_bytes());
let policy = TrustPolicy {
includes: Vec::new(),
enforcement: Enforcement::Deny,
publishers: vec![trust::Publisher {
name: "other".to_string(),
issuer: None,
repository: None,
workflow: None,
ref_pattern: None,
key_id: Some(other_key_id),
public_key: Some(other_pub_b64),
build_signer_uri: None,
}],
..TrustPolicy::default()
};
let result = run_pre_exec_scan(dir.path(), &policy, true, &[]).unwrap();
assert!(!result.should_proceed());
assert_eq!(result.blocked, 1);
}
#[test]
fn scan_nonmatching_files_ignored() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("README.md"), "# Readme").unwrap();
std::fs::write(dir.path().join("src.rs"), "fn main() {}").unwrap();
let policy = TrustPolicy::default();
let result = run_pre_exec_scan(dir.path(), &policy, true, &[]).unwrap();
assert!(result.should_proceed());
assert!(result.results.is_empty());
}
#[test]
fn missing_literal_pattern_blocks_with_deny_enforcement() {
let dir = tempfile::tempdir().unwrap();
let policy = TrustPolicy {
includes: vec!["SKILLS.md".to_string()],
enforcement: Enforcement::Deny,
..TrustPolicy::default()
};
let result = run_pre_exec_scan(dir.path(), &policy, true, &[]);
if cfg!(target_os = "linux") {
assert!(result.is_ok());
return;
}
match result {
Err(err) => {
let err = err.to_string();
assert!(err.contains("SKILLS.md"));
assert!(err.contains("no matching file"));
}
Ok(_) => panic!("expected missing literal includes to block startup on this platform"),
}
}
#[test]
fn missing_literal_pattern_warns_with_warn_enforcement() {
let dir = tempfile::tempdir().unwrap();
let policy = TrustPolicy {
includes: vec!["SKILLS.md".to_string()],
enforcement: Enforcement::Warn,
..TrustPolicy::default()
};
let result = run_pre_exec_scan(dir.path(), &policy, true, &[]);
assert!(result.is_ok());
}
#[test]
fn glob_pattern_with_no_matches_does_not_block() {
let dir = tempfile::tempdir().unwrap();
let policy = TrustPolicy {
includes: vec!["SKILLS*".to_string()],
enforcement: Enforcement::Deny,
..TrustPolicy::default()
};
let result = run_pre_exec_scan(dir.path(), &policy, true, &[]);
assert!(result.is_ok());
}
#[test]
fn literal_pattern_present_on_disk_does_not_block() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("SKILLS.md"), "# Skills").unwrap();
let policy = TrustPolicy {
includes: vec!["SKILLS.md".to_string()],
enforcement: Enforcement::Deny,
..TrustPolicy::default()
};
let result = run_pre_exec_scan(dir.path(), &policy, true, &[]);
assert!(result.is_ok());
}
#[test]
fn is_glob_pattern_classification() {
assert!(!is_glob_pattern("SKILLS.md"));
assert!(!is_glob_pattern(".claude/commands/deploy.md"));
assert!(is_glob_pattern("SKILLS*"));
assert!(is_glob_pattern("CLAUDE*.md"));
assert!(is_glob_pattern(".claude/**/*.md"));
assert!(is_glob_pattern("test[0-9].md"));
assert!(is_glob_pattern("{a,b}.md"));
}
#[test]
fn safe_subject_path_accepts_plain_filename() {
let dir = tempfile::tempdir().unwrap();
let result = safe_subject_path(dir.path(), "SKILLS.md").unwrap();
assert_eq!(result, dir.path().join("SKILLS.md"));
}
#[test]
fn safe_subject_path_accepts_subdirectory() {
let dir = tempfile::tempdir().unwrap();
let result = safe_subject_path(dir.path(), ".claude/commands/deploy.md").unwrap();
assert_eq!(result, dir.path().join(".claude/commands/deploy.md"));
}
#[test]
fn safe_subject_path_rejects_absolute_path() {
let root = std::path::Path::new("/tmp/scan");
let err = safe_subject_path(root, "/etc/passwd").unwrap_err();
assert!(
err.contains("absolute"),
"error should mention 'absolute': {err}"
);
}
#[test]
fn safe_subject_path_rejects_relative_dotdot_traversal() {
let root = std::path::Path::new("/tmp/scan");
let err = safe_subject_path(root, "../../../etc/shadow").unwrap_err();
assert!(err.contains(".."), "error should mention '..': {err}");
}
#[test]
fn safe_subject_path_rejects_embedded_dotdot() {
let root = std::path::Path::new("/tmp/scan");
let err = safe_subject_path(root, "subdir/../../etc/passwd").unwrap_err();
assert!(err.contains(".."), "error should mention '..': {err}");
}
#[test]
fn safe_subject_path_rejects_trailing_dotdot() {
let root = std::path::Path::new("/tmp/scan");
let err = safe_subject_path(root, "subdir/..").unwrap_err();
assert!(err.contains(".."), "error should mention '..': {err}");
}
#[cfg(unix)]
#[test]
fn safe_subject_path_rejects_symlink_escape() {
use std::os::unix::fs::symlink;
let outer = tempfile::tempdir().unwrap();
let scan_root = outer.path().join("scan");
std::fs::create_dir_all(&scan_root).unwrap();
let outside = outer.path().join("secret.txt");
std::fs::write(&outside, "SECRET").unwrap();
let link = scan_root.join("link.txt");
symlink(&outside, &link).unwrap();
let err = safe_subject_path(&scan_root, "link.txt").unwrap_err();
assert!(
err.contains("symlink") || err.contains("outside"),
"error must describe the escape: {err}"
);
}
#[cfg(unix)]
#[test]
fn multi_subject_bundle_rejects_symlink_escape() {
use nono::trust;
use std::os::unix::fs::symlink;
let outer = tempfile::tempdir().unwrap();
let scan_root = outer.path().join("scan");
std::fs::create_dir_all(&scan_root).unwrap();
let content = b"SYMLINK_SECRET";
let outside = outer.path().join("secret.txt");
std::fs::write(&outside, content).unwrap();
let secret_digest = trust::bytes_digest(content);
let link_name = "link.txt";
symlink(&outside, scan_root.join(link_name)).unwrap();
let key_pair = trust::generate_signing_key().unwrap();
let key_id = trust::key_id_hex(&key_pair).unwrap();
let pub_key_bytes = trust::export_public_key(&key_pair).unwrap();
let pub_key_b64 = nono::trust::base64::base64_encode(pub_key_bytes.as_bytes());
let files = vec![(std::path::PathBuf::from(link_name), secret_digest)];
let bundle_json = trust::sign_files(&files, &key_pair, &key_id).unwrap();
let bundle_path = trust::multi_subject_bundle_path(&scan_root);
std::fs::write(&bundle_path, &bundle_json).unwrap();
let policy = TrustPolicy {
includes: Vec::new(),
enforcement: Enforcement::Deny,
publishers: vec![trust::Publisher {
name: "attacker".to_string(),
issuer: None,
repository: None,
workflow: None,
ref_pattern: None,
key_id: Some(key_id),
public_key: Some(pub_key_b64),
build_signer_uri: None,
}],
..TrustPolicy::default()
};
let results = verify_multi_subject_bundle(&bundle_path, &scan_root, &policy);
assert_eq!(
results.len(),
1,
"expected one result for the symlink subject"
);
let outcome = &results[0].outcome;
assert!(
matches!(outcome, VerificationOutcome::InvalidSignature { .. }),
"symlink escape must yield InvalidSignature, got: {outcome:?}"
);
assert!(
!outcome.is_verified(),
"symlink escape must not pass as verified"
);
}
#[test]
fn multi_subject_bundle_rejects_traversal_subject_name() {
use nono::trust;
let outer = tempfile::tempdir().unwrap();
let scan_root = outer.path().join("scan");
std::fs::create_dir_all(&scan_root).unwrap();
let secret = outer.path().join("secret.txt");
std::fs::write(&secret, "SECRET").unwrap();
let secret_digest = trust::bytes_digest(b"SECRET");
let traversal_name = "../secret.txt";
let key_pair = trust::generate_signing_key().unwrap();
let key_id = trust::key_id_hex(&key_pair).unwrap();
let pub_key_bytes = trust::export_public_key(&key_pair).unwrap();
let pub_key_b64 = nono::trust::base64::base64_encode(pub_key_bytes.as_bytes());
let files = vec![(std::path::PathBuf::from(traversal_name), secret_digest)];
let bundle_json = trust::sign_files(&files, &key_pair, &key_id).unwrap();
let bundle_path = trust::multi_subject_bundle_path(&scan_root);
std::fs::write(&bundle_path, &bundle_json).unwrap();
let policy = TrustPolicy {
includes: Vec::new(),
enforcement: Enforcement::Deny,
publishers: vec![trust::Publisher {
name: "attacker".to_string(),
issuer: None,
repository: None,
workflow: None,
ref_pattern: None,
key_id: Some(key_id),
public_key: Some(pub_key_b64),
build_signer_uri: None,
}],
..TrustPolicy::default()
};
let results = verify_multi_subject_bundle(&bundle_path, &scan_root, &policy);
assert_eq!(
results.len(),
1,
"expected one result for the traversal subject"
);
let outcome = &results[0].outcome;
assert!(
matches!(outcome, VerificationOutcome::InvalidSignature { .. }),
"traversal subject must yield InvalidSignature, got: {outcome:?}"
);
assert!(
!outcome.is_verified(),
"traversal must not pass as verified"
);
}
}