fraiseql_cli/commands/
validate_documents.rs1use std::collections::HashMap;
9use std::path::Path;
10
11use anyhow::{Context, Result};
12use serde::Deserialize;
13use sha2::{Digest, Sha256};
14
15struct 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
29pub 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 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 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 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}