1use crate::doctor::{Check, Status};
29use std::path::{Path, PathBuf};
30
31pub const REQUIRED_FILES: &[&str] = &["ops.jsonl", "node.fingerprint", "policy.tar", "manifest"];
33
34pub const REQUIRED_POLICY: &[&str] = &["keys", "reviewers", "repos.list"];
36
37pub const OPTIONAL_POLICY: &[&str] = &[
45 "protected-refs",
46 "newcomer-audit.jsonl",
47 "newcomer-adjudications.jsonl",
48 "review-adjudications.jsonl",
49 "acl",
50 "private-beta.manifest",
51];
52
53#[must_use]
61pub fn is_secret(name: &str) -> bool {
62 let base = name.rsplit('/').next().unwrap_or(name);
63 base == "auth" || base == "node.key" || base.ends_with(".key") || base.ends_with(".pem")
64}
65
66fn output(program: &str, args: &[&str]) -> Option<String> {
68 let out = std::process::Command::new(program)
69 .args(args)
70 .output()
71 .ok()?;
72 out.status
73 .success()
74 .then(|| String::from_utf8_lossy(&out.stdout).to_string())
75}
76
77fn sha256(path: &Path) -> Option<String> {
84 let text = output(
85 "openssl",
86 &["dgst", "-sha256", "-r", &path.display().to_string()],
87 )?;
88 text.split_whitespace().next().map(str::to_string)
90}
91
92#[must_use]
94pub fn manifest_value(manifest: &str, key: &str) -> Option<String> {
95 manifest.lines().find_map(|line| {
96 let (found, value) = line.trim().split_once(char::is_whitespace)?;
97 (found == key).then(|| value.trim().to_string())
98 })
99}
100
101fn tar_members(path: &Path) -> Option<Vec<String>> {
103 let text = output("tar", &["tf", &path.display().to_string()])?;
104 Some(
105 text.lines()
106 .map(|line| line.trim_start_matches("./").to_string())
107 .filter(|line| !line.is_empty())
108 .collect(),
109 )
110}
111
112#[must_use]
119pub fn verify(dir: &Path, daemon: Option<&Path>) -> Vec<Check> {
120 let mut checks = Vec::new();
121
122 let mut missing = Vec::new();
126 for name in REQUIRED_FILES {
127 let path = dir.join(name);
128 match std::fs::metadata(&path) {
129 Ok(meta) if meta.len() > 0 => {}
130 Ok(_) => missing.push(format!("{name} (empty)")),
131 Err(_) => missing.push((*name).to_string()),
132 }
133 }
134 if missing.is_empty() {
135 checks.push(Check::pass(
136 "files",
137 format!("{} present", REQUIRED_FILES.len()),
138 ));
139 } else {
140 checks.push(
141 Check::fail("files", missing.join(", "))
142 .with_fix("this is not a complete backup; take another"),
143 );
144 return checks;
147 }
148
149 let manifest = std::fs::read_to_string(dir.join("manifest")).unwrap_or_default();
150 let ops = dir.join("ops.jsonl");
151
152 match (manifest_value(&manifest, "ops_sha256"), sha256(&ops)) {
154 (Some(want), Some(got)) if want == got => {
155 checks.push(Check::pass("checksum", format!("ops.jsonl {}", &got[..16])));
156 }
157 (Some(want), Some(got)) => checks.push(
158 Check::fail(
159 "checksum",
160 format!(
161 "manifest says {}, file is {}",
162 &want[..16.min(want.len())],
163 &got[..16]
164 ),
165 )
166 .with_fix("the log was modified or truncated after it was written"),
167 ),
168 (None, _) => checks.push(Check::fail("checksum", "no ops_sha256 in the manifest")),
169 (_, None) => checks.push(Check::warn("checksum", "openssl could not read the log")),
170 }
171
172 match daemon {
174 Some(daemon) => {
175 let ok = std::process::Command::new(daemon)
176 .args(["--verify-log", &ops.display().to_string()])
177 .output()
178 .is_ok_and(|out| out.status.success());
179 if ok {
180 checks.push(Check::pass("chain", "format, sequence and hashes verify"));
181 } else {
182 checks.push(
183 Check::fail("chain", "choir-node --verify-log refused this log")
184 .with_fix("choir repair <log> --verify says where it breaks"),
185 );
186 }
187 }
188 None => checks.push(
189 Check::warn("chain", "no choir-node to walk it with")
190 .with_fix("cargo build --release -p choir-node"),
191 ),
192 }
193
194 if dir.join("refs.snapshot").is_file() {
196 checks.push(Check::pass("attestation", "refs.snapshot present"));
197 } else {
198 checks.push(Check::warn(
199 "attestation",
200 "no refs.snapshot — a restore cannot check the refs it serves against one",
201 ));
202 }
203
204 match tar_members(&dir.join("policy.tar")) {
206 None => checks.push(Check::fail("policy", "policy.tar cannot be listed")),
207 Some(members) => {
208 let held = |name: &str| members.iter().any(|m| m == name);
209 let absent: Vec<&str> = REQUIRED_POLICY
210 .iter()
211 .copied()
212 .filter(|n| !held(n))
213 .collect();
214 if absent.is_empty() {
215 checks.push(Check::pass("policy", format!("{} files", members.len())));
216 } else {
217 checks.push(
218 Check::fail("policy", format!("missing {}", absent.join(", ")))
219 .with_fix("a node restored without these cannot start"),
220 );
221 }
222 let optional: Vec<&str> = OPTIONAL_POLICY
223 .iter()
224 .copied()
225 .filter(|n| !held(n))
226 .collect();
227 if !optional.is_empty() {
228 checks.push(Check::warn(
229 "policy (optional)",
230 format!(
231 "not in this backup: {} — either the node had none, or they were lost",
232 optional.join(", ")
233 ),
234 ));
235 }
236 let leaked: Vec<&String> = members.iter().filter(|m| is_secret(m)).collect();
237 if leaked.is_empty() {
238 checks.push(Check::pass("secrets", "no key or credential in the backup"));
239 } else {
240 checks.push(
241 Check::fail(
242 "secrets",
243 format!(
244 "{} in policy.tar",
245 leaked
246 .iter()
247 .map(|s| s.as_str())
248 .collect::<Vec<_>>()
249 .join(", ")
250 ),
251 )
252 .with_fix("remove it and take the backup again; this copy holds an identity"),
253 );
254 }
255 }
256 }
257
258 checks.push(bundle_check(dir));
260
261 let age = std::fs::metadata(dir.join("ops.jsonl"))
263 .and_then(|m| m.modified())
264 .ok()
265 .and_then(|t| t.elapsed().ok())
266 .map(|d| format!("{} hours old", d.as_secs() / 3600))
267 .unwrap_or_else(|| "age unknown".to_string());
268 let seq = manifest_value(&manifest, "next_seq").unwrap_or_else(|| "?".to_string());
269 checks.push(Check::pass("taken", format!("{age}, next seq {seq}")));
270
271 checks
272}
273
274fn scratch_repo() -> Option<PathBuf> {
287 static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
293 let at = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
294 let dir = std::env::temp_dir().join(format!("choir-bundle-check-{}-{at}", std::process::id()));
295 std::fs::create_dir_all(&dir).ok()?;
296 let ok = std::process::Command::new("git")
297 .args(["init", "--bare", "-q", &dir.display().to_string()])
298 .output()
299 .is_ok_and(|out| out.status.success());
300 ok.then_some(dir)
301}
302
303fn bundle_check(dir: &Path) -> Check {
309 let repos = dir.join("repos");
310 let mut bundles: Vec<PathBuf> = Vec::new();
311 let mut stack = vec![repos];
312 while let Some(at) = stack.pop() {
313 let Ok(entries) = std::fs::read_dir(&at) else {
314 continue;
315 };
316 for entry in entries.flatten() {
317 let path = entry.path();
318 if path.is_dir() {
319 stack.push(path);
320 } else if path.extension().is_some_and(|e| e == "bundle") {
321 bundles.push(path);
322 }
323 }
324 }
325 if bundles.is_empty() {
326 return Check::fail("bundles", "no git bundle in repos/")
327 .with_fix("the log would restore, but every repository would be empty");
328 }
329 let Some(scratch) = scratch_repo() else {
330 return Check::warn(
331 "bundles",
332 format!("{} found, git could not check them", bundles.len()),
333 );
334 };
335 let bad: Vec<String> = bundles
336 .iter()
337 .filter(|path| {
338 !std::process::Command::new("git")
339 .args([
340 "--git-dir",
341 &scratch.display().to_string(),
342 "bundle",
343 "verify",
344 &path.display().to_string(),
345 ])
346 .output()
347 .is_ok_and(|out| out.status.success())
348 })
349 .map(|path| {
350 path.file_name()
351 .unwrap_or_default()
352 .to_string_lossy()
353 .to_string()
354 })
355 .collect();
356 std::fs::remove_dir_all(&scratch).ok();
357 if bad.is_empty() {
358 Check::pass(
359 "bundles",
360 format!("{} verify, complete history", bundles.len()),
361 )
362 } else {
363 Check::fail("bundles", format!("git refuses {}", bad.join(", ")))
364 .with_fix("an incomplete bundle restores a repository missing its own history")
365 }
366}
367
368#[must_use]
374pub fn restorable(checks: &[Check]) -> bool {
375 Status::worst(checks) != Status::Fail
376}