Skip to main content

git_simple_encrypt/
repo.rs

1use std::path::{Path, PathBuf};
2
3use config_file2::LoadConfigFile;
4use log::{info, warn};
5use parking_lot::Mutex;
6use rayon::prelude::*;
7
8use crate::{
9    config::{CONFIG_FILE_NAME, Config},
10    error::{Error, Result},
11    utils::{Progress, is_file_encrypted, prompt_password, resolve_target_files, style::Colorize},
12};
13
14pub const GIT_CONFIG_PREFIX: &str =
15    const_str::replace!(concat!(env!("CARGO_CRATE_NAME"), "."), "_", "-");
16
17/// The pre-commit hook content that runs `git-se check --staged` before every
18/// commit, so only files part of the current commit are checked.
19const PRE_COMMIT_HOOK: &[u8] = br#"#!/bin/sh
20# Auto-generated by git-se install
21git-se check --staged
22if [ $? -ne 0 ]; then
23    echo "Please run 'git-se e' to encrypt them before committing."
24    exit 1
25fi
26"#;
27
28#[derive(Debug, Clone, Default)]
29pub struct Repo {
30    /// The absolute path of the opened repo.
31    pub path: PathBuf,
32    pub conf: Config,
33}
34
35impl Repo {
36    /// Open a repo. The `path` argument must be an absolute path.
37    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
38        debug_assert!(path.as_ref().is_absolute(), "given path must be absolute");
39        let mut repo_path = path.as_ref().to_path_buf();
40        if !repo_path.exists() {
41            return Err(Error::RepoNotFound(repo_path));
42        }
43        if !repo_path.is_dir() {
44            return Err(Error::NotADirectory(repo_path));
45        }
46        if repo_path
47            .file_name()
48            .ok_or_else(|| Error::Other("Filename not found".to_string()))?
49            == ".git"
50        {
51            repo_path.pop();
52        }
53        info!("Open repo: {}", repo_path.display());
54        let config_file_path = repo_path.join(CONFIG_FILE_NAME);
55        if !config_file_path.exists() {
56            warn!(
57                "Config file not found: `{}`, using default config instead...",
58                config_file_path.display()
59            );
60        }
61        let conf = Config::load_or_default(&config_file_path)
62            .map_err(|e| Error::Config(e.to_string()))?
63            .with_repo_path(&repo_path);
64        Ok(Self {
65            path: repo_path,
66            conf,
67        })
68    }
69
70    #[must_use]
71    pub fn path(&self) -> &Path {
72        &self.path
73    }
74
75    pub fn to_absolute_path(&self, path: impl AsRef<Path>) -> PathBuf {
76        self.path.join(path.as_ref())
77    }
78
79    /// Read the master key from git config.
80    ///
81    /// Returns an error if the key has not been configured.
82    pub fn get_key(&self) -> Result<String> {
83        self.get_config("key").map_err(|e| {
84            Error::Other(format!(
85                "Key not found, please run `git-se p` (or `git-se set key <VALUE>`) first: {e}"
86            ))
87        })
88    }
89
90    /// Set the key interactively by prompting on stdin.
91    pub fn set_key_interactive(&self) -> Result<()> {
92        let key = prompt_password("Please input your key: ")?;
93        self.set_config("key", key.as_str())?;
94        info!("Master key updated.");
95        Ok(())
96    }
97
98    /// Check if all files in the crypt list (or given paths) are encrypted.
99    ///
100    /// When `staged` is true, only files staged for the current commit are
101    /// checked, by intersecting staged files against the crypt list. This is
102    /// used by the pre-commit hook to avoid checking the entire repo.
103    ///
104    /// Returns `Ok(())` if all files are encrypted, or an error summarizing
105    /// which files are not encrypted. The process exits with a non-zero code
106    /// when files are not encrypted, suitable for CI usage.
107    pub fn check(&self, paths: &[PathBuf], staged: bool) -> Result<()> {
108        let target_files = if staged {
109            let staged_output =
110                self.run_with_output(&["diff", "--cached", "--name-only", "--diff-filter=ACMR"])?;
111            let crypt_files = resolve_target_files(&[], &self.conf.crypt_list, self.path());
112
113            staged_output
114                .lines()
115                .map(|line| self.path.join(line.trim()))
116                .filter(|p| p.exists())
117                .filter(|f| crypt_files.contains(f))
118                .collect()
119        } else {
120            resolve_target_files(paths, &self.conf.crypt_list, self.path())
121        };
122
123        if staged && target_files.is_empty() {
124            println!("No staged files need encryption check.");
125            return Ok(());
126        }
127        if target_files.is_empty() {
128            return Err(Error::NoFile("check"));
129        }
130
131        println!(
132            "\n{} {} {}",
133            "Checking encryption status".bold(),
134            format!("({} files)", target_files.len()).cyan(),
135            ":".dimmed()
136        );
137
138        let pb = Progress::new(target_files.len(), "Check");
139        let not_encrypted: Mutex<Vec<PathBuf>> = Mutex::new(Vec::new());
140
141        target_files.par_iter().try_for_each(|f| -> Result<()> {
142            if !is_file_encrypted(f)? {
143                let mut list = not_encrypted.lock();
144                let relative = pathdiff::diff_paths(f, &self.path).unwrap_or_else(|| f.clone());
145                list.push(relative);
146            }
147            pb.inc(1);
148            Ok(())
149        })?;
150
151        pb.finish_and_clear();
152
153        let not_encrypted = not_encrypted.into_inner();
154        let total = target_files.len();
155        let encrypted_count = total - not_encrypted.len();
156
157        if not_encrypted.is_empty() {
158            println!(
159                "\n{}: All {} files are encrypted.",
160                "Check complete".bold(),
161                total.to_string().green(),
162            );
163            Ok(())
164        } else {
165            println!(
166                "\n{} files are {}:",
167                not_encrypted.len().to_string().yellow(),
168                "NOT encrypted".yellow()
169            );
170            for f in &not_encrypted {
171                println!("  - {}", f.display());
172            }
173
174            println!(
175                "\n{}: {}/{} files encrypted",
176                "Check complete".bold(),
177                encrypted_count.to_string().green(),
178                total,
179            );
180
181            Err(Error::FilesNotEncrypted(not_encrypted.len(), total))
182        }
183    }
184
185    /// Install a pre-commit hook that runs `git-se check` before each commit.
186    ///
187    /// Creates `<repo>/.git/hooks/pre-commit` with the check script.
188    /// Fails if a hook already exists and is not managed by git-se.
189    pub fn install_hook(&self) -> Result<()> {
190        let hooks_dir = self.path.join(".git").join("hooks");
191        std::fs::create_dir_all(&hooks_dir)?;
192
193        let hook_path = hooks_dir.join("pre-commit");
194
195        // Check if hook already exists
196        if hook_path.exists() {
197            return Err(Error::HookExists(hook_path));
198        }
199
200        std::fs::write(&hook_path, PRE_COMMIT_HOOK)?;
201
202        // Set executable permission on unix. Windows users need Git for Windows
203        // (msys-based) which executes the sh hook regardless of the executable
204        // bit; a normal `write` is sufficient there.
205        #[cfg(unix)]
206        {
207            use std::os::unix::fs::PermissionsExt;
208            let mut perms = std::fs::metadata(&hook_path)?.permissions();
209            perms.set_mode(0o755);
210            std::fs::set_permissions(&hook_path, perms)?;
211        }
212
213        println!(
214            "{} pre-commit hook at {}",
215            "Installed".green().bold(),
216            hook_path.display()
217        );
218        Ok(())
219    }
220
221    /// Run a `git` command in the repo, discarding its stdout/stderr.
222    pub fn run(&self, args: &[&str]) -> Result<()> {
223        let output = std::process::Command::new("git")
224            .current_dir(&self.path)
225            .args(args)
226            .output()?;
227        if !output.status.success() {
228            return Err(Error::Git(
229                String::from_utf8_lossy(&output.stderr).into_owned(),
230            ));
231        }
232        Ok(())
233    }
234
235    /// Run a `git` command and return its trimmed stdout as a `String`.
236    pub fn run_with_output(&self, args: &[&str]) -> Result<String> {
237        let mut cmd = std::process::Command::new("git");
238
239        // Force English output in tests so we can match on stderr reliably.
240        if cfg!(test) {
241            cmd.env("LC_ALL", "C.UTF-8").env("LANGUAGE", "C.UTF-8");
242        }
243
244        let output = cmd.current_dir(&self.path).args(args).output()?;
245        if !output.status.success() {
246            return Err(Error::Git(
247                String::from_utf8_lossy(&output.stderr).into_owned(),
248            ));
249        }
250        String::from_utf8(output.stdout)
251            .map_err(|e| Error::Other(format!("git output not UTF-8: {e}")))
252    }
253
254    /// Write a value to `<prefix>.<key>` in the repo-local git config.
255    ///
256    /// Note: the value is stored verbatim (no trimming), so callers should
257    /// sanitize it themselves if needed. This matters for the `key` field,
258    /// which holds a user-supplied password and must not be silently modified.
259    pub fn set_config(&self, key: &str, value: &str) -> Result<()> {
260        let temp = String::from(GIT_CONFIG_PREFIX) + key;
261        self.run(&["config", "--local", &temp, value])
262    }
263
264    /// Read `<prefix>.<key>` from the repo-local git config.
265    pub fn get_config(&self, key: &str) -> Result<String> {
266        let temp = String::from(GIT_CONFIG_PREFIX) + key;
267        self.run_with_output(&["config", "--get", &temp])
268            .map(|x| x.trim().to_string())
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use std::process::Command;
275
276    use path_absolutize::Absolutize;
277    use tempfile::TempDir;
278
279    use super::*;
280    use crate::crypt::HEADER_LEN;
281
282    #[test]
283    fn test_repo_open() -> Result<()> {
284        let repo = Repo::open(Path::new(".").absolutize()?)?;
285        assert_eq!(repo.path().file_name().unwrap(), "git-simple-encrypt");
286        let repo = Repo::open(Path::new("./.git").absolutize()?)?;
287        assert_eq!(repo.path().file_name().unwrap(), "git-simple-encrypt");
288        Ok(())
289    }
290
291    fn init_temp_repo() -> TempDir {
292        let dir = TempDir::new().unwrap();
293        // `git init` so that .git/ exists for hook installation & config.
294        Command::new("git")
295            .args(["init"])
296            .current_dir(dir.path())
297            .output()
298            .unwrap();
299        dir
300    }
301
302    #[test]
303    fn test_set_get_config_roundtrip() -> Result<()> {
304        let dir = init_temp_repo();
305        let repo_path = dir.path().absolutize().unwrap().to_path_buf();
306        let repo = Repo::open(&repo_path)?;
307
308        repo.set_config("key", "hunter2")?;
309        let read_back = repo.get_config("key")?;
310        assert_eq!(read_back, "hunter2");
311        Ok(())
312    }
313
314    #[test]
315    fn test_install_hook_creates_file() -> Result<()> {
316        let dir = init_temp_repo();
317        let repo_path = dir.path().absolutize().unwrap().to_path_buf();
318        let repo = Repo::open(&repo_path)?;
319
320        repo.install_hook()?;
321        let hook = repo_path.join(".git").join("hooks").join("pre-commit");
322        assert!(hook.exists());
323        let content = std::fs::read(&hook)?;
324        assert!(
325            content.starts_with(b"#!/bin/sh"),
326            "hook should be a shell script"
327        );
328
329        // Second install must fail (idempotency guard).
330        let second = repo.install_hook();
331        assert!(matches!(second, Err(Error::HookExists(_))));
332        Ok(())
333    }
334
335    #[test]
336    fn test_check_reports_unencrypted() -> Result<()> {
337        let dir = init_temp_repo();
338        let repo_path = dir.path().absolutize().unwrap().to_path_buf();
339        let mut repo = Repo::open(&repo_path)?;
340
341        // Add a plaintext file to the crypt list.
342        std::fs::write(repo_path.join("plain.txt"), b"hello")?;
343        repo.conf.add_one_path_to_crypt_list("plain.txt")?;
344
345        // check should return FilesNotEncrypted.
346        let result = repo.check(&[], false);
347        assert!(matches!(result, Err(Error::FilesNotEncrypted(1, 1))));
348
349        // Encrypt the file (cheap path: just give it a valid 64-byte GITSE header so
350        // is_file_encrypted returns true), then check passes.
351        let mut fake_header = vec![0u8; HEADER_LEN];
352        fake_header[..5].copy_from_slice(b"GITSE");
353        fake_header[5] = 3; // version
354        std::fs::write(repo_path.join("plain.txt"), fake_header).unwrap();
355        let result = repo.check(&[], false);
356        assert!(result.is_ok());
357        Ok(())
358    }
359}