allow-policy-legacy 0.1.9

Legacy policy adapters for cargo-allow migrations.
Documentation
use super::*;
use crate::findings::{
    executable_finding, executable_findings_from_git_stage, executable_findings_from_paths,
    generated_finding, generated_findings_from_gitattributes_text,
};
use crate::test_support::*;
use allow_core::FindingKind;
use std::path::{Path, PathBuf};

#[test]
fn migrates_generated_allowlist_to_canonical_policy() {
    let policy = generated_policy_fixture_path();
    let cfg = load_legacy_or_canonical(&policy)
        .unwrap_or_else(|err| std::panic::panic_any(format!("generated policy migrates: {err}")));

    assert_eq!(cfg.policy, "cargo-allow");
    assert_eq!(cfg.allow.len(), 1);
    let entry = cfg
        .allow
        .first()
        .unwrap_or_else(|| std::panic::panic_any("expected generated allow entry"));
    assert_eq!(entry.kind, FindingKind::GeneratedCode);
    assert_eq!(entry.family.as_deref(), Some("generated_code"));
    assert_eq!(
        entry.path.as_deref(),
        Some(Path::new("policy/no-panic-baseline.toml"))
    );
    assert_eq!(entry.lifecycle.expires.as_deref(), Some("never"));
    assert_eq!(entry.lifecycle.review_after.as_deref(), Some("2026-05-10"));
    assert!(
        entry
            .evidence
            .iter()
            .any(|item| item == "legacy-policy:generated-no-panic-baseline")
    );
    assert!(entry.evidence.iter().any(|item| item.starts_with("cargo:")));
}

#[test]
fn generated_findings_read_linguist_generated_paths() {
    let root = generated_fixture_root();

    let findings = generated_findings_from_gitattributes(&root)
        .unwrap_or_else(|err| std::panic::panic_any(format!("generated findings load: {err}")));

    assert_eq!(findings.len(), 1);
    let finding = findings
        .first()
        .unwrap_or_else(|| std::panic::panic_any("expected generated finding"));
    assert_eq!(finding.kind, FindingKind::GeneratedCode);
    assert_eq!(finding.path, PathBuf::from("policy/no-panic-baseline.toml"));
}

#[test]
fn generated_findings_can_read_gitattributes_text() {
    let findings = generated_findings_from_gitattributes_text(
        "generated/schema.json linguist-generated=true\nREADME.md linguist-documentation=true\n",
    );

    assert_eq!(findings.len(), 1);
    assert_eq!(findings[0].kind, FindingKind::GeneratedCode);
    assert_eq!(findings[0].path, PathBuf::from("generated/schema.json"));
}

#[test]
fn generated_compat_preserves_missing_and_stale_drift() {
    let policy = generated_policy_fixture_path();
    let cfg = load_generated_compat_config(&policy).unwrap_or_else(|err| {
        std::panic::panic_any(format!("generated compat config loads: {err}"))
    });

    let matched = allow_match::evaluate(
        &cfg,
        &[generated_finding(PathBuf::from(
            "policy/no-panic-baseline.toml",
        ))],
        allow_match::CheckMode::NoNew,
    );
    assert!(matched.iter().any(|outcome| {
        outcome.finding_index.is_some()
            && outcome.allow_id.as_deref() == Some("generated-no-panic-baseline")
            && matches!(
                outcome.status,
                allow_core::MatchStatus::Matched | allow_core::MatchStatus::ReviewDue
            )
    }));

    let missing_allow = allow_match::evaluate(
        &cfg,
        &[generated_finding(PathBuf::from(
            "policy/extra-baseline.toml",
        ))],
        allow_match::CheckMode::NoNew,
    );
    assert!(
        missing_allow
            .iter()
            .any(|outcome| outcome.status == allow_core::MatchStatus::New)
    );

    let stale_allow = allow_match::evaluate(&cfg, &[], allow_match::CheckMode::Audit);
    assert!(stale_allow.iter().any(|outcome| {
        outcome.finding_index.is_none()
            && matches!(
                outcome.status,
                allow_core::MatchStatus::Stale | allow_core::MatchStatus::ReviewDue
            )
    }));
}

#[test]
fn generated_migration_preserves_legacy_evidence_when_present() {
    let path = fixture_dir().join("generated-allowlist.toml");
    std::fs::write(
        &path,
        r#"schema_version = 1
policy = "generated-allowlist"
owner = "EffortlessMetrics"
status = "advisory"

[[allow]]
id = "generated-schema"
path = "docs/generated/schema.json"
generator = "cargo xtask schema"
regenerate_command = "cargo xtask schema"
owner = "policy"
reason = "Generated schema fixture."
evidence = ["doc:docs/schemas/README.md", "issue:#123"]
created = "2026-05-10"
expires = "permanent"
"#,
    )
    .unwrap_or_else(|err| std::panic::panic_any(format!("fixture write: {err}")));

    let cfg = load_legacy_or_canonical(&path).unwrap_or_else(|err| {
        std::panic::panic_any(format!("generated policy with evidence migrates: {err}"))
    });

    let entry = cfg
        .allow
        .first()
        .unwrap_or_else(|| std::panic::panic_any("expected generated allow entry"));
    assert!(
        entry
            .evidence
            .iter()
            .any(|item| item == "doc:docs/schemas/README.md")
    );
    assert!(entry.evidence.iter().any(|item| item == "issue:#123"));
    assert!(
        entry
            .evidence
            .iter()
            .any(|item| item == "legacy-policy:generated-schema")
    );
    assert!(
        entry
            .evidence
            .iter()
            .any(|item| item == "generator:cargo xtask schema")
    );
    assert!(
        entry
            .evidence
            .iter()
            .any(|item| item == "cargo:cargo xtask schema")
    );
}

#[test]
fn migrates_executable_allowlist_to_policy_exception_entries() {
    let policy = executable_policy_fixture_path();
    let cfg = load_legacy_or_canonical(&policy)
        .unwrap_or_else(|err| std::panic::panic_any(format!("executable policy migrates: {err}")));

    assert_eq!(cfg.policy, "cargo-allow");
    assert_eq!(cfg.allow.len(), 1);
    let entry = cfg
        .allow
        .first()
        .unwrap_or_else(|| std::panic::panic_any("expected executable allow entry"));
    assert_eq!(entry.kind, FindingKind::PolicyException);
    assert_eq!(entry.family.as_deref(), Some("executable_file"));
    assert_eq!(entry.classification, "executable_file");
    assert_eq!(
        entry.path.as_deref(),
        Some(Path::new("scripts/package-proof.sh"))
    );
    assert_eq!(entry.lifecycle.expires.as_deref(), Some("never"));
    assert_eq!(entry.lifecycle.review_after.as_deref(), Some("2026-05-09"));
    assert_eq!(
        entry.evidence,
        vec![
            "legacy-policy:exec-package-proof".to_string(),
            "interpreter:bash".to_string(),
        ]
    );
    assert_eq!(
        entry.selector.target_fingerprint.as_deref(),
        Some("git-mode:100755")
    );
}

#[test]
fn executable_migration_accepts_covered_by_as_legacy_evidence() {
    let path = fixture_dir().join("executable-allowlist.toml");
    std::fs::write(
        &path,
        r#"schema_version = 1
policy = "executable-allowlist"
owner = "EffortlessMetrics"
status = "advisory"

[[allow]]
id = "exec-release-helper"
path = "scripts/release.sh"
interpreter = "bash"
owner = "release"
reason = "Release helper fixture."
covered_by = "doc:docs/release/README.md"
created = "2026-05-09"
expires = "permanent"
"#,
    )
    .unwrap_or_else(|err| std::panic::panic_any(format!("fixture write: {err}")));

    let cfg = load_legacy_or_canonical(&path).unwrap_or_else(|err| {
        std::panic::panic_any(format!("executable policy with covered_by migrates: {err}"))
    });

    let entry = cfg
        .allow
        .first()
        .unwrap_or_else(|| std::panic::panic_any("expected executable allow entry"));
    assert_eq!(
        entry.evidence,
        vec![
            "doc:docs/release/README.md".to_string(),
            "legacy-policy:exec-release-helper".to_string(),
            "interpreter:bash".to_string(),
        ]
    );
}

#[test]
fn executable_findings_read_git_stage_executable_paths() {
    let stage = "\
100644 abc 0\tREADME.md\n\
100755 def 0\tscripts/package-proof.sh\n\
120000 ghi 0\tscripts/link.sh\n";

    let findings = executable_findings_from_git_stage(stage);

    assert_eq!(findings.len(), 1);
    let finding = findings
        .first()
        .unwrap_or_else(|| std::panic::panic_any("expected executable finding"));
    assert_eq!(finding.kind, FindingKind::PolicyException);
    assert_eq!(finding.family.as_deref(), Some("executable_file"));
    assert_eq!(finding.path, PathBuf::from("scripts/package-proof.sh"));
    assert_eq!(finding.identity.ast_kind, "git_executable_file");
    assert_eq!(
        finding.identity.target_fingerprint.as_deref(),
        Some("git-mode:100755")
    );
}

#[test]
fn executable_findings_can_use_source_tree_paths() {
    let findings = executable_findings_from_paths(&[PathBuf::from("scripts/package-proof.sh")]);

    assert_eq!(findings.len(), 1);
    assert_eq!(findings[0].kind, FindingKind::PolicyException);
    assert_eq!(findings[0].family.as_deref(), Some("executable_file"));
    assert_eq!(findings[0].path, PathBuf::from("scripts/package-proof.sh"));
    assert_eq!(findings[0].identity.ast_kind, "git_executable_file");
    assert_eq!(
        findings[0].identity.target_fingerprint.as_deref(),
        Some("git-mode:100755")
    );
}

#[test]
fn executable_compat_preserves_missing_and_stale_drift() {
    let policy = executable_policy_fixture_path();
    let cfg = load_executable_compat_config(&policy).unwrap_or_else(|err| {
        std::panic::panic_any(format!("executable compat config loads: {err}"))
    });

    let matched = allow_match::evaluate(
        &cfg,
        &[executable_finding(PathBuf::from(
            "scripts/package-proof.sh",
        ))],
        allow_match::CheckMode::NoNew,
    );
    assert!(matched.iter().any(|outcome| {
        outcome.finding_index.is_some()
            && outcome.allow_id.as_deref() == Some("exec-package-proof")
            && matches!(
                outcome.status,
                allow_core::MatchStatus::Matched | allow_core::MatchStatus::ReviewDue
            )
    }));

    let missing_allow = allow_match::evaluate(
        &cfg,
        &[executable_finding(PathBuf::from("scripts/new-tool.sh"))],
        allow_match::CheckMode::NoNew,
    );
    assert!(
        missing_allow
            .iter()
            .any(|outcome| outcome.status == allow_core::MatchStatus::New)
    );

    let stale_allow = allow_match::evaluate(&cfg, &[], allow_match::CheckMode::Audit);
    assert!(stale_allow.iter().any(|outcome| {
        outcome.finding_index.is_none()
            && matches!(
                outcome.status,
                allow_core::MatchStatus::Stale | allow_core::MatchStatus::ReviewDue
            )
    }));
}