use async_trait::async_trait;
use chrono::Utc;
use std::path::PathBuf;
use std::process::Stdio;
use tokio::process::Command;
use vex_core::Hash;
use crate::backend::{AnchorBackend, AnchorMetadata, AnchorReceipt};
use crate::error::AnchorError;
#[derive(Debug, Clone)]
pub struct GitAnchor {
repo_path: PathBuf,
branch: String,
}
impl GitAnchor {
pub fn new(repo_path: impl Into<PathBuf>) -> Self {
Self {
repo_path: repo_path.into(),
branch: "vex-anchors".to_string(),
}
}
pub fn with_branch(mut self, branch: impl Into<String>) -> Self {
let branch_name: String = branch.into();
self.branch = Self::sanitize_git_message(&branch_name)
.replace(' ', "-") .chars()
.filter(|c| c.is_alphanumeric() || "-_".contains(*c))
.collect();
self
}
fn sanitize_git_message(s: &str) -> String {
s.chars()
.filter(|c| c.is_alphanumeric() || " -_:.@/()[]{}".contains(*c))
.filter(|c| !c.is_control())
.take(1000)
.collect()
}
async fn git(&self, args: &[&str]) -> Result<String, AnchorError> {
let output = Command::new("git")
.args(args)
.current_dir(&self.repo_path)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.map_err(|e| AnchorError::Git(format!("Failed to run git: {}", e)))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(AnchorError::Git(format!("Git command failed: {}", stderr)));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
async fn ensure_branch(&self) -> Result<(), AnchorError> {
let branches = self.git(&["branch", "--list", &self.branch]).await?;
if branches.is_empty() {
self.git(&["checkout", "--orphan", &self.branch]).await?;
self.git(&[
"commit",
"--allow-empty",
"-m",
"VEX Anchor Chain Initialized",
])
.await?;
} else {
self.git(&["checkout", &self.branch]).await?;
}
Ok(())
}
}
#[async_trait]
impl AnchorBackend for GitAnchor {
async fn anchor(
&self,
root: &Hash,
metadata: AnchorMetadata,
) -> Result<AnchorReceipt, AnchorError> {
self.ensure_branch().await?;
let safe_tenant = Self::sanitize_git_message(&metadata.tenant_id);
let safe_description =
Self::sanitize_git_message(metadata.description.as_deref().unwrap_or("N/A"));
let message = format!(
"VEX Anchor: {}\n\n\
Root: {}\n\
Tenant: {}\n\
Events: {}\n\
Timestamp: {}\n\
Description: {}",
&root.to_hex()[..16],
root.to_hex(),
safe_tenant,
metadata.event_count,
metadata.timestamp.to_rfc3339(),
safe_description
);
let commit_hash = self
.git(&["commit", "--allow-empty", "-m", &message])
.await?;
let anchor_id = self.git(&["rev-parse", "HEAD"]).await?;
Ok(AnchorReceipt {
backend: self.name().to_string(),
root_hash: root.to_hex(),
anchor_id,
anchored_at: Utc::now(),
proof: Some(format!("git:{}:{}", self.branch, commit_hash)),
metadata,
})
}
async fn verify(&self, receipt: &AnchorReceipt) -> Result<bool, AnchorError> {
let _ = self.git(&["checkout", &self.branch]).await;
let result = self.git(&["cat-file", "-t", &receipt.anchor_id]).await;
if result.is_err() {
return Ok(false);
}
let message = self
.git(&["log", "-1", "--format=%B", &receipt.anchor_id])
.await?;
Ok(message.contains(&receipt.root_hash))
}
fn name(&self) -> &str {
"git"
}
async fn is_healthy(&self) -> bool {
self.git(&["status"]).await.is_ok()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
async fn init_test_repo(path: &PathBuf) {
Command::new("git")
.args(["init"])
.current_dir(path)
.output()
.await
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(path)
.output()
.await
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(path)
.output()
.await
.unwrap();
Command::new("git")
.args(["commit", "--allow-empty", "-m", "Initial"])
.current_dir(path)
.output()
.await
.unwrap();
}
#[tokio::test]
async fn test_git_anchor_roundtrip() {
let dir = tempdir().unwrap();
let repo_path = dir.path().to_path_buf();
init_test_repo(&repo_path).await;
let anchor = GitAnchor::new(&repo_path);
let root = Hash::digest(b"test_merkle_root");
let metadata = AnchorMetadata::new("tenant-1", 100);
let receipt = anchor.anchor(&root, metadata).await.unwrap();
assert_eq!(receipt.backend, "git");
assert_eq!(receipt.root_hash, root.to_hex());
assert!(!receipt.anchor_id.is_empty());
let valid = anchor.verify(&receipt).await.unwrap();
assert!(valid, "Receipt should verify");
}
}