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}
377
378fn policy_names() -> Vec<&'static str> {
382 REQUIRED_POLICY
383 .iter()
384 .chain(OPTIONAL_POLICY.iter())
385 .copied()
386 .collect()
387}
388
389fn sha256_bytes(bytes: &[u8]) -> Option<String> {
391 use std::io::Write;
392 let mut child = std::process::Command::new("openssl")
393 .args(["dgst", "-sha256", "-r"])
394 .stdin(std::process::Stdio::piped())
395 .stdout(std::process::Stdio::piped())
396 .stderr(std::process::Stdio::null())
397 .spawn()
398 .ok()?;
399 child.stdin.take()?.write_all(bytes).ok()?;
400 let out = child.wait_with_output().ok()?;
401 if !out.status.success() {
402 return None;
403 }
404 String::from_utf8_lossy(&out.stdout)
405 .split_whitespace()
406 .next()
407 .map(str::to_string)
408}
409
410#[must_use]
416pub fn utc_stamp() -> String {
417 let secs = std::time::SystemTime::now()
418 .duration_since(std::time::UNIX_EPOCH)
419 .map(|d| d.as_secs())
420 .unwrap_or(0);
421 let days = i64::try_from(secs / 86_400).unwrap_or(0);
422 let rem = secs % 86_400;
423 let (hh, mm, ss) = (rem / 3600, (rem % 3600) / 60, rem % 60);
424 let z = days + 719_468;
426 let era = z.div_euclid(146_097);
427 let doe = z.rem_euclid(146_097);
428 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
429 let y = yoe + era * 400;
430 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
431 let mp = (5 * doy + 2) / 153;
432 let d = doy - (153 * mp + 2) / 5 + 1;
433 let m = if mp < 10 { mp + 3 } else { mp - 9 };
434 let y = if m <= 2 { y + 1 } else { y };
435 format!("{y:04}{m:02}{d:02}T{hh:02}{mm:02}{ss:02}Z")
436}
437
438#[derive(Debug, Default)]
440pub struct Taken {
441 pub dest: PathBuf,
443 pub ops: usize,
445 pub next_seq: u64,
447 pub grew: Option<(u64, u64)>,
449 pub bundles: Vec<String>,
451 pub warnings: Vec<String>,
453}
454
455fn git_in(bare: &Path, args: &[&str]) -> Option<Vec<u8>> {
457 let out = std::process::Command::new("git")
458 .arg("--git-dir")
459 .arg(bare)
460 .args(args)
461 .output()
462 .ok()?;
463 out.status.success().then_some(out.stdout)
464}
465
466pub fn take(
488 layout: &crate::serve::Layout,
489 dest: &Path,
490 daemon: Option<&Path>,
491) -> Result<Taken, String> {
492 let node_state = layout.repos.join(".choir");
493 let ops_src = node_state.join("ops.jsonl");
494 let fingerprint_src = node_state.join("node.fingerprint");
495 for (what, path) in [("op log", &ops_src), ("node fingerprint", &fingerprint_src)] {
496 match std::fs::metadata(path) {
497 Ok(meta) if meta.len() > 0 => {}
498 Ok(_) => return Err(format!("the {what} at {} is empty", path.display())),
499 Err(_) => {
500 return Err(format!(
501 "no {what} at {}\n\n is {} a node's state directory? choir node status",
502 path.display(),
503 layout.state.display()
504 ))
505 }
506 }
507 }
508
509 let stamp = utc_stamp();
510 let incoming = dest.join(format!(".incoming-{stamp}"));
511 std::fs::create_dir_all(incoming.join("repos"))
512 .map_err(|e| format!("create {}: {e}", incoming.display()))?;
513 struct Cleanup(PathBuf);
516 impl Drop for Cleanup {
517 fn drop(&mut self) {
518 std::fs::remove_dir_all(&self.0).ok();
519 }
520 }
521 let _cleanup = Cleanup(incoming.clone());
522 let mut taken = Taken {
523 dest: dest.to_path_buf(),
524 ..Taken::default()
525 };
526
527 let ops = std::fs::read(&ops_src).map_err(|e| format!("read {}: {e}", ops_src.display()))?;
529 let held = std::fs::read(dest.join("ops.jsonl")).ok();
530 if let Some(held) = &held {
531 if ops.len() < held.len() {
532 return Err(format!(
533 "the node's log is SHORTER than the copy held here: held {} bytes, node has {}\n \
534 the log was rewritten, not appended to; keeping the existing backup",
535 held.len(),
536 ops.len()
537 ));
538 }
539 if &ops[..held.len()] != held.as_slice() {
540 return Err(
541 "the copy held here is NOT a prefix of the node's log: the history diverged\n \
542 keeping the existing backup and refusing this one"
543 .to_string(),
544 );
545 }
546 taken.grew = Some((held.len() as u64, ops.len() as u64));
547 }
548 let mut expected = 0u64;
550 for (at, line) in ops.split(|b| *b == b'\n').enumerate() {
551 if line.is_empty() {
552 continue;
553 }
554 let seq = serde_json::from_slice::<serde_json::Value>(line)
555 .ok()
556 .and_then(|v| v["seq"].as_u64())
557 .ok_or_else(|| {
558 format!(
559 "line {} of the log carries no seq; taken mid-write? try again",
560 at + 1
561 )
562 })?;
563 if seq != expected {
564 return Err(format!(
565 "the log is not contiguous: seq {expected} expected, {seq} found"
566 ));
567 }
568 expected += 1;
569 taken.ops += 1;
570 }
571 taken.next_seq = expected;
572 let ops_copy = incoming.join("ops.jsonl");
573 std::fs::write(&ops_copy, &ops).map_err(|e| format!("write {}: {e}", ops_copy.display()))?;
574 let ops_sha = sha256(&ops_copy).ok_or("openssl could not digest the log")?;
575 match daemon {
577 Some(daemon) => {
578 let out = std::process::Command::new(daemon)
579 .args(["--verify-log", &ops_copy.display().to_string()])
580 .output()
581 .map_err(|e| format!("run {}: {e}", daemon.display()))?;
582 if !out.status.success() {
583 return Err(format!(
584 "choir-node --verify-log refused the log:\n {}",
585 String::from_utf8_lossy(&out.stderr).trim()
586 ));
587 }
588 }
589 None => taken
590 .warnings
591 .push("chain unverified: no choir-node beside choir to walk it with".to_string()),
592 }
593
594 std::fs::copy(&fingerprint_src, incoming.join("node.fingerprint"))
596 .map_err(|e| format!("copy node.fingerprint: {e}"))?;
597 let snapshot_src = node_state.join("refs.snapshot");
598 let has_snapshot = match std::fs::metadata(&snapshot_src) {
599 Ok(meta) if meta.len() > 0 => {
600 std::fs::copy(&snapshot_src, incoming.join("refs.snapshot"))
601 .map_err(|e| format!("copy refs.snapshot: {e}"))?;
602 true
603 }
604 _ => {
605 taken.warnings.push(
606 "no attestation on the node: a restore from this backup cannot check the view it replays into".to_string(),
607 );
608 false
609 }
610 };
611
612 let present: Vec<&str> = policy_names()
614 .into_iter()
615 .filter(|name| layout.state.join(name).exists())
616 .collect();
617 if present.is_empty() {
618 return Err(format!(
619 "no policy files in {}: nothing to ship",
620 layout.state.display()
621 ));
622 }
623 for name in policy_names() {
624 if !present.contains(&name) {
625 taken.warnings.push(format!(
626 "no {name} on the node: a restore starts without it"
627 ));
628 }
629 }
630 let leaked: Vec<&&str> = present.iter().filter(|n| is_secret(n)).collect();
631 if !leaked.is_empty() {
632 return Err(format!("refusing: a policy name is a secret: {leaked:?}"));
633 }
634 let policy_tar = incoming.join("policy.tar");
635 let ok = std::process::Command::new("tar")
636 .arg("cf")
637 .arg(&policy_tar)
638 .arg("-C")
639 .arg(&layout.state)
640 .args(&present)
641 .status()
642 .map_err(|e| format!("run tar: {e}"))?
643 .success();
644 if !ok {
645 return Err("tar could not build the policy archive".to_string());
646 }
647
648 let listed = std::fs::read_to_string(layout.state.join("repos.list")).unwrap_or_default();
650 let repos: Vec<&str> = listed
651 .lines()
652 .map(str::trim)
653 .filter(|l| !l.is_empty() && !l.starts_with('#'))
654 .collect();
655 for repo in &repos {
656 let bare = layout.repos.join(repo);
657 let name = Path::new(repo)
658 .file_name()
659 .map(|n| n.to_string_lossy().trim_end_matches(".git").to_string())
660 .unwrap_or_else(|| (*repo).to_string());
661 let refs = git_in(&bare, &["show-ref"]).unwrap_or_default();
662 let refs_hash = sha256_bytes(&refs).ok_or("openssl could not digest the refs")?;
663 let held_hash = std::fs::read_to_string(dest.join("repos").join(format!("{name}.refs")))
664 .map(|s| s.trim().to_string())
665 .unwrap_or_default();
666 let held_bundle = dest.join("repos").join(format!("{name}.bundle"));
667 let bundle = incoming.join("repos").join(format!("{name}.bundle"));
668 if held_hash == refs_hash && held_bundle.is_file() {
669 std::fs::copy(&held_bundle, &bundle).map_err(|e| format!("keep {name}.bundle: {e}"))?;
670 taken.bundles.push(format!("{name}: unchanged, kept"));
671 } else {
672 if git_in(
673 &bare,
674 &["bundle", "create", &bundle.display().to_string(), "--all"],
675 )
676 .is_none()
677 {
678 return Err(format!("git could not bundle {repo} at {}", bare.display()));
679 }
680 let size = std::fs::metadata(&bundle).map(|m| m.len()).unwrap_or(0);
681 taken.bundles.push(format!("{name}: bundled, {size} bytes"));
682 }
683 std::fs::write(
684 incoming.join("repos").join(format!("{name}.refs")),
685 format!("{refs_hash}\n"),
686 )
687 .map_err(|e| format!("write {name}.refs: {e}"))?;
688 }
689 if repos.is_empty() {
690 taken
691 .warnings
692 .push("repos.list names no repository: no bundle taken".to_string());
693 }
694
695 std::fs::write(
698 incoming.join("manifest"),
699 format!(
700 "format_version 1\npulled_at {stamp}\nops_sha256 {ops_sha}\nops_bytes {}\nnext_seq {}\nrepos {}\n",
701 ops.len(),
702 taken.next_seq,
703 repos.len()
704 ),
705 )
706 .map_err(|e| format!("write manifest: {e}"))?;
707 std::fs::create_dir_all(dest.join("repos"))
708 .map_err(|e| format!("create {}: {e}", dest.display()))?;
709 for entry in
710 std::fs::read_dir(incoming.join("repos")).map_err(|e| format!("list bundles: {e}"))?
711 {
712 let entry = entry.map_err(|e| format!("list bundles: {e}"))?;
713 std::fs::rename(entry.path(), dest.join("repos").join(entry.file_name()))
714 .map_err(|e| format!("move {}: {e}", entry.path().display()))?;
715 }
716 for name in ["node.fingerprint", "policy.tar", "manifest", "ops.jsonl"] {
717 std::fs::rename(incoming.join(name), dest.join(name))
718 .map_err(|e| format!("move {name}: {e}"))?;
719 }
720 if has_snapshot {
721 std::fs::rename(incoming.join("refs.snapshot"), dest.join("refs.snapshot"))
722 .map_err(|e| format!("move refs.snapshot: {e}"))?;
723 } else {
724 std::fs::remove_file(dest.join("refs.snapshot")).ok();
725 }
726 #[cfg(unix)]
727 {
728 use std::os::unix::fs::PermissionsExt;
729 std::fs::set_permissions(dest, std::fs::Permissions::from_mode(0o700)).ok();
730 }
731 Ok(taken)
732}