use super::*;
use crate::parse::Token;
use crate::registry::{dispatch_spec, load_toml};
use proptest::prelude::*;
fn arb_unknown_name() -> impl Strategy<Value = String> {
"[a-z][a-z0-9_]{3,11}".prop_filter("must be unknown to safe-chains", |n| !is_known(n))
}
fn arb_flag() -> impl Strategy<Value = String> {
prop_oneof![
"-[a-z]",
"-[a-z]{2,4}",
"--[a-z][a-z-]{1,7}",
"--[a-z]{2,6}=[a-z0-9]{1,4}",
]
}
fn arb_positional() -> impl Strategy<Value = String> {
"[a-z0-9][a-z0-9._/]{0,7}"
}
const UNOBSERVED_FLAG: &str = "--never-observed-9z";
fn argv_command(name: &str, flags: &[String], positionals: &[String]) -> (String, Vec<String>) {
let mut argv = vec![name.to_string()];
argv.extend(flags.iter().cloned());
argv.extend(positionals.iter().cloned());
(argv.join(" "), argv)
}
fn tokens(argv: &[String]) -> Vec<Token> {
argv.iter().map(|s| Token::from_test(s)).collect()
}
fn only_entry(command: &str) -> GeneratedEntry {
match analyze(command) {
Outcome::Generated { mut entries, .. } => {
assert_eq!(entries.len(), 1, "expected one entry for `{command}`");
entries.remove(0)
}
other => panic!("expected Generated for `{command}`, got {other:?}"),
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(600))]
#[test]
fn generated_entry_admits_the_observed_command(
name in arb_unknown_name(),
flags in prop::collection::vec(arb_flag(), 0..5),
positionals in prop::collection::vec(arb_positional(), 0..4),
) {
let (command, argv) = argv_command(&name, &flags, &positionals);
let outcome = analyze(&command);
prop_assert!(matches!(outcome, Outcome::Generated { .. }), "expected Generated, got {outcome:?}");
let Outcome::Generated { entries, .. } = outcome else { unreachable!() };
prop_assert_eq!(entries.len(), 1);
prop_assert_eq!(&entries[0].name, &name);
let toml = render_toml(&entries);
let specs = load_toml(&toml, "suggest-test");
prop_assert_eq!(specs.len(), 1, "generated toml did not load to one spec:\n{}", toml);
prop_assert!(
dispatch_spec(&tokens(&argv), &specs[0]).is_allowed(),
"generated entry did not admit `{}`; toml:\n{}", command, toml
);
}
#[test]
fn generated_entry_rejects_an_unobserved_flag(
name in arb_unknown_name(),
flags in prop::collection::vec(arb_flag(), 0..4),
positionals in prop::collection::vec(arb_positional(), 0..3),
) {
let (command, _) = argv_command(&name, &flags, &positionals);
let entry = only_entry(&command);
prop_assert!(!entry.standalone.iter().any(|f| f == UNOBSERVED_FLAG));
let toml = render_toml(std::slice::from_ref(&entry));
let specs = load_toml(&toml, "suggest-test");
let probe = tokens(&[name.clone(), UNOBSERVED_FLAG.to_string()]);
prop_assert!(
!dispatch_spec(&probe, &specs[0]).is_allowed(),
"entry wrongly admitted an unobserved flag; toml:\n{}", toml
);
}
#[test]
fn generated_entry_bounds_positionals(
name in arb_unknown_name(),
positionals in prop::collection::vec(arb_positional(), 0..4),
) {
let (command, _) = argv_command(&name, &[], &positionals);
let entry = only_entry(&command);
prop_assert_eq!(entry.max_positional, positionals.len());
let toml = render_toml(std::slice::from_ref(&entry));
let specs = load_toml(&toml, "suggest-test");
let mut argv = vec![name.clone()];
argv.extend(std::iter::repeat_n("x".to_string(), entry.max_positional + 1));
prop_assert!(
!dispatch_spec(&tokens(&argv), &specs[0]).is_allowed(),
"entry admitted {}+1 positionals; toml:\n{}", entry.max_positional, toml
);
}
#[test]
fn generated_toml_always_loads(
name in arb_unknown_name(),
flags in prop::collection::vec(arb_flag(), 0..6),
max_positional in 0usize..6,
) {
let entry = GeneratedEntry {
name: name.clone(),
standalone: {
let mut v: Vec<String> = flags.into_iter().collect::<BTreeSet<_>>().into_iter().collect();
v.sort();
v
},
max_positional,
level: "SafeWrite".to_string(),
};
let toml = render_toml(std::slice::from_ref(&entry));
let specs = load_toml(&toml, "suggest-test");
prop_assert_eq!(specs.len(), 1, "toml failed to load:\n{}", toml);
prop_assert_eq!(&specs[0].name, &name);
}
#[test]
fn analyze_is_deterministic(
name in arb_unknown_name(),
flags in prop::collection::vec(arb_flag(), 0..4),
) {
let (command, _) = argv_command(&name, &flags, &[]);
prop_assert_eq!(analyze(&command), analyze(&command));
}
}
#[test]
fn config_hash_matches_trust_sha256() {
assert_eq!(
config_hash(b""),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
assert_eq!(
config_hash(b"abc"),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
#[test]
fn pin_hash_is_over_the_merged_file() {
let entry = GeneratedEntry {
name: "mytool".into(),
standalone: vec!["-x".into()],
max_positional: 1,
level: "SafeWrite".into(),
};
let merged = merged_content("", std::slice::from_ref(&entry));
assert_eq!(config_hash(merged.as_bytes()), config_hash(render_toml(std::slice::from_ref(&entry)).as_bytes()));
let with_existing = merged_content("[[command]]\nname = \"other\"\n", std::slice::from_ref(&entry));
assert!(with_existing.starts_with("[[command]]\nname = \"other\"\n"));
assert!(with_existing.contains("name = \"mytool\""));
let specs = load_toml(&with_existing, "suggest-test");
let names: BTreeSet<&str> = specs.iter().map(|s| s.name.as_str()).collect();
assert!(names.contains("other") && names.contains("mytool"), "merged file lost a command: {names:?}");
}
#[test]
fn already_allowed_commands_yield_nothing() {
assert_eq!(analyze("ls -la"), Outcome::AlreadyAllowed);
assert_eq!(analyze("echo hello"), Outcome::AlreadyAllowed);
}
#[test]
fn recognized_but_denied_is_not_overridden() {
match analyze("git frobnicate-xyz-42") {
Outcome::RecognizedButDenied { names } => assert!(names.contains(&"git".to_string())),
other => panic!("expected RecognizedButDenied, got {other:?}"),
}
}
#[test]
fn unknown_command_is_generated() {
match analyze("zzmadeuptool-42 --wat foo") {
Outcome::Generated { entries, .. } => {
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "zzmadeuptool-42");
assert!(entries[0].standalone.contains(&"--wat".to_string()));
assert_eq!(entries[0].max_positional, 1);
}
other => panic!("expected Generated, got {other:?}"),
}
}
#[test]
fn mixed_chain_generates_unknown_and_flags_recognized() {
match analyze("zzmixtool-42 -x && rm -rf /") {
Outcome::Generated { entries, also_recognized } => {
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "zzmixtool-42");
assert!(also_recognized.contains(&"rm".to_string()), "rm not reported: {also_recognized:?}");
}
other => panic!("expected Generated, got {other:?}"),
}
}
#[test]
fn unparseable_command_reports_unparseable() {
assert_eq!(analyze("zztool 'unterminated"), Outcome::Unparseable);
}
#[test]
fn unknown_command_nested_in_substitution_is_found() {
match analyze("echo $(zzhiddentool-42 -a)") {
Outcome::Generated { entries, .. } => {
assert!(entries.iter().any(|e| e.name == "zzhiddentool-42"));
}
other => panic!("expected Generated, got {other:?}"),
}
}
#[test]
fn path_basename_is_used_for_the_name() {
match analyze("/opt/bin/zztool-42 --flag") {
Outcome::Generated { entries, .. } => {
assert!(entries.iter().any(|e| e.name == "zztool-42"), "entries: {entries:?}");
}
other => panic!("expected Generated, got {other:?}"),
}
}
#[test]
fn generated_pin_matches_the_written_file_and_dir() {
let dir = tempfile::tempdir().unwrap();
let entry = GeneratedEntry {
name: "zztool-42".into(),
standalone: vec!["-x".into()],
max_positional: 0,
level: "SafeWrite".into(),
};
let content = merged_content("", std::slice::from_ref(&entry));
let file = dir.path().join(".safe-chains.toml");
std::fs::write(&file, &content).unwrap();
let hash = config_hash(content.as_bytes());
assert_eq!(config_hash(&std::fs::read(&file).unwrap()), hash);
let canonical_dir = std::fs::canonicalize(dir.path()).unwrap();
let pin = pin_block(&canonical_dir.to_string_lossy(), &hash);
assert!(pin.contains(&hash), "pin must carry the file hash");
assert!(pin.contains(&*canonical_dir.to_string_lossy()), "pin must carry the canonical dir");
}
#[test]
fn toml_str_escapes_quotes_and_backslashes() {
assert_eq!(toml_str("a\"b\\c"), "\"a\\\"b\\\\c\"");
assert_eq!(toml_str("plain"), "\"plain\"");
}