Skip to main content

tracevault_cli/commands/
verify.rs

1use crate::api_client::{resolve_credentials, ApiClient, CiVerifyRequest};
2use crate::config::TracevaultConfig;
3use std::path::Path;
4use std::process::Command;
5
6fn git_repo_name(project_root: &Path) -> String {
7    Command::new("git")
8        .args(["rev-parse", "--show-toplevel"])
9        .current_dir(project_root)
10        .output()
11        .ok()
12        .filter(|o| o.status.success())
13        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
14        .as_deref()
15        .and_then(|p| p.rsplit('/').next())
16        .map(String::from)
17        .unwrap_or_else(|| "unknown".into())
18}
19
20fn expand_range(
21    project_root: &Path,
22    range: &str,
23) -> Result<Vec<String>, Box<dyn std::error::Error>> {
24    let output = Command::new("git")
25        .args(["rev-list", "--reverse", range])
26        .current_dir(project_root)
27        .output()?;
28
29    if !output.status.success() {
30        let stderr = String::from_utf8_lossy(&output.stderr);
31        return Err(format!("git rev-list failed: {stderr}").into());
32    }
33
34    let shas = String::from_utf8_lossy(&output.stdout)
35        .lines()
36        .map(|l| l.trim().to_string())
37        .filter(|l| !l.is_empty())
38        .collect();
39
40    Ok(shas)
41}
42
43pub async fn verify(
44    project_root: &Path,
45    commits: Option<&str>,
46    range: Option<&str>,
47) -> Result<(), Box<dyn std::error::Error>> {
48    let commit_list = if let Some(range) = range {
49        expand_range(project_root, range)?
50    } else if let Some(commits) = commits {
51        commits
52            .split(',')
53            .map(|s| s.trim().to_string())
54            .filter(|s| !s.is_empty())
55            .collect()
56    } else {
57        return Err("Provide either --commits or --range".into());
58    };
59
60    if commit_list.is_empty() {
61        println!("No commits to verify.");
62        return Ok(());
63    }
64
65    let (server_url, token) = resolve_credentials(project_root);
66
67    let server_url =
68        match server_url {
69            Some(url) => url,
70            None => return Err(
71                "No server URL configured. Set TRACEVAULT_SERVER_URL or run 'tracevault login'."
72                    .into(),
73            ),
74        };
75
76    if token.is_none() {
77        return Err("No auth token. Set TRACEVAULT_API_KEY or run 'tracevault login'.".into());
78    }
79
80    let org_slug = TracevaultConfig::load(project_root)
81        .and_then(|c| c.org_slug)
82        .ok_or("No org_slug in config. Run 'tracevault init' first.")?;
83
84    let client = ApiClient::new(&server_url, token.as_deref());
85
86    // Resolve repo_id by name
87    let repo_name = git_repo_name(project_root);
88    let repos = client.list_repos(&org_slug).await?;
89    let repo = repos.iter().find(|r| r.name == repo_name).ok_or_else(|| {
90        format!(
91            "Repo '{}' not found on server. Run 'tracevault sync' first.",
92            repo_name
93        )
94    })?;
95
96    println!(
97        "Verifying {} commit(s) for repo '{}'...",
98        commit_list.len(),
99        repo_name
100    );
101
102    let result = client
103        .verify_commits(
104            &org_slug,
105            &repo.id,
106            CiVerifyRequest {
107                commits: commit_list,
108            },
109        )
110        .await?;
111
112    // Print results
113    println!();
114    for r in &result.results {
115        let icon = match r.status.as_str() {
116            "pass" => "\x1b[32m✓\x1b[0m",
117            "unregistered" => "\x1b[33m?\x1b[0m",
118            "unsealed" => "\x1b[33m○\x1b[0m",
119            _ => "\x1b[31m✗\x1b[0m",
120        };
121        let sha_short = if r.commit_sha.len() > 7 {
122            &r.commit_sha[..7]
123        } else {
124            &r.commit_sha
125        };
126        println!("  {} {} — {}", icon, sha_short, r.status);
127
128        if r.registered && r.sealed {
129            let sig_icon = if r.signature_valid {
130                "\x1b[32m✓\x1b[0m"
131            } else {
132                "\x1b[31m✗\x1b[0m"
133            };
134            let chain_icon = if r.chain_valid {
135                "\x1b[32m✓\x1b[0m"
136            } else {
137                "\x1b[31m✗\x1b[0m"
138            };
139            println!("      signature: {}  chain: {}", sig_icon, chain_icon);
140        }
141
142        for p in &r.policy_results {
143            let p_icon = match p.result.as_str() {
144                "pass" => "\x1b[32m✓\x1b[0m",
145                "fail" if p.action == "block_push" => "\x1b[31m✗\x1b[0m",
146                "fail" => "\x1b[33m!\x1b[0m",
147                _ => " ",
148            };
149            println!(
150                "      {} [{}] {} — {}",
151                p_icon, p.severity, p.rule_name, p.details
152            );
153        }
154    }
155
156    // Summary
157    println!();
158    println!(
159        "Total: {}  Registered: {}  Sealed: {}  Policy passed: {}",
160        result.total_commits,
161        result.registered_commits,
162        result.sealed_commits,
163        result.policy_passed_commits
164    );
165
166    if result.status == "pass" {
167        println!("\n\x1b[32mVerification passed.\x1b[0m");
168        Ok(())
169    } else {
170        eprintln!("\n\x1b[31mVerification failed.\x1b[0m");
171        std::process::exit(1);
172    }
173}