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, path::Path};
9
10use anyhow::{Context, Result};
11use serde::Deserialize;
12use sha2::{Digest, Sha256};
13
14use crate::output::OutputFormatter;
15
16/// Validation result for a single document entry.
17struct EntryResult {
18    key:   String,
19    valid: bool,
20    error: Option<String>,
21}
22
23const SUPPORTED_MANIFEST_VERSION: u32 = 1;
24
25/// Maximum manifest file size accepted (10 MiB).
26///
27/// Manifests larger than this limit are rejected before reading into memory to
28/// prevent trivial OOM attacks via a crafted large file.
29const MAX_MANIFEST_BYTES: u64 = 10 * 1024 * 1024;
30
31#[derive(Deserialize)]
32struct Manifest {
33    version:   u32,
34    documents: HashMap<String, String>,
35}
36
37/// Run the `validate-documents` command.
38///
39/// # Errors
40///
41/// Returns an error if the manifest file cannot be read, exceeds the 10 MiB size
42/// limit, cannot be parsed as JSON, or specifies an unsupported manifest version.
43pub fn run(manifest_path: &str, formatter: &OutputFormatter) -> Result<bool> {
44    let path = Path::new(manifest_path);
45
46    // Reject oversized files before reading into memory.
47    let metadata =
48        std::fs::metadata(path).context(format!("Failed to read manifest: {manifest_path}"))?;
49    if metadata.len() > MAX_MANIFEST_BYTES {
50        anyhow::bail!(
51            "Manifest file {manifest_path} is too large ({} bytes); \
52             the maximum accepted size is {} bytes (10 MiB)",
53            metadata.len(),
54            MAX_MANIFEST_BYTES,
55        );
56    }
57
58    let contents = std::fs::read_to_string(path)
59        .context(format!("Failed to read manifest: {manifest_path}"))?;
60
61    let manifest: Manifest = serde_json::from_str(&contents)
62        .context(format!("Failed to parse manifest JSON: {manifest_path}"))?;
63
64    if manifest.version != SUPPORTED_MANIFEST_VERSION {
65        anyhow::bail!(
66            "Unsupported manifest version {}; this version of fraiseql-cli supports version {}",
67            manifest.version,
68            SUPPORTED_MANIFEST_VERSION,
69        );
70    }
71
72    let total = manifest.documents.len();
73    let mut results: Vec<EntryResult> = Vec::with_capacity(total);
74
75    for (key, body) in &manifest.documents {
76        let hash_hex = key.strip_prefix("sha256:").unwrap_or(key);
77
78        // Validate hex string length (SHA-256 = 64 hex chars)
79        if hash_hex.len() != 64 || !hash_hex.chars().all(|c| c.is_ascii_hexdigit()) {
80            results.push(EntryResult {
81                key:   key.clone(),
82                valid: false,
83                error: Some(format!(
84                    "Invalid SHA-256 hash: expected 64 hex characters, got {} chars",
85                    hash_hex.len()
86                )),
87            });
88            continue;
89        }
90
91        // Compute SHA-256 of the query body and compare
92        let computed = format!("{:x}", Sha256::digest(body.as_bytes()));
93        if computed == hash_hex {
94            results.push(EntryResult {
95                key:   key.clone(),
96                valid: true,
97                error: None,
98            });
99        } else {
100            results.push(EntryResult {
101                key:   key.clone(),
102                valid: false,
103                error: Some(format!("Hash mismatch: computed {computed}")),
104            });
105        }
106    }
107
108    let valid_count = results.iter().filter(|r| r.valid).count();
109    let error_count = results.iter().filter(|r| !r.valid).count();
110
111    // Print summary
112    formatter.progress(&format!("Trusted documents manifest: {manifest_path}"));
113    formatter.progress(&format!("Total documents: {total}"));
114    formatter.progress(&format!("Valid: {valid_count}"));
115
116    if error_count > 0 {
117        formatter.progress(&format!("Errors: {error_count}"));
118        formatter.progress("");
119        for r in &results {
120            if let Some(ref err) = r.error {
121                formatter.progress(&format!("  {} - {err}", r.key));
122            }
123        }
124        Ok(false)
125    } else {
126        formatter.progress("All documents valid.");
127        Ok(true)
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    #![allow(clippy::unwrap_used)] // Reason: test code, panics acceptable
134
135    use super::*;
136    use crate::output::OutputFormatter;
137
138    #[test]
139    fn test_rejects_manifest_exceeding_size_limit() {
140        let dir = tempfile::tempdir().unwrap();
141        let path = dir.path().join("big.json");
142
143        // Write a file of MAX_MANIFEST_BYTES + 1 bytes (just over the limit).
144        let size = usize::try_from(MAX_MANIFEST_BYTES).unwrap() + 1;
145        std::fs::write(&path, vec![b'x'; size]).unwrap();
146
147        let formatter = OutputFormatter::new(false, false);
148        let result = run(path.to_str().unwrap(), &formatter);
149        let msg = result.expect_err("expected Err for oversized manifest").to_string();
150        assert!(msg.contains("too large"), "expected size error, got: {msg}");
151    }
152
153    #[test]
154    fn test_rejects_unknown_version() {
155        let dir = tempfile::tempdir().unwrap();
156        let path = dir.path().join("manifest.json");
157
158        let manifest = serde_json::json!({
159            "version": 99,
160            "documents": {}
161        });
162        std::fs::write(&path, serde_json::to_string(&manifest).unwrap()).unwrap();
163
164        let formatter = OutputFormatter::new(false, false);
165        let result = run(path.to_str().unwrap(), &formatter);
166        let msg = result.expect_err("expected Err for unknown manifest version").to_string();
167        assert!(
168            msg.contains("Unsupported manifest version"),
169            "expected version error, got: {msg}"
170        );
171    }
172
173    #[test]
174    fn valid_manifest_passes() {
175        let dir = tempfile::tempdir().unwrap();
176        let path = dir.path().join("manifest.json");
177
178        let query = "{ users { id } }";
179        let hash = format!("{:x}", Sha256::digest(query.as_bytes()));
180        let manifest = serde_json::json!({
181            "version": 1,
182            "documents": {
183                format!("sha256:{hash}"): query
184            }
185        });
186        std::fs::write(&path, serde_json::to_string(&manifest).unwrap()).unwrap();
187
188        let formatter = OutputFormatter::new(false, false);
189        let result = run(path.to_str().unwrap(), &formatter).unwrap();
190        assert!(result);
191    }
192
193    #[test]
194    fn mismatched_hash_fails() {
195        let dir = tempfile::tempdir().unwrap();
196        let path = dir.path().join("manifest.json");
197
198        let manifest = serde_json::json!({
199            "version": 1,
200            "documents": {
201                "sha256:0000000000000000000000000000000000000000000000000000000000000000": "{ users { id } }"
202            }
203        });
204        std::fs::write(&path, serde_json::to_string(&manifest).unwrap()).unwrap();
205
206        let formatter = OutputFormatter::new(false, false);
207        let result = run(path.to_str().unwrap(), &formatter).unwrap();
208        assert!(!result);
209    }
210
211    #[test]
212    fn invalid_hash_length_fails() {
213        let dir = tempfile::tempdir().unwrap();
214        let path = dir.path().join("manifest.json");
215
216        let manifest = serde_json::json!({
217            "version": 1,
218            "documents": {
219                "sha256:tooshort": "{ users { id } }"
220            }
221        });
222        std::fs::write(&path, serde_json::to_string(&manifest).unwrap()).unwrap();
223
224        let formatter = OutputFormatter::new(false, false);
225        let result = run(path.to_str().unwrap(), &formatter).unwrap();
226        assert!(!result);
227    }
228}