1use std::path::{Path, PathBuf};
2
3use git2::{Repository, Signature};
4
5use crate::error::VaultError;
6
7pub fn gitignore_content() -> &'static str {
9 "# agent-vault: block unencrypted key material\n\
10 *.key\n\
11 *.pem\n\
12 **/private.*\n\
13 !**/*.escrow\n"
14}
15
16pub fn pre_commit_hook_script() -> &'static str {
18 r#"#!/bin/sh
19# agent-vault pre-commit hook: block unencrypted private key material
20PATTERNS='AGE-SECRET-KEY-\|-----BEGIN PRIVATE KEY-----\|-----BEGIN RSA PRIVATE KEY-----\|-----BEGIN EC PRIVATE KEY-----\|-----BEGIN OPENSSH PRIVATE KEY-----'
21
22if git diff --cached --diff-filter=ACM -z --name-only | \
23 xargs -0 grep -l "$PATTERNS" 2>/dev/null; then
24 echo ""
25 echo "ERROR: Commit blocked by agent-vault pre-commit hook."
26 echo "Staged files contain unencrypted private key material."
27 echo "Remove the private key material before committing."
28 exit 1
29fi
30exit 0
31"#
32}
33
34pub fn open_repo(path: &Path) -> Result<Repository, VaultError> {
36 let repo = Repository::discover(path)?;
37 Ok(repo)
38}
39
40pub fn commit_files(repo: &Repository, paths: &[PathBuf], message: &str) -> Result<(), VaultError> {
42 let mut index = repo.index()?;
43
44 let workdir = repo
45 .workdir()
46 .ok_or_else(|| VaultError::Git(git2::Error::from_str("bare repository")))?;
47
48 let workdir_canonical = workdir.canonicalize().unwrap_or_else(|_| workdir.to_path_buf());
50
51 for path in paths {
52 let canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
54 let relative = canonical
55 .strip_prefix(&workdir_canonical)
56 .unwrap_or(&canonical);
57 index.add_path(relative)?;
58 }
59 index.write()?;
60
61 let tree_id = index.write_tree()?;
62 let tree = repo.find_tree(tree_id)?;
63
64 let sig = Signature::now("agent-vault", "agent-vault@localhost")?;
65
66 let parent_commit = repo.head().ok().and_then(|head| head.peel_to_commit().ok());
68
69 match parent_commit {
70 Some(parent) => {
71 repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent])?;
72 }
73 None => {
74 repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[])?;
75 }
76 };
77
78 Ok(())
79}
80
81pub fn pull(repo: &Repository) -> Result<(), VaultError> {
83 let remote = match repo.find_remote("origin") {
85 Ok(r) => r,
86 Err(_) => return Ok(()), };
88 let remote_name = remote.name().unwrap_or("origin").to_string();
89 drop(remote);
90
91 let mut remote = repo.find_remote(&remote_name)?;
93 remote.fetch(&[] as &[&str], None, None)?;
94
95 let fetch_head = match repo.find_reference("FETCH_HEAD") {
97 Ok(r) => r,
98 Err(_) => return Ok(()), };
100 let fetch_commit = repo.reference_to_annotated_commit(&fetch_head)?;
101
102 let (analysis, _) = repo.merge_analysis(&[&fetch_commit])?;
103 if analysis.is_fast_forward() {
104 if let Ok(mut head_ref) = repo.head() {
105 let msg = format!("Fast-forward to {}", fetch_commit.id());
106 head_ref.set_target(fetch_commit.id(), &msg)?;
107 repo.checkout_head(Some(git2::build::CheckoutBuilder::default().force()))?;
108 }
109 }
110 Ok(())
113}
114
115pub fn remove_dir_from_index(repo: &Repository, relative_path: &Path) -> Result<(), VaultError> {
118 let mut index = repo.index()?;
119 index.remove_dir(relative_path, 0)?;
120 index.write()?;
121 Ok(())
122}
123
124pub fn install_pre_commit_hook(repo: &Repository) -> Result<(), VaultError> {
126 let hooks_dir = repo.path().join("hooks");
127 std::fs::create_dir_all(&hooks_dir)?;
128
129 let hook_path = hooks_dir.join("pre-commit");
130
131 if hook_path.exists() {
133 let existing = std::fs::read_to_string(&hook_path)?;
134 if existing.contains("agent-vault") {
135 return Ok(());
136 }
137 let combined = format!("{existing}\n{}", pre_commit_hook_script());
139 std::fs::write(&hook_path, combined)?;
140 } else {
141 std::fs::write(&hook_path, pre_commit_hook_script())?;
142 }
143
144 #[cfg(unix)]
146 {
147 use std::os::unix::fs::PermissionsExt;
148 std::fs::set_permissions(&hook_path, std::fs::Permissions::from_mode(0o755))?;
149 }
150
151 Ok(())
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157
158 #[test]
159 fn test_pre_commit_hook_contains_all_patterns() {
160 let script = pre_commit_hook_script();
161 assert!(script.contains("AGE-SECRET-KEY-"));
162 assert!(script.contains("BEGIN PRIVATE KEY"));
163 assert!(script.contains("BEGIN RSA PRIVATE KEY"));
164 assert!(script.contains("BEGIN EC PRIVATE KEY"));
165 assert!(script.contains("BEGIN OPENSSH PRIVATE KEY"));
166 }
167}