devist 0.17.2

Project bootstrap CLI for AI-assisted development. Spin up new projects from templates, manage backends, and keep your codebase comprehensible.
// Conservative blocklist: paths matching these patterns are dropped at
// the daemon (never recorded in SQLite, never pushed to Supabase) AND
// re-checked in advice.rs before any file content is read.
//
// We err on the side of false positives. Adding a knob to whitelist
// specific files is intentionally not provided yet — secrets-by-default
// is the safer posture.

use std::path::Path;

/// Exact filename matches (case-insensitive).
const SECRET_FILENAMES: &[&str] = &[
    ".env",
    ".envrc",
    ".netrc",
    ".pgpass",
    ".npmrc",
    ".yarnrc",
    "id_rsa",
    "id_dsa",
    "id_ecdsa",
    "id_ed25519",
    "credentials",
    "credentials.json",
    "service-account.json",
    "service_account.json",
    "secrets.json",
    "secret.json",
    "private.key",
    "auth.json",
    ".htpasswd",
    "wallet.json",
];

/// Filename prefix matches (case-insensitive). Catches `.env.local`,
/// `.env.production`, `secret-*`, `secrets.*` etc.
const SECRET_FILENAME_PREFIXES: &[&str] = &[".env.", "secret.", "secrets.", "credential"];

/// Extension blocklist (case-insensitive, no leading dot).
const SECRET_EXTENSIONS: &[&str] = &[
    "pem", "key", "crt", "cer", "der", "p12", "pfx", "p7b", "p7c", "kdbx", "kdb", "gpg", "asc",
    "pgp", "jks", "keystore", "ovpn", "ppk",
];

/// Any path component matching these (e.g. `~/.ssh/...`,
/// `<project>/secrets/api.json`) is treated as secret.
const SECRET_PATH_SEGMENTS: &[&str] = &[
    ".aws", ".ssh", ".gnupg", ".gcloud", ".azure", ".kube", "secrets", "private",
];

pub fn is_secret_path(path: &Path) -> bool {
    let s = path.to_string_lossy();

    for seg in SECRET_PATH_SEGMENTS {
        let mid = format!("/{}/", seg);
        let end = format!("/{}", seg);
        let win_mid = format!("\\{}\\", seg);
        let win_end = format!("\\{}", seg);
        if s.contains(&mid) || s.ends_with(&end) || s.contains(&win_mid) || s.ends_with(&win_end) {
            return true;
        }
    }

    let name = match path.file_name().and_then(|n| n.to_str()) {
        Some(n) => n.to_lowercase(),
        None => return false,
    };

    if SECRET_FILENAMES.contains(&name.as_str()) {
        return true;
    }

    for pre in SECRET_FILENAME_PREFIXES {
        if name.starts_with(pre) {
            return true;
        }
    }

    if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
        let ext_lower = ext.to_lowercase();
        if SECRET_EXTENSIONS.contains(&ext_lower.as_str()) {
            return true;
        }
    }

    false
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::PathBuf;

    #[test]
    fn detects_dotenv_family() {
        assert!(is_secret_path(&PathBuf::from("project/.env")));
        assert!(is_secret_path(&PathBuf::from("project/.env.local")));
        assert!(is_secret_path(&PathBuf::from("project/.env.production")));
        assert!(is_secret_path(&PathBuf::from("project/.envrc")));
    }

    #[test]
    fn detects_credential_files() {
        assert!(is_secret_path(&PathBuf::from("project/credentials.json")));
        assert!(is_secret_path(&PathBuf::from(
            "project/service-account.json"
        )));
        assert!(is_secret_path(&PathBuf::from("project/secrets.json")));
        assert!(is_secret_path(&PathBuf::from(
            "project/credential-store.txt"
        )));
    }

    #[test]
    fn detects_key_extensions() {
        assert!(is_secret_path(&PathBuf::from("project/cert.pem")));
        assert!(is_secret_path(&PathBuf::from("project/api.key")));
        assert!(is_secret_path(&PathBuf::from("project/keystore.jks")));
        assert!(is_secret_path(&PathBuf::from("project/wallet.kdbx")));
    }

    #[test]
    fn detects_path_segments() {
        assert!(is_secret_path(&PathBuf::from("/Users/me/.ssh/id_rsa")));
        assert!(is_secret_path(&PathBuf::from("/Users/me/.aws/credentials")));
        assert!(is_secret_path(&PathBuf::from(
            "/home/me/proj/secrets/api.toml"
        )));
        assert!(is_secret_path(&PathBuf::from("/home/me/proj/private/key")));
    }

    #[test]
    fn case_insensitive() {
        assert!(is_secret_path(&PathBuf::from("PROJECT/.ENV.LOCAL")));
        assert!(is_secret_path(&PathBuf::from("project/CERT.PEM")));
    }

    #[test]
    fn allows_normal_files() {
        assert!(!is_secret_path(&PathBuf::from("src/main.rs")));
        assert!(!is_secret_path(&PathBuf::from("README.md")));
        assert!(!is_secret_path(&PathBuf::from("package.json")));
        assert!(!is_secret_path(&PathBuf::from("Cargo.toml")));
        assert!(!is_secret_path(&PathBuf::from(
            "src/components/SecretReveal.tsx"
        ))); // "secret" inside a name is fine
    }

    #[test]
    fn windows_separators() {
        assert!(is_secret_path(&PathBuf::from(r"C:\Users\me\.ssh\id_rsa")));
    }
}