1use async_trait::async_trait;
7use chrono::Utc;
8use std::path::PathBuf;
9use std::process::Stdio;
10use tokio::process::Command;
11use vex_core::Hash;
12
13use crate::backend::{AnchorBackend, AnchorMetadata, AnchorReceipt};
14use crate::error::AnchorError;
15
16#[derive(Debug, Clone)]
26pub struct GitAnchor {
27 repo_path: PathBuf,
28 branch: String,
29}
30
31impl GitAnchor {
32 pub fn new(repo_path: impl Into<PathBuf>) -> Self {
34 Self {
35 repo_path: repo_path.into(),
36 branch: "vex-anchors".to_string(),
37 }
38 }
39
40 pub fn with_branch(mut self, branch: impl Into<String>) -> Self {
42 let branch_name: String = branch.into();
43 self.branch = Self::sanitize_git_message(&branch_name)
44 .replace(' ', "-") .chars()
46 .filter(|c| c.is_alphanumeric() || "-_".contains(*c))
47 .collect();
48 self
49 }
50
51 fn sanitize_git_message(s: &str) -> String {
54 s.chars()
55 .filter(|c| c.is_alphanumeric() || " -_:.@/()[]{}".contains(*c))
57 .filter(|c| !c.is_control())
59 .take(1000)
61 .collect()
62 }
63
64 async fn git(&self, args: &[&str]) -> Result<String, AnchorError> {
66 let output = Command::new("git")
67 .args(args)
68 .current_dir(&self.repo_path)
69 .stdout(Stdio::piped())
70 .stderr(Stdio::piped())
71 .output()
72 .await
73 .map_err(|e| AnchorError::Git(format!("Failed to run git: {}", e)))?;
74
75 if !output.status.success() {
76 let stderr = String::from_utf8_lossy(&output.stderr);
77 return Err(AnchorError::Git(format!("Git command failed: {}", stderr)));
78 }
79
80 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
81 }
82
83 async fn ensure_branch(&self) -> Result<(), AnchorError> {
85 let branches = self.git(&["branch", "--list", &self.branch]).await?;
87
88 if branches.is_empty() {
89 self.git(&["checkout", "--orphan", &self.branch]).await?;
91
92 self.git(&[
94 "commit",
95 "--allow-empty",
96 "-m",
97 "VEX Anchor Chain Initialized",
98 ])
99 .await?;
100 } else {
101 self.git(&["checkout", &self.branch]).await?;
103 }
104
105 Ok(())
106 }
107}
108
109#[async_trait]
110impl AnchorBackend for GitAnchor {
111 async fn anchor(
112 &self,
113 root: &Hash,
114 metadata: AnchorMetadata,
115 ) -> Result<AnchorReceipt, AnchorError> {
116 self.ensure_branch().await?;
118
119 let safe_tenant = Self::sanitize_git_message(&metadata.tenant_id);
121 let safe_description =
122 Self::sanitize_git_message(metadata.description.as_deref().unwrap_or("N/A"));
123
124 let message = format!(
126 "VEX Anchor: {}\n\n\
127 Root: {}\n\
128 Tenant: {}\n\
129 Events: {}\n\
130 Timestamp: {}\n\
131 Description: {}",
132 &root.to_hex()[..16],
133 root.to_hex(),
134 safe_tenant,
135 metadata.event_count,
136 metadata.timestamp.to_rfc3339(),
137 safe_description
138 );
139
140 let commit_hash = self
142 .git(&["commit", "--allow-empty", "-m", &message])
143 .await?;
144
145 let anchor_id = self.git(&["rev-parse", "HEAD"]).await?;
147
148 Ok(AnchorReceipt {
149 backend: self.name().to_string(),
150 root_hash: root.to_hex(),
151 anchor_id,
152 anchored_at: Utc::now(),
153 proof: Some(format!("git:{}:{}", self.branch, commit_hash)),
154 metadata,
155 })
156 }
157
158 async fn verify(&self, receipt: &AnchorReceipt) -> Result<bool, AnchorError> {
159 let _ = self.git(&["checkout", &self.branch]).await;
161
162 let result = self.git(&["cat-file", "-t", &receipt.anchor_id]).await;
164
165 if result.is_err() {
166 return Ok(false);
167 }
168
169 let message = self
171 .git(&["log", "-1", "--format=%B", &receipt.anchor_id])
172 .await?;
173
174 Ok(message.contains(&receipt.root_hash))
176 }
177
178 fn name(&self) -> &str {
179 "git"
180 }
181
182 async fn is_healthy(&self) -> bool {
183 self.git(&["status"]).await.is_ok()
185 }
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191 use tempfile::tempdir;
192
193 async fn init_test_repo(path: &PathBuf) {
194 Command::new("git")
195 .args(["init"])
196 .current_dir(path)
197 .output()
198 .await
199 .unwrap();
200
201 Command::new("git")
202 .args(["config", "user.email", "test@test.com"])
203 .current_dir(path)
204 .output()
205 .await
206 .unwrap();
207
208 Command::new("git")
209 .args(["config", "user.name", "Test"])
210 .current_dir(path)
211 .output()
212 .await
213 .unwrap();
214
215 Command::new("git")
217 .args(["commit", "--allow-empty", "-m", "Initial"])
218 .current_dir(path)
219 .output()
220 .await
221 .unwrap();
222 }
223
224 #[tokio::test]
225 async fn test_git_anchor_roundtrip() {
226 let dir = tempdir().unwrap();
227 let repo_path = dir.path().to_path_buf();
228 init_test_repo(&repo_path).await;
229
230 let anchor = GitAnchor::new(&repo_path);
231
232 let root = Hash::digest(b"test_merkle_root");
233 let metadata = AnchorMetadata::new("tenant-1", 100);
234
235 let receipt = anchor.anchor(&root, metadata).await.unwrap();
237 assert_eq!(receipt.backend, "git");
238 assert_eq!(receipt.root_hash, root.to_hex());
239 assert!(!receipt.anchor_id.is_empty());
240
241 let valid = anchor.verify(&receipt).await.unwrap();
243 assert!(valid, "Receipt should verify");
244 }
245}