git-simple-encrypt 3.0.2

Encrypt/decrypt files in your git repo using only one password
Documentation
use std::path::{Path, PathBuf};

use config_file2::LoadConfigFile;
use log::{info, warn};
use parking_lot::Mutex;
use rayon::prelude::*;

use crate::{
    config::{CONFIG_FILE_NAME, Config},
    error::{Error, Result},
    utils::{Progress, is_file_encrypted, prompt_password, resolve_target_files, style::Colorize},
};

pub const GIT_CONFIG_PREFIX: &str =
    const_str::replace!(concat!(env!("CARGO_CRATE_NAME"), "."), "_", "-");

/// The pre-commit hook content that runs `git-se check --staged` before every
/// commit, so only files part of the current commit are checked.
const PRE_COMMIT_HOOK: &[u8] = br#"#!/bin/sh
# Auto-generated by git-se install
git-se check --staged
if [ $? -ne 0 ]; then
    echo "Please run 'git-se e' to encrypt them before committing."
    exit 1
fi
"#;

#[derive(Debug, Clone, Default)]
pub struct Repo {
    /// The absolute path of the opened repo.
    pub path: PathBuf,
    pub conf: Config,
}

impl Repo {
    /// Open a repo. The `path` argument must be an absolute path.
    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
        debug_assert!(path.as_ref().is_absolute(), "given path must be absolute");
        let mut repo_path = path.as_ref().to_path_buf();
        if !repo_path.exists() {
            return Err(Error::RepoNotFound(repo_path));
        }
        if !repo_path.is_dir() {
            return Err(Error::NotADirectory(repo_path));
        }
        if repo_path
            .file_name()
            .ok_or_else(|| Error::Other("Filename not found".to_string()))?
            == ".git"
        {
            repo_path.pop();
        }
        info!("Open repo: {}", repo_path.display());
        let config_file_path = repo_path.join(CONFIG_FILE_NAME);
        if !config_file_path.exists() {
            warn!(
                "Config file not found: `{}`, using default config instead...",
                config_file_path.display()
            );
        }
        let conf = Config::load_or_default(&config_file_path)
            .map_err(|e| Error::Config(e.to_string()))?
            .with_repo_path(&repo_path);
        Ok(Self {
            path: repo_path,
            conf,
        })
    }

    #[must_use]
    pub fn path(&self) -> &Path {
        &self.path
    }

    pub fn to_absolute_path(&self, path: impl AsRef<Path>) -> PathBuf {
        self.path.join(path.as_ref())
    }

    /// Read the master key from git config.
    ///
    /// Returns an error if the key has not been configured.
    pub fn get_key(&self) -> Result<String> {
        self.get_config("key").map_err(|e| {
            Error::Other(format!(
                "Key not found, please run `git-se p` (or `git-se set key <VALUE>`) first: {e}"
            ))
        })
    }

    /// Set the key interactively by prompting on stdin.
    pub fn set_key_interactive(&self) -> Result<()> {
        let key = prompt_password("Please input your key: ")?;
        self.set_config("key", key.as_str())?;
        info!("Master key updated.");
        Ok(())
    }

    /// Check if all files in the crypt list (or given paths) are encrypted.
    ///
    /// When `staged` is true, only files staged for the current commit are
    /// checked, by intersecting staged files against the crypt list. This is
    /// used by the pre-commit hook to avoid checking the entire repo.
    ///
    /// Returns `Ok(())` if all files are encrypted, or an error summarizing
    /// which files are not encrypted. The process exits with a non-zero code
    /// when files are not encrypted, suitable for CI usage.
    pub fn check(&self, paths: &[PathBuf], staged: bool) -> Result<()> {
        let target_files = if staged {
            let staged_output =
                self.run_with_output(&["diff", "--cached", "--name-only", "--diff-filter=ACMR"])?;
            let crypt_files = resolve_target_files(&[], &self.conf.crypt_list, self.path());

            staged_output
                .lines()
                .map(|line| self.path.join(line.trim()))
                .filter(|p| p.exists())
                .filter(|f| crypt_files.contains(f))
                .collect()
        } else {
            resolve_target_files(paths, &self.conf.crypt_list, self.path())
        };

        if staged && target_files.is_empty() {
            println!("No staged files need encryption check.");
            return Ok(());
        }
        if target_files.is_empty() {
            return Err(Error::NoFile("check"));
        }

        println!(
            "\n{} {} {}",
            "Checking encryption status".bold(),
            format!("({} files)", target_files.len()).cyan(),
            ":".dimmed()
        );

        let pb = Progress::new(target_files.len(), "Check");
        let not_encrypted: Mutex<Vec<PathBuf>> = Mutex::new(Vec::new());

        target_files.par_iter().try_for_each(|f| -> Result<()> {
            if !is_file_encrypted(f)? {
                let mut list = not_encrypted.lock();
                let relative = pathdiff::diff_paths(f, &self.path).unwrap_or_else(|| f.clone());
                list.push(relative);
            }
            pb.inc(1);
            Ok(())
        })?;

        pb.finish_and_clear();

        let not_encrypted = not_encrypted.into_inner();
        let total = target_files.len();
        let encrypted_count = total - not_encrypted.len();

        if not_encrypted.is_empty() {
            println!(
                "\n{}: All {} files are encrypted.",
                "Check complete".bold(),
                total.to_string().green(),
            );
            Ok(())
        } else {
            println!(
                "\n{} files are {}:",
                not_encrypted.len().to_string().yellow(),
                "NOT encrypted".yellow()
            );
            for f in &not_encrypted {
                println!("  - {}", f.display());
            }

            println!(
                "\n{}: {}/{} files encrypted",
                "Check complete".bold(),
                encrypted_count.to_string().green(),
                total,
            );

            Err(Error::FilesNotEncrypted(not_encrypted.len(), total))
        }
    }

    /// Install a pre-commit hook that runs `git-se check` before each commit.
    ///
    /// Creates `<repo>/.git/hooks/pre-commit` with the check script.
    /// Fails if a hook already exists and is not managed by git-se.
    pub fn install_hook(&self) -> Result<()> {
        let hooks_dir = self.path.join(".git").join("hooks");
        std::fs::create_dir_all(&hooks_dir)?;

        let hook_path = hooks_dir.join("pre-commit");

        // Check if hook already exists
        if hook_path.exists() {
            return Err(Error::HookExists(hook_path));
        }

        std::fs::write(&hook_path, PRE_COMMIT_HOOK)?;

        // Set executable permission on unix. Windows users need Git for Windows
        // (msys-based) which executes the sh hook regardless of the executable
        // bit; a normal `write` is sufficient there.
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let mut perms = std::fs::metadata(&hook_path)?.permissions();
            perms.set_mode(0o755);
            std::fs::set_permissions(&hook_path, perms)?;
        }

        println!(
            "{} pre-commit hook at {}",
            "Installed".green().bold(),
            hook_path.display()
        );
        Ok(())
    }

    /// Run a `git` command in the repo, discarding its stdout/stderr.
    pub fn run(&self, args: &[&str]) -> Result<()> {
        let output = std::process::Command::new("git")
            .current_dir(&self.path)
            .args(args)
            .output()?;
        if !output.status.success() {
            return Err(Error::Git(
                String::from_utf8_lossy(&output.stderr).into_owned(),
            ));
        }
        Ok(())
    }

    /// Run a `git` command and return its trimmed stdout as a `String`.
    pub fn run_with_output(&self, args: &[&str]) -> Result<String> {
        let mut cmd = std::process::Command::new("git");

        // Force English output in tests so we can match on stderr reliably.
        if cfg!(test) {
            cmd.env("LC_ALL", "C.UTF-8").env("LANGUAGE", "C.UTF-8");
        }

        let output = cmd.current_dir(&self.path).args(args).output()?;
        if !output.status.success() {
            return Err(Error::Git(
                String::from_utf8_lossy(&output.stderr).into_owned(),
            ));
        }
        String::from_utf8(output.stdout)
            .map_err(|e| Error::Other(format!("git output not UTF-8: {e}")))
    }

    /// Write a value to `<prefix>.<key>` in the repo-local git config.
    ///
    /// Note: the value is stored verbatim (no trimming), so callers should
    /// sanitize it themselves if needed. This matters for the `key` field,
    /// which holds a user-supplied password and must not be silently modified.
    pub fn set_config(&self, key: &str, value: &str) -> Result<()> {
        let temp = String::from(GIT_CONFIG_PREFIX) + key;
        self.run(&["config", "--local", &temp, value])
    }

    /// Read `<prefix>.<key>` from the repo-local git config.
    pub fn get_config(&self, key: &str) -> Result<String> {
        let temp = String::from(GIT_CONFIG_PREFIX) + key;
        self.run_with_output(&["config", "--get", &temp])
            .map(|x| x.trim().to_string())
    }
}

#[cfg(test)]
mod tests {
    use std::process::Command;

    use path_absolutize::Absolutize;
    use tempfile::TempDir;

    use super::*;
    use crate::crypt::HEADER_LEN;

    #[test]
    fn test_repo_open() -> Result<()> {
        let repo = Repo::open(Path::new(".").absolutize()?)?;
        assert_eq!(repo.path().file_name().unwrap(), "git-simple-encrypt");
        let repo = Repo::open(Path::new("./.git").absolutize()?)?;
        assert_eq!(repo.path().file_name().unwrap(), "git-simple-encrypt");
        Ok(())
    }

    fn init_temp_repo() -> TempDir {
        let dir = TempDir::new().unwrap();
        // `git init` so that .git/ exists for hook installation & config.
        Command::new("git")
            .args(["init"])
            .current_dir(dir.path())
            .output()
            .unwrap();
        dir
    }

    #[test]
    fn test_set_get_config_roundtrip() -> Result<()> {
        let dir = init_temp_repo();
        let repo_path = dir.path().absolutize().unwrap().to_path_buf();
        let repo = Repo::open(&repo_path)?;

        repo.set_config("key", "hunter2")?;
        let read_back = repo.get_config("key")?;
        assert_eq!(read_back, "hunter2");
        Ok(())
    }

    #[test]
    fn test_install_hook_creates_file() -> Result<()> {
        let dir = init_temp_repo();
        let repo_path = dir.path().absolutize().unwrap().to_path_buf();
        let repo = Repo::open(&repo_path)?;

        repo.install_hook()?;
        let hook = repo_path.join(".git").join("hooks").join("pre-commit");
        assert!(hook.exists());
        let content = std::fs::read(&hook)?;
        assert!(
            content.starts_with(b"#!/bin/sh"),
            "hook should be a shell script"
        );

        // Second install must fail (idempotency guard).
        let second = repo.install_hook();
        assert!(matches!(second, Err(Error::HookExists(_))));
        Ok(())
    }

    #[test]
    fn test_check_reports_unencrypted() -> Result<()> {
        let dir = init_temp_repo();
        let repo_path = dir.path().absolutize().unwrap().to_path_buf();
        let mut repo = Repo::open(&repo_path)?;

        // Add a plaintext file to the crypt list.
        std::fs::write(repo_path.join("plain.txt"), b"hello")?;
        repo.conf.add_one_path_to_crypt_list("plain.txt")?;

        // check should return FilesNotEncrypted.
        let result = repo.check(&[], false);
        assert!(matches!(result, Err(Error::FilesNotEncrypted(1, 1))));

        // Encrypt the file (cheap path: just give it a valid 64-byte GITSE header so
        // is_file_encrypted returns true), then check passes.
        let mut fake_header = vec![0u8; HEADER_LEN];
        fake_header[..5].copy_from_slice(b"GITSE");
        fake_header[5] = 3; // version
        std::fs::write(repo_path.join("plain.txt"), fake_header).unwrap();
        let result = repo.check(&[], false);
        assert!(result.is_ok());
        Ok(())
    }
}