git_simple_encrypt/
repo.rs1use 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
17const 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 pub path: PathBuf,
32 pub conf: Config,
33}
34
35impl Repo {
36 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 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 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 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 ¬_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 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 if hook_path.exists() {
197 return Err(Error::HookExists(hook_path));
198 }
199
200 std::fs::write(&hook_path, PRE_COMMIT_HOOK)?;
201
202 #[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 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 pub fn run_with_output(&self, args: &[&str]) -> Result<String> {
237 let mut cmd = std::process::Command::new("git");
238
239 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 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 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 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 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 std::fs::write(repo_path.join("plain.txt"), b"hello")?;
343 repo.conf.add_one_path_to_crypt_list("plain.txt")?;
344
345 let result = repo.check(&[], false);
347 assert!(matches!(result, Err(Error::FilesNotEncrypted(1, 1))));
348
349 let mut fake_header = vec![0u8; HEADER_LEN];
352 fake_header[..5].copy_from_slice(b"GITSE");
353 fake_header[5] = 3; 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}