vex_anchor/
git.rs

1//! Git-based anchoring backend
2//!
3//! Commits Merkle roots to a Git repository for tamper-evident timestamping.
4//! Each anchor creates a new commit with the root hash in the commit message.
5
6use 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/// Git-based anchor backend
17///
18/// Creates commits in a Git repository containing the Merkle root.
19/// The commit hash serves as a tamper-evident timestamp.
20///
21/// ## Security Properties
22/// - Git commit hashes are SHA-1 (legacy) or SHA-256 (modern)
23/// - Commits can be pushed to remote repositories for redundancy
24/// - OpenTimestamps can be added for Bitcoin-backed timestamps
25#[derive(Debug, Clone)]
26pub struct GitAnchor {
27    repo_path: PathBuf,
28    branch: String,
29}
30
31impl GitAnchor {
32    /// Create a new Git anchor
33    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    /// Set the branch to use for anchors (Sanitized to prevent injection)
41    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(' ', "-") // Branches cannot have spaces
45            .chars()
46            .filter(|c| c.is_alphanumeric() || "-_".contains(*c))
47            .collect();
48        self
49    }
50
51    /// Sanitize a string for use in git commit messages
52    /// Prevents log injection and potential git hook exploitation (CRITICAL-2 fix)
53    fn sanitize_git_message(s: &str) -> String {
54        s.chars()
55            // Allow alphanumeric, common punctuation, and whitespace
56            .filter(|c| c.is_alphanumeric() || " -_:.@/()[]{}".contains(*c))
57            // Remove control characters and ANSI escape sequences
58            .filter(|c| !c.is_control())
59            // Limit length to prevent abuse
60            .take(1000)
61            .collect()
62    }
63
64    /// Run a git command and return stdout
65    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    /// Initialize the anchor branch if it doesn't exist
84    async fn ensure_branch(&self) -> Result<(), AnchorError> {
85        // Check if branch exists
86        let branches = self.git(&["branch", "--list", &self.branch]).await?;
87
88        if branches.is_empty() {
89            // Create orphan branch for anchors
90            self.git(&["checkout", "--orphan", &self.branch]).await?;
91
92            // Create initial commit
93            self.git(&[
94                "commit",
95                "--allow-empty",
96                "-m",
97                "VEX Anchor Chain Initialized",
98            ])
99            .await?;
100        } else {
101            // Switch to the branch
102            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        // Ensure we're on the right branch
117        self.ensure_branch().await?;
118
119        // Sanitize user-controlled fields (CRITICAL-2 fix)
120        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        // Create commit message with structured data
125        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        // Create empty commit with the anchor data
141        let commit_hash = self
142            .git(&["commit", "--allow-empty", "-m", &message])
143            .await?;
144
145        // Get the commit hash
146        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        // Switch to anchor branch
160        let _ = self.git(&["checkout", &self.branch]).await;
161
162        // Check if commit exists
163        let result = self.git(&["cat-file", "-t", &receipt.anchor_id]).await;
164
165        if result.is_err() {
166            return Ok(false);
167        }
168
169        // Get commit message
170        let message = self
171            .git(&["log", "-1", "--format=%B", &receipt.anchor_id])
172            .await?;
173
174        // Verify root hash is in the commit
175        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        // Check if this is a git repository
184        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        // Initial commit on main
216        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        // Anchor
236        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        // Verify
242        let valid = anchor.verify(&receipt).await.unwrap();
243        assert!(valid, "Receipt should verify");
244    }
245}