Skip to main content

choir_cli/
backup.rs

1//! `choir backup verify` — is this copy restorable?
2//!
3//! The mirror leg reports that it *wrote* a backup. This is the only
4//! thing that reports the backup can be restored from, which is a
5//! different claim and the one that matters on the day it is needed.
6//!
7//! Every check is local. It opens no connection, reads nothing from the
8//! node, and does not care which machine it is run on — a backup you can
9//! only verify by asking the thing it is a backup of is not a backup.
10//!
11//! # Why this is not the shell script it replaces
12//!
13//! `docs/runbook-restore.md` told a reader to run `./choirctl
14//! verify-backup`, and the release ships `choir` and `choir-node` and
15//! nothing else. The restore runbook — the page reached for on the worst
16//! day — named a command that was not in the tarball.
17//!
18//! # Examples
19//!
20//! ```
21//! use choir_cli::backup::REQUIRED_POLICY;
22//!
23//! // A backup without these cannot start the node it came from.
24//! assert!(REQUIRED_POLICY.contains(&"keys"));
25//! assert!(REQUIRED_POLICY.contains(&"repos.list"));
26//! ```
27
28use crate::doctor::{Check, Status};
29use std::path::{Path, PathBuf};
30
31/// The four files every backup has, and cannot be restored without.
32pub const REQUIRED_FILES: &[&str] = &["ops.jsonl", "node.fingerprint", "policy.tar", "manifest"];
33
34/// Policy files a restored node cannot start without.
35pub const REQUIRED_POLICY: &[&str] = &["keys", "reviewers", "repos.list"];
36
37/// Policy files a node may or may not have been started with.
38///
39/// Absent, they are reported and not fatal: a node with no ACL is a node
40/// with no ACL, and a backup of it is complete without one. Reported at
41/// all because "the ACL is missing from the backup" and "there was no
42/// ACL" look identical in a restored directory, and only one of them is
43/// a disaster.
44pub 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/// Names that must never be inside a backup.
54///
55/// A backup carries the log, the policy and the git objects. It does not
56/// carry the node's key or anybody's credential, and one that does is a
57/// copy of the node's identity sitting on whatever disk the backup lives
58/// on. Fatal rather than a warning for that reason: the fix is to remove
59/// it and re-take the backup, not to note it.
60#[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
66/// Runs `program` and returns its stdout when it succeeded.
67fn 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
77/// The SHA-256 of a file, as lowercase hex.
78///
79/// `openssl`, not a crate: the manifest is written in SHA-256 by the
80/// script that takes the backup, this workspace adds dependencies
81/// reluctantly, and `openssl` is already required by `choir doctor` for
82/// every other digest and signature it handles.
83fn sha256(path: &Path) -> Option<String> {
84    let text = output(
85        "openssl",
86        &["dgst", "-sha256", "-r", &path.display().to_string()],
87    )?;
88    // `-r` is the coreutils-shaped form: `<hex> *<path>`.
89    text.split_whitespace().next().map(str::to_string)
90}
91
92/// One `key value` line out of the manifest.
93#[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
101/// The members of a tar archive, by name.
102fn 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/// Checks one backup directory.
113///
114/// `daemon` is the `choir-node` that walks the hash chain. Without one
115/// the chain check warns rather than failing: every other check here is
116/// still worth having, and a machine holding a backup is not necessarily
117/// a machine that runs nodes.
118#[must_use]
119pub fn verify(dir: &Path, daemon: Option<&Path>) -> Vec<Check> {
120    let mut checks = Vec::new();
121
122    // 1. The four files, present and not empty. A zero-byte ops.jsonl is
123    //    a backup that ran and captured nothing, which reads as success
124    //    everywhere except here.
125    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        // Everything below reads these. Stopping here says one true
145        // thing instead of eight consequences of it.
146        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    // 2. The log is the bytes the manifest says it is.
153    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    // 3. The chain, from the binary that defines it.
173    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    // 4. The attestation, if this node kept one.
195    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    // 5-7. The policy archive.
205    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    // 8. Every bundle git will actually open.
259    checks.push(bundle_check(dir));
260
261    // 9. What this backup is, so a stale one is visible as stale.
262    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
274/// A throwaway empty repository to verify bundles against.
275///
276/// `git bundle verify` refuses to run outside a repository, so without
277/// one the answer depends on where the reader happened to be standing —
278/// the check passed from a checkout and failed from a backup directory,
279/// which is the one place somebody verifying a backup actually stands.
280///
281/// Empty on purpose, and not merely as a convenience. Verifying against
282/// a repository that already holds the history proves nothing about the
283/// bundle; verifying against one that holds nothing proves the bundle
284/// records a complete history, which is exactly what a restore needs
285/// and what the shell version never checked.
286fn scratch_repo() -> Option<PathBuf> {
287    // Process id *and* a counter. The pid alone is unique between
288    // `choir` invocations and not within one, and the test harness runs
289    // every module in a single process on parallel threads — so two
290    // verifications shared a directory and one deleted it while the
291    // other was still verifying against it.
292    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
303/// Every `.bundle` under `repos/`, as git sees it.
304///
305/// A backup with no bundle is a backup of the log alone: the operations
306/// are all there and the git objects they name are not, so a restore
307/// produces a node that agrees about history and can serve none of it.
308fn 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/// Whether the whole backup is restorable.
369///
370/// A warning never makes it unrestorable — that is the difference
371/// between "this node had no ACL" and "this backup lost the ACL", and
372/// only the second is a failure.
373#[must_use]
374pub fn restorable(checks: &[Check]) -> bool {
375    Status::worst(checks) != Status::Fail
376}