Skip to main content

fraiseql_cli/commands/
validate_documents.rs

1//! `fraiseql validate-documents` — validate a trusted documents manifest.
2//!
3//! Checks:
4//! 1. The manifest JSON is well-formed
5//! 2. Each key is a valid SHA-256 hex string matching its query body
6//! 3. Exits 0 on success, 2 on validation failure
7
8use std::collections::HashMap;
9use std::path::Path;
10
11use anyhow::{Context, Result};
12use serde::Deserialize;
13use sha2::{Digest, Sha256};
14
15/// Validation result for a single document entry.
16struct EntryResult {
17    key:   String,
18    valid: bool,
19    error: Option<String>,
20}
21
22#[derive(Deserialize)]
23struct Manifest {
24    #[allow(dead_code)]
25    version:   u32,
26    documents: HashMap<String, String>,
27}
28
29/// Run the `validate-documents` command.
30pub fn run(manifest_path: &str) -> Result<bool> {
31    let path = Path::new(manifest_path);
32    let contents = std::fs::read_to_string(path)
33        .context(format!("Failed to read manifest: {manifest_path}"))?;
34
35    let manifest: Manifest = serde_json::from_str(&contents)
36        .context(format!("Failed to parse manifest JSON: {manifest_path}"))?;
37
38    let total = manifest.documents.len();
39    let mut results: Vec<EntryResult> = Vec::with_capacity(total);
40
41    for (key, body) in &manifest.documents {
42        let hash_hex = key.strip_prefix("sha256:").unwrap_or(key);
43
44        // Validate hex string length (SHA-256 = 64 hex chars)
45        if hash_hex.len() != 64 || !hash_hex.chars().all(|c| c.is_ascii_hexdigit()) {
46            results.push(EntryResult {
47                key:   key.clone(),
48                valid: false,
49                error: Some(format!(
50                    "Invalid SHA-256 hash: expected 64 hex characters, got {} chars",
51                    hash_hex.len()
52                )),
53            });
54            continue;
55        }
56
57        // Compute SHA-256 of the query body and compare
58        let computed = format!("{:x}", Sha256::digest(body.as_bytes()));
59        if computed == hash_hex {
60            results.push(EntryResult {
61                key:   key.clone(),
62                valid: true,
63                error: None,
64            });
65        } else {
66            results.push(EntryResult {
67                key:   key.clone(),
68                valid: false,
69                error: Some(format!("Hash mismatch: computed {computed}")),
70            });
71        }
72    }
73
74    let valid_count = results.iter().filter(|r| r.valid).count();
75    let error_count = results.iter().filter(|r| !r.valid).count();
76
77    // Print summary
78    println!("Trusted documents manifest: {manifest_path}");
79    println!("Total documents: {total}");
80    println!("Valid: {valid_count}");
81
82    if error_count > 0 {
83        println!("Errors: {error_count}");
84        println!();
85        for r in &results {
86            if let Some(ref err) = r.error {
87                println!("  {} — {err}", r.key);
88            }
89        }
90        Ok(false)
91    } else {
92        println!("All documents valid.");
93        Ok(true)
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    #[test]
102    fn valid_manifest_passes() {
103        let dir = tempfile::tempdir().unwrap();
104        let path = dir.path().join("manifest.json");
105
106        let query = "{ users { id } }";
107        let hash = format!("{:x}", Sha256::digest(query.as_bytes()));
108        let manifest = serde_json::json!({
109            "version": 1,
110            "documents": {
111                format!("sha256:{hash}"): query
112            }
113        });
114        std::fs::write(&path, serde_json::to_string(&manifest).unwrap()).unwrap();
115
116        let result = run(path.to_str().unwrap()).unwrap();
117        assert!(result);
118    }
119
120    #[test]
121    fn mismatched_hash_fails() {
122        let dir = tempfile::tempdir().unwrap();
123        let path = dir.path().join("manifest.json");
124
125        let manifest = serde_json::json!({
126            "version": 1,
127            "documents": {
128                "sha256:0000000000000000000000000000000000000000000000000000000000000000": "{ users { id } }"
129            }
130        });
131        std::fs::write(&path, serde_json::to_string(&manifest).unwrap()).unwrap();
132
133        let result = run(path.to_str().unwrap()).unwrap();
134        assert!(!result);
135    }
136
137    #[test]
138    fn invalid_hash_length_fails() {
139        let dir = tempfile::tempdir().unwrap();
140        let path = dir.path().join("manifest.json");
141
142        let manifest = serde_json::json!({
143            "version": 1,
144            "documents": {
145                "sha256:tooshort": "{ users { id } }"
146            }
147        });
148        std::fs::write(&path, serde_json::to_string(&manifest).unwrap()).unwrap();
149
150        let result = run(path.to_str().unwrap()).unwrap();
151        assert!(!result);
152    }
153}