fp-dotfiles-manager 0.2.5

Minimal, zero-dependency Chezmoi-based dotfiles manager
use crate::commands::sync;
use crate::config::Config;
use crate::exec::{Cmd, ScanPolicy, SecretsArg};
use crate::lock;
use crate::logger::*;
use crate::paths::*;
use std::io::{self, Write};

/// How many paths to pass to a single `chezmoi` invocation, to stay well clear
/// of `ARG_MAX` on large managed sets.
const SCAN_BATCH: usize = 200;

/// Lists every **plaintext** file chezmoi currently manages, as absolute paths.
///
/// `--exclude=encrypted` matters: chezmoi's `--secrets` check keys off the
/// *prospective* add, so an already-encrypted file would be reported as a leak
/// on every run even though nothing is at risk.
fn managed_plaintext_files(chezmoi: &Cmd) -> io::Result<Vec<String>> {
    let outcome = chezmoi.run(
        "managed",
        &[
            "--include=files,symlinks",
            "--exclude=encrypted",
            "--path-style",
            "absolute",
            "-0",
        ],
        SecretsArg::Unsupported,
    )?;
    outcome.report();

    let mut files = Vec::new();
    for chunk in outcome.stdout.as_bytes().split(|&b| b == 0) {
        if chunk.is_empty() {
            continue;
        }
        let path = String::from_utf8_lossy(chunk).into_owned();
        // The scanner only inspects regular files, and `add` fails outright on
        // a path that has since disappeared.
        if std::path::Path::new(&path).is_file() {
            files.push(path);
        }
    }
    Ok(files)
}

/// Scans every managed file for plaintext secrets without modifying anything.
///
/// `chezmoi add --dry-run` still runs the detector but writes nothing to the
/// source state, so this is safe to run at any time.
fn scan_managed(chezmoi: &Cmd, files: &[String]) -> io::Result<Vec<String>> {
    let mut flagged: Vec<String> = Vec::new();

    for batch in files.chunks(SCAN_BATCH) {
        let mut argv: Vec<&str> = vec!["--dry-run", "--force"];
        argv.extend(batch.iter().map(|p| p.as_str()));
        let outcome = chezmoi.run(
            "add",
            &argv,
            chezmoi.secrets_arg("add", ScanPolicy::Enforce),
        )?;
        outcome.report();
        for path in outcome.secret_paths(&[]) {
            if !flagged.contains(&path) {
                flagged.push(path);
            }
        }
    }

    Ok(flagged)
}

/// Appends paths to the encrypted tracking list, skipping ones already present.
fn promote_to_encrypted(tracked_enc_file: &str, home: &str, paths: &[String]) -> io::Result<usize> {
    if let Some(parent) = std::path::Path::new(tracked_enc_file).parent() {
        std::fs::create_dir_all(parent)?;
    }

    let mut existing: Vec<String> = Vec::new();
    if let Ok(file) = std::fs::File::open(tracked_enc_file) {
        use std::io::BufRead;
        for line in io::BufReader::new(file).lines().map_while(Result::ok) {
            let trimmed = line.trim().to_string();
            if !trimmed.is_empty() && !trimmed.starts_with('#') {
                existing.push(trimmed);
            }
        }
    }

    let home_path = canonical_path(home);
    let mut file = std::fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(tracked_enc_file)?;

    let mut added = 0usize;
    for path in paths {
        let canonical = canonical_path(path);
        if !canonical.starts_with(&home_path) {
            log_warn(&("Skipping path outside home: ".to_owned() + path));
            continue;
        }
        let offset = if home_path.ends_with('/') {
            home_path.len()
        } else {
            home_path.len() + 1
        };
        let rel_path = if canonical.len() > offset {
            canonical[offset..].to_string()
        } else {
            continue;
        };
        if existing.contains(&rel_path) {
            log_warn(&(rel_path.clone() + " is already tracked as encrypted."));
            continue;
        }
        writeln!(file, "{}", rel_path)?;
        existing.push(rel_path.clone());
        added += 1;
    }
    Ok(added)
}

/// `fp-dotfiles-manager audit-secrets [--fix]`
///
/// Reports plaintext secrets in already-managed files. With `--fix`, the
/// offending paths are moved into the encrypted tracking list and re-added with
/// encryption.
pub fn run_audit_secrets(config: Config, fix: bool) -> io::Result<()> {
    log_section("Auditing managed dotfiles for leaked secrets...");

    let policy = config.scan_policy();
    if policy == ScanPolicy::Off {
        log_warn("Secret scanning is disabled (secret_scan: off). Nothing was scanned.");
        return Ok(());
    }
    if !crate::exec::supports_secret_scanning() {
        crate::exec::warn_if_unsupported();
        return Ok(());
    }

    // The audit mutates the source state with --fix, so it takes the same lock
    // as sync rather than racing it.
    let runtime_dir =
        std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| concat_paths(&config.home, ".cache"));
    let Some(_guard) =
        lock::acquire_or_report(&concat_paths(&runtime_dir, "dotfiles-sync.lock.dir"))
    else {
        return Ok(());
    };

    sync::resolve_config_drift()?;

    // Route through the autoCommit/autoPush-disabled config, exactly as sync
    // does. Without it, the re-add below would make chezmoi commit and push on
    // its own, bypassing this crate's single commit-and-push step.
    let temp_config = sync::generate_chezmoi_run_config(&config.home)?;
    let chezmoi = Cmd::new().with_config(&temp_config);
    let result = audit_with(&chezmoi, &config, fix);
    sync::remove_chezmoi_run_config(&temp_config);
    result
}

fn audit_with(chezmoi: &Cmd, config: &Config, fix: bool) -> io::Result<()> {
    let policy = config.scan_policy();
    let files = managed_plaintext_files(chezmoi)?;
    if files.is_empty() {
        log_success("No plaintext managed files to audit.");
        return Ok(());
    }

    log_step(&format!("Scanning {} plaintext file(s)...", files.len()));
    let flagged = scan_managed(chezmoi, &files)?;

    if flagged.is_empty() {
        log_success("No potential secrets found in managed files.");
        return Ok(());
    }

    // The findings themselves (path, line, and what was detected) were already
    // printed by the scan above, so only the roll-up is added here.
    log_warn(&format!(
        "{} file(s) contain potential secrets and are stored in plaintext.",
        flagged.len()
    ));

    if !fix {
        log_step("Re-run with --fix to move these into tracked_encrypted and encrypt them.");
        return Ok(());
    }

    log_section("Promoting flagged files to encrypted tracking...");
    let added = promote_to_encrypted(&config.tracked_enc_file, &config.home, &flagged)?;
    if added == 0 {
        log_warn("No new entries were added to the encrypted tracking list.");
        return Ok(());
    }
    log_success(
        &("Added ".to_owned() + &added.to_string() + " path(s) to the encrypted tracking list."),
    );

    // Re-add the promoted files so the plaintext source entries are replaced by
    // encrypted ones. Anything still tracked plainly is reported, not removed.
    let mut argv: Vec<&str> = vec!["--encrypt", "--force"];
    argv.extend(flagged.iter().map(|p| p.as_str()));
    let outcome = chezmoi.run("add", &argv, chezmoi.secrets_arg("add", policy))?;
    outcome.report();

    if outcome.success {
        log_success("Re-encrypted the promoted files.");
        // autoCommit/autoPush are off, so nothing has been committed yet.
        log_step("Run `fp-dotfiles-manager sync` to commit and push the change.");
    } else {
        log_error("Failed to re-encrypt the promoted files.");
    }

    Ok(())
}