allow-policy-legacy 0.1.9

Legacy policy adapters for cargo-allow migrations.
Documentation
use super::*;
use crate::findings::{
    dependency_surface_finding, dependency_surface_findings_from_paths, workflow_action_finding,
    workflow_file_finding, workflow_findings_from_sources,
};
use crate::test_support::*;
use allow_core::FindingKind;
use std::path::{Path, PathBuf};

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

    assert_eq!(cfg.policy, "cargo-allow");
    assert_eq!(cfg.allow.len(), 3);
    assert!(cfg.allow.iter().any(|entry| {
        entry.kind == FindingKind::PolicyException
            && entry.family.as_deref() == Some("github_workflow")
            && entry.path.as_deref() == Some(Path::new(".github/workflows/ci.yml"))
    }));
    let action = cfg
        .allow
        .iter()
        .find(|entry| {
            entry.family.as_deref() == Some("workflow_external_action")
                && entry
                    .selector
                    .target_fingerprint
                    .as_deref()
                    .is_some_and(|target| target == "action:actions/checkout@v6.0.2")
        })
        .unwrap_or_else(|| std::panic::panic_any("expected checkout action entry"));
    assert_eq!(action.classification, "workflow_external_action");
    assert_eq!(action.lifecycle.expires.as_deref(), Some("never"));
    assert_eq!(action.lifecycle.review_after.as_deref(), Some("2026-05-09"));
    assert!(
        action
            .evidence
            .iter()
            .any(|item| item == "legacy-policy:workflow:.github/workflows/ci.yml")
    );
}

#[test]
fn workflow_findings_read_workflow_files_and_uses_lines() {
    let root = workflow_fixture_root();

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

    assert!(findings.iter().any(|finding| {
        finding.family.as_deref() == Some("github_workflow")
            && finding.path == Path::new(".github/workflows/ci.yml")
    }));
    assert!(findings.iter().any(|finding| {
        finding.family.as_deref() == Some("workflow_external_action")
            && finding.identity.target_fingerprint.as_deref()
                == Some("action:actions/checkout@v6.0.2")
    }));
    assert!(!findings.iter().any(|finding| {
        finding.identity.target_fingerprint.as_deref() == Some("action:ignored/comment@v1")
    }));
}

#[test]
fn workflow_findings_can_use_source_tree_text() {
    let findings = workflow_findings_from_sources(vec![(
        PathBuf::from(".github/workflows/ci.yml"),
        "steps:\n  - uses: actions/checkout@v4\n  - uses: dtolnay/rust-toolchain@stable\n"
            .to_string(),
    )]);

    assert!(findings.iter().any(|finding| {
        finding.family.as_deref() == Some("github_workflow")
            && finding.path == Path::new(".github/workflows/ci.yml")
    }));
    assert!(findings.iter().any(|finding| {
        finding.family.as_deref() == Some("workflow_external_action")
            && finding.identity.target_fingerprint.as_deref() == Some("action:actions/checkout@v4")
    }));
    assert!(findings.iter().any(|finding| {
        finding.family.as_deref() == Some("workflow_external_action")
            && finding.identity.target_fingerprint.as_deref()
                == Some("action:dtolnay/rust-toolchain@stable")
    }));
}

#[test]
fn workflow_compat_preserves_missing_and_stale_drift() {
    let policy = workflow_policy_fixture_path();
    let cfg = load_workflow_compat_config(&policy).unwrap_or_else(|err| {
        std::panic::panic_any(format!("workflow compat config loads: {err}"))
    });

    let matched = allow_match::evaluate(
        &cfg,
        &[
            workflow_file_finding(PathBuf::from(".github/workflows/ci.yml")),
            workflow_action_finding(
                PathBuf::from(".github/workflows/ci.yml"),
                "actions/checkout@v6.0.2".to_string(),
            ),
            workflow_action_finding(
                PathBuf::from(".github/workflows/ci.yml"),
                "Swatinem/rust-cache@v2".to_string(),
            ),
        ],
        allow_match::CheckMode::NoNew,
    );
    assert_eq!(
        matched
            .iter()
            .filter(|outcome| {
                outcome.finding_index.is_some()
                    && matches!(
                        outcome.status,
                        allow_core::MatchStatus::Matched | allow_core::MatchStatus::ReviewDue
                    )
            })
            .count(),
        3
    );

    let missing_allow = allow_match::evaluate(
        &cfg,
        &[
            workflow_file_finding(PathBuf::from(".github/workflows/ci.yml")),
            workflow_action_finding(
                PathBuf::from(".github/workflows/ci.yml"),
                "actions/setup-node@v5".to_string(),
            ),
        ],
        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 workflow_migration_preserves_legacy_evidence_when_present() {
    let path = fixture_dir().join("workflow-allowlist.toml");
    std::fs::write(
        &path,
        r#"schema_version = 1
policy = "workflow-allowlist"
owner = "EffortlessMetrics"
status = "advisory"

[[entry]]
path = ".github/workflows/release.yml"
owner = "release/ci"
reason = "Release workflow fixture."
permissions = ["contents:read"]
secrets_used = []
external_actions = ["actions/checkout@v4"]
evidence = ["doc:docs/ci.md", "issue:#123"]
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!("workflow policy with evidence migrates: {err}"))
    });

    let workflow = cfg
        .allow
        .iter()
        .find(|entry| entry.family.as_deref() == Some("github_workflow"))
        .unwrap_or_else(|| std::panic::panic_any("expected workflow file entry"));
    assert!(
        workflow
            .evidence
            .iter()
            .any(|item| item == "doc:docs/ci.md")
    );
    assert!(workflow.evidence.iter().any(|item| item == "issue:#123"));
    assert!(
        workflow
            .evidence
            .iter()
            .any(|item| item == "legacy-policy:workflow:.github/workflows/release.yml")
    );
    assert!(
        workflow
            .evidence
            .iter()
            .any(|item| item == "permission:contents:read")
    );

    let action = cfg
        .allow
        .iter()
        .find(|entry| entry.family.as_deref() == Some("workflow_external_action"))
        .unwrap_or_else(|| std::panic::panic_any("expected workflow action entry"));
    assert!(action.evidence.iter().any(|item| item == "doc:docs/ci.md"));
    assert!(
        action
            .evidence
            .iter()
            .any(|item| item == "legacy-policy:workflow:.github/workflows/release.yml")
    );
    assert!(
        action
            .evidence
            .iter()
            .any(|item| item == "external_action:actions/checkout@v4")
    );
}

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

    assert_eq!(cfg.policy, "cargo-allow");
    assert_eq!(cfg.allow.len(), 2);
    let workspace = cfg
        .allow
        .iter()
        .find(|entry| entry.id == "dep-workspace-cargo-toml")
        .unwrap_or_else(|| std::panic::panic_any("expected workspace manifest entry"));
    assert_eq!(workspace.kind, FindingKind::PolicyException);
    assert_eq!(workspace.family.as_deref(), Some("dependency_surface"));
    assert_eq!(workspace.classification, "workspace_manifest");
    assert_eq!(workspace.path.as_deref(), Some(Path::new("Cargo.toml")));
    assert_eq!(workspace.lifecycle.expires.as_deref(), Some("never"));
    assert_eq!(
        workspace.lifecycle.review_after.as_deref(),
        Some("2026-05-09")
    );
    assert!(
        workspace
            .evidence
            .iter()
            .any(|item| item == "legacy-policy:dep-workspace-cargo-toml")
    );
    assert!(
        workspace
            .evidence
            .iter()
            .any(|item| item == "dep_count_at_baseline:22")
    );

    let crates = cfg
        .allow
        .iter()
        .find(|entry| entry.id == "dep-crate-cargo-toml")
        .unwrap_or_else(|| std::panic::panic_any("expected crate glob entry"));
    assert_eq!(crates.glob.as_deref(), Some("crates/*/Cargo.toml"));
    assert!(crates.reason.contains("Scope note:"));
}

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

[[allow]]
id = "dep-release-manifest"
path = "Cargo.toml"
surface = "workspace_manifest"
owner = "release"
reason = "Workspace dependency block fixture."
dep_count_at_baseline = 22
covered_by = "doc:docs/dependencies.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!("dependency policy with covered_by migrates: {err}"))
    });

    let entry = cfg
        .allow
        .first()
        .unwrap_or_else(|| std::panic::panic_any("expected dependency surface entry"));
    assert_eq!(
        entry.evidence,
        vec![
            "doc:docs/dependencies.md".to_string(),
            "legacy-policy:dep-release-manifest".to_string(),
            "surface:workspace_manifest".to_string(),
            "dep_count_at_baseline:22".to_string(),
        ]
    );
}

#[test]
fn dependency_surface_compat_preserves_matched_new_and_stale_drift() {
    let policy = dependency_policy_fixture_path();
    let cfg = load_dependency_surface_compat_config(&policy).unwrap_or_else(|err| {
        std::panic::panic_any(format!("dependency compat config loads: {err}"))
    });

    let matched = allow_match::evaluate(
        &cfg,
        &[
            dependency_surface_finding(PathBuf::from("Cargo.toml")),
            dependency_surface_finding(PathBuf::from("crates/core/Cargo.toml")),
        ],
        allow_match::CheckMode::NoNew,
    );
    assert_eq!(
        matched
            .iter()
            .filter(|outcome| {
                outcome.finding_index.is_some()
                    && matches!(
                        outcome.status,
                        allow_core::MatchStatus::Matched | allow_core::MatchStatus::ReviewDue
                    )
            })
            .count(),
        2
    );

    let missing_allow = allow_match::evaluate(
        &cfg,
        &[dependency_surface_finding(PathBuf::from(
            "xtask/Cargo.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 dependency_surface_findings_can_use_source_tree_inventory_paths() {
    let policy = dependency_policy_fixture_path();
    let cfg = load_dependency_surface_compat_config(&policy).unwrap_or_else(|err| {
        std::panic::panic_any(format!("dependency compat config loads: {err}"))
    });
    let paths = vec![
        PathBuf::from("Cargo.toml"),
        PathBuf::from("crates/core/Cargo.toml"),
        PathBuf::from("policy/dependency-surface-allowlist.toml"),
    ];

    let findings = dependency_surface_findings_from_paths(&paths, &cfg);

    assert_eq!(findings.len(), 2);
    assert!(
        findings
            .iter()
            .any(|finding| finding.path == Path::new("Cargo.toml"))
    );
    assert!(
        findings
            .iter()
            .any(|finding| finding.path == Path::new("crates/core/Cargo.toml"))
    );
}