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"), "."), "_", "-");
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 {
pub path: PathBuf,
pub conf: Config,
}
impl Repo {
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())
}
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}"
))
})
}
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(())
}
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 ¬_encrypted {
println!(" - {}", f.display());
}
println!(
"\n{}: {}/{} files encrypted",
"Check complete".bold(),
encrypted_count.to_string().green(),
total,
);
Err(Error::FilesNotEncrypted(not_encrypted.len(), total))
}
}
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");
if hook_path.exists() {
return Err(Error::HookExists(hook_path));
}
std::fs::write(&hook_path, PRE_COMMIT_HOOK)?;
#[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(())
}
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(())
}
pub fn run_with_output(&self, args: &[&str]) -> Result<String> {
let mut cmd = std::process::Command::new("git");
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}")))
}
pub fn set_config(&self, key: &str, value: &str) -> Result<()> {
let temp = String::from(GIT_CONFIG_PREFIX) + key;
self.run(&["config", "--local", &temp, value])
}
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();
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"
);
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)?;
std::fs::write(repo_path.join("plain.txt"), b"hello")?;
repo.conf.add_one_path_to_crypt_list("plain.txt")?;
let result = repo.check(&[], false);
assert!(matches!(result, Err(Error::FilesNotEncrypted(1, 1))));
let mut fake_header = vec![0u8; HEADER_LEN];
fake_header[..5].copy_from_slice(b"GITSE");
fake_header[5] = 3; std::fs::write(repo_path.join("plain.txt"), fake_header).unwrap();
let result = repo.check(&[], false);
assert!(result.is_ok());
Ok(())
}
}