fraiseql_cli/commands/
validate_documents.rs1use std::{collections::HashMap, path::Path};
9
10use anyhow::{Context, Result};
11use serde::Deserialize;
12use sha2::{Digest, Sha256};
13
14use crate::output::OutputFormatter;
15
16struct EntryResult {
18 key: String,
19 valid: bool,
20 error: Option<String>,
21}
22
23const SUPPORTED_MANIFEST_VERSION: u32 = 1;
24
25const MAX_MANIFEST_BYTES: u64 = 10 * 1024 * 1024;
30
31#[derive(Deserialize)]
32struct Manifest {
33 version: u32,
34 documents: HashMap<String, String>,
35}
36
37pub fn run(manifest_path: &str, formatter: &OutputFormatter) -> Result<bool> {
44 let path = Path::new(manifest_path);
45
46 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 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 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 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)] 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 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}