use std::path::{Path, PathBuf};
use git2::{Repository, Signature};
use crate::error::VaultError;
pub fn gitignore_content() -> &'static str {
"# agent-vault: block unencrypted key material\n\
*.key\n\
*.pem\n\
**/private.*\n\
!**/*.escrow\n"
}
pub fn pre_commit_hook_script() -> &'static str {
r#"#!/bin/sh
# agent-vault pre-commit hook: block unencrypted private key material
PATTERNS='AGE-SECRET-KEY-\|-----BEGIN PRIVATE KEY-----\|-----BEGIN RSA PRIVATE KEY-----\|-----BEGIN EC PRIVATE KEY-----\|-----BEGIN OPENSSH PRIVATE KEY-----'
if git diff --cached --diff-filter=ACM -z --name-only | \
xargs -0 grep -l "$PATTERNS" 2>/dev/null; then
echo ""
echo "ERROR: Commit blocked by agent-vault pre-commit hook."
echo "Staged files contain unencrypted private key material."
echo "Remove the private key material before committing."
exit 1
fi
exit 0
"#
}
pub fn open_repo(path: &Path) -> Result<Repository, VaultError> {
let repo = Repository::discover(path)?;
Ok(repo)
}
pub fn commit_files(repo: &Repository, paths: &[PathBuf], message: &str) -> Result<(), VaultError> {
let mut index = repo.index()?;
let workdir = repo
.workdir()
.ok_or_else(|| VaultError::Git(git2::Error::from_str("bare repository")))?;
let workdir_canonical = workdir.canonicalize().unwrap_or_else(|_| workdir.to_path_buf());
for path in paths {
let canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
let relative = canonical
.strip_prefix(&workdir_canonical)
.unwrap_or(&canonical);
index.add_path(relative)?;
}
index.write()?;
let tree_id = index.write_tree()?;
let tree = repo.find_tree(tree_id)?;
let sig = Signature::now("agent-vault", "agent-vault@localhost")?;
let parent_commit = repo.head().ok().and_then(|head| head.peel_to_commit().ok());
match parent_commit {
Some(parent) => {
repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent])?;
}
None => {
repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[])?;
}
};
Ok(())
}
pub fn pull(repo: &Repository) -> Result<(), VaultError> {
let remote = match repo.find_remote("origin") {
Ok(r) => r,
Err(_) => return Ok(()), };
let remote_name = remote.name().unwrap_or("origin").to_string();
drop(remote);
let mut remote = repo.find_remote(&remote_name)?;
remote.fetch(&[] as &[&str], None, None)?;
let fetch_head = match repo.find_reference("FETCH_HEAD") {
Ok(r) => r,
Err(_) => return Ok(()), };
let fetch_commit = repo.reference_to_annotated_commit(&fetch_head)?;
let (analysis, _) = repo.merge_analysis(&[&fetch_commit])?;
if analysis.is_fast_forward() {
if let Ok(mut head_ref) = repo.head() {
let msg = format!("Fast-forward to {}", fetch_commit.id());
head_ref.set_target(fetch_commit.id(), &msg)?;
repo.checkout_head(Some(git2::build::CheckoutBuilder::default().force()))?;
}
}
Ok(())
}
pub fn remove_dir_from_index(repo: &Repository, relative_path: &Path) -> Result<(), VaultError> {
let mut index = repo.index()?;
index.remove_dir(relative_path, 0)?;
index.write()?;
Ok(())
}
pub fn install_pre_commit_hook(repo: &Repository) -> Result<(), VaultError> {
let hooks_dir = repo.path().join("hooks");
std::fs::create_dir_all(&hooks_dir)?;
let hook_path = hooks_dir.join("pre-commit");
if hook_path.exists() {
let existing = std::fs::read_to_string(&hook_path)?;
if existing.contains("agent-vault") {
return Ok(());
}
let combined = format!("{existing}\n{}", pre_commit_hook_script());
std::fs::write(&hook_path, combined)?;
} else {
std::fs::write(&hook_path, pre_commit_hook_script())?;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&hook_path, std::fs::Permissions::from_mode(0o755))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pre_commit_hook_contains_all_patterns() {
let script = pre_commit_hook_script();
assert!(script.contains("AGE-SECRET-KEY-"));
assert!(script.contains("BEGIN PRIVATE KEY"));
assert!(script.contains("BEGIN RSA PRIVATE KEY"));
assert!(script.contains("BEGIN EC PRIVATE KEY"));
assert!(script.contains("BEGIN OPENSSH PRIVATE KEY"));
}
}