allow-policy-legacy 0.1.9

Legacy policy adapters for cargo-allow migrations.
Documentation
use allow_core::{
    AllowConfig, AllowEntry, CargoAllowError, CargoAllowResult, Finding, FindingKind, glob_matches,
    normalize_path,
};
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::process::Command;

pub fn dependency_surface_findings_from_git(
    root: impl AsRef<Path>,
    cfg: &AllowConfig,
) -> CargoAllowResult<Vec<Finding>> {
    let tracked = git_ls_files(root)?;
    Ok(dependency_surface_findings_from_paths(&tracked, cfg))
}

pub fn dependency_surface_findings_from_paths(
    inventory_paths: &[PathBuf],
    cfg: &AllowConfig,
) -> Vec<Finding> {
    let mut paths = BTreeSet::new();
    for entry in &cfg.allow {
        if entry.kind != FindingKind::PolicyException
            || entry.family.as_deref() != Some("dependency_surface")
        {
            continue;
        }
        for path in inventory_paths {
            if dependency_entry_matches_path(entry, path) {
                paths.insert(path.to_path_buf());
            }
        }
    }
    paths.into_iter().map(dependency_surface_finding).collect()
}

fn git_ls_files(root: impl AsRef<Path>) -> CargoAllowResult<Vec<PathBuf>> {
    let output = Command::new("git")
        .args(["ls-files"])
        .current_dir(root.as_ref())
        .output()
        .map_err(|e| CargoAllowError::new(format!("failed to run git ls-files: {e}")))?;
    if !output.status.success() {
        return Err(CargoAllowError::new(format!(
            "git ls-files failed: {}",
            String::from_utf8_lossy(&output.stderr)
        )));
    }
    let text = String::from_utf8(output.stdout)
        .map_err(|e| CargoAllowError::new(format!("git ls-files output was not UTF-8: {e}")))?;
    Ok(text
        .lines()
        .map(str::trim)
        .filter(|line| !line.is_empty())
        .map(PathBuf::from)
        .collect())
}

pub(crate) fn dependency_surface_finding(path: PathBuf) -> Finding {
    let normalized = normalize_path(&path);
    let mut identity = allow_core::StructuralIdentity::new("file", "dependency_surface");
    identity.symbol = Some(normalized.clone());
    identity.target_fingerprint = Some(dependency_surface_family(&path));
    Finding {
        kind: FindingKind::PolicyException,
        family: Some("dependency_surface".to_string()),
        path,
        span: Some(allow_core::Span { line: 1, column: 1 }),
        identity,
        message: format!("tracked dependency surface {normalized}"),
    }
}

fn dependency_surface_family(path: &Path) -> String {
    let normalized = normalize_path(path);
    match normalized.as_str() {
        "Cargo.toml" => "workspace_manifest".to_string(),
        "Cargo.lock" => "workspace_lockfile".to_string(),
        "rust-toolchain.toml" => "toolchain_pin".to_string(),
        "deny.toml" => "policy_config".to_string(),
        text if text.ends_with("/Cargo.toml") => "crate_manifest".to_string(),
        text if text.ends_with("/Cargo.lock") => "lockfile".to_string(),
        text if text.ends_with("/rust-toolchain.toml") => "toolchain_pin".to_string(),
        _ => "dependency_surface".to_string(),
    }
}

fn dependency_entry_matches_path(entry: &AllowEntry, path: &Path) -> bool {
    entry
        .path
        .as_ref()
        .is_some_and(|scope| normalize_path(scope) == normalize_path(path))
        || entry
            .glob
            .as_ref()
            .is_some_and(|glob| glob_matches(glob, path))
        || entry
            .selector
            .glob
            .as_ref()
            .is_some_and(|glob| glob_matches(glob, path))
}

#[cfg(test)]
mod tests {
    use super::*;
    use allow_core::{Lifecycle, Selector, WorkspaceConfig};

    #[test]
    fn dependency_surface_finding_preserves_family_identity_and_message() {
        let finding = dependency_surface_finding(PathBuf::from("crates\\core\\Cargo.toml"));

        assert_eq!(finding.kind, FindingKind::PolicyException);
        assert_eq!(finding.family.as_deref(), Some("dependency_surface"));
        assert_eq!(finding.path, PathBuf::from("crates\\core\\Cargo.toml"));
        assert_eq!(
            finding.span.as_ref().map(|span| (span.line, span.column)),
            Some((1, 1))
        );
        assert_eq!(finding.identity.language, "file");
        assert_eq!(finding.identity.ast_kind, "dependency_surface");
        assert_eq!(
            finding.identity.symbol.as_deref(),
            Some("crates/core/Cargo.toml")
        );
        assert_eq!(
            finding.identity.target_fingerprint.as_deref(),
            Some("crate_manifest")
        );
        assert_eq!(
            finding.message,
            "tracked dependency surface crates/core/Cargo.toml"
        );
    }

    #[test]
    fn dependency_surface_family_classifies_known_paths() {
        assert_eq!(
            dependency_surface_family(Path::new("Cargo.toml")),
            "workspace_manifest"
        );
        assert_eq!(
            dependency_surface_family(Path::new("Cargo.lock")),
            "workspace_lockfile"
        );
        assert_eq!(
            dependency_surface_family(Path::new("rust-toolchain.toml")),
            "toolchain_pin"
        );
        assert_eq!(
            dependency_surface_family(Path::new("deny.toml")),
            "policy_config"
        );
        assert_eq!(
            dependency_surface_family(Path::new("crates/core/Cargo.toml")),
            "crate_manifest"
        );
        assert_eq!(
            dependency_surface_family(Path::new("crates/core/Cargo.lock")),
            "lockfile"
        );
        assert_eq!(
            dependency_surface_family(Path::new("crates/core/rust-toolchain.toml")),
            "toolchain_pin"
        );
        assert_eq!(
            dependency_surface_family(Path::new("docs/dependencies.md")),
            "dependency_surface"
        );
    }

    #[test]
    fn dependency_entry_matches_direct_path_glob_or_selector_glob() {
        let mut path_entry = dependency_entry();
        path_entry.path = Some(PathBuf::from("Cargo.toml"));

        let mut glob_entry = dependency_entry();
        glob_entry.glob = Some("crates/*/Cargo.toml".to_string());

        let mut selector_entry = dependency_entry();
        selector_entry.selector = Selector {
            glob: Some("tools/**/Cargo.toml".to_string()),
            ..Selector::default()
        };

        assert!(dependency_entry_matches_path(
            &path_entry,
            Path::new("Cargo.toml")
        ));
        assert!(!dependency_entry_matches_path(
            &path_entry,
            Path::new("Cargo.lock")
        ));
        assert!(dependency_entry_matches_path(
            &glob_entry,
            Path::new("crates/core/Cargo.toml")
        ));
        assert!(dependency_entry_matches_path(
            &selector_entry,
            Path::new("tools/xtask/Cargo.toml")
        ));
        assert!(!dependency_entry_matches_path(
            &selector_entry,
            Path::new("docs/Cargo.toml")
        ));
    }

    #[test]
    fn dependency_surface_findings_filter_policy_entries_and_sort_paths() {
        let cfg = AllowConfig {
            schema_version: "0.1".to_string(),
            policy: "cargo-allow".to_string(),
            owner: None,
            status: None,
            workspace: WorkspaceConfig::default(),
            requirements: Default::default(),
            allow: vec![
                dependency_entry_with_glob("crates/*/Cargo.toml"),
                non_dependency_entry(),
            ],
        };
        let paths = vec![
            PathBuf::from("README.md"),
            PathBuf::from("crates/zeta/Cargo.toml"),
            PathBuf::from("crates/alpha/Cargo.toml"),
        ];

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

        assert_eq!(
            findings
                .iter()
                .map(|finding| normalize_path(&finding.path))
                .collect::<Vec<_>>(),
            vec!["crates/alpha/Cargo.toml", "crates/zeta/Cargo.toml"]
        );
    }

    fn dependency_entry() -> AllowEntry {
        AllowEntry {
            id: "dependency-surface".to_string(),
            kind: FindingKind::PolicyException,
            family: Some("dependency_surface".to_string()),
            path: None,
            glob: None,
            owner: "release".to_string(),
            classification: "dependency_surface".to_string(),
            reason: "Dependency surface is governed.".to_string(),
            evidence: vec!["doc:docs/dependencies.md".to_string()],
            links: Vec::new(),
            occurrence_limit: None,
            lifecycle: Lifecycle::empty(),
            selector: Selector::default(),
            last_seen: None,
        }
    }

    fn dependency_entry_with_glob(glob: &str) -> AllowEntry {
        let mut entry = dependency_entry();
        entry.glob = Some(glob.to_string());
        entry
    }

    fn non_dependency_entry() -> AllowEntry {
        let mut entry = dependency_entry();
        entry.id = "other-policy".to_string();
        entry.family = Some("github_workflow".to_string());
        entry.glob = Some(".github/workflows/*.yml".to_string());
        entry
    }
}