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}
377
378// ---------------------------------------------------------------- taking one
379
380/// Every policy file a backup carries, required and optional.
381fn policy_names() -> Vec<&'static str> {
382    REQUIRED_POLICY
383        .iter()
384        .chain(OPTIONAL_POLICY.iter())
385        .copied()
386        .collect()
387}
388
389/// The SHA-256 of some bytes, as lowercase hex, through `openssl`'s stdin.
390fn 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/// `YYYYMMDDTHHMMSSZ`, now, from the system clock and arithmetic.
411///
412/// No date crate: the manifest wants one sortable stamp, and the civil
413/// date from a day count is twenty lines that have not changed since
414/// 1582.
415#[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    // Howard Hinnant's civil_from_days.
425    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/// What `take` did, for the caller to print.
439#[derive(Debug, Default)]
440pub struct Taken {
441    /// Where the backup now is.
442    pub dest: PathBuf,
443    /// Entries in the log copied.
444    pub ops: usize,
445    /// The position the log will fill next.
446    pub next_seq: u64,
447    /// Bytes the log grew by since the previous backup here, if there was one.
448    pub grew: Option<(u64, u64)>,
449    /// One line per repository: kept unchanged, or bundled afresh.
450    pub bundles: Vec<String>,
451    /// Things worth a line that did not stop the backup.
452    pub warnings: Vec<String>,
453}
454
455/// Runs git against one bare repository and returns its stdout.
456fn 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
466/// Takes a backup of the node whose state directory is `layout`, into
467/// `dest`, in the shape [`verify`] reads and `choir backup restore`
468/// unpacks.
469///
470/// The same checks the flip-era pull script made, on the node's own
471/// disk: the copied log must be a prefix-extension of the one already
472/// held here, contiguous from seq 0, and pass `choir-node --verify-log`
473/// when a daemon is there to run it; the policy archive must carry no
474/// key or credential; every bundle must verify. Everything is written
475/// into a sibling directory first and moved into place last, so a run
476/// cut short leaves the previous backup as it was.
477///
478/// Live is fine: the log is append-only, and a line the daemon was
479/// still writing fails the contiguity check and is refused, which is
480/// the right answer for a copy taken a moment too early.
481///
482/// # Errors
483///
484/// Returns the sentence to print when the node's files are missing, the
485/// log shrank or diverged from the backup already here, a secret is
486/// among the policy files, or a subprocess refused.
487pub 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    // Removed on every exit from here, success included: on success the
514    // files have been moved out of it.
515    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    // 1. The log, and whether it extends what is held here.
528    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    // 2. Contiguous from 0, every line a record with a seq.
549    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    // 3. The chain, from the binary that defines it.
576    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    // 4. Identity and attestation.
595    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    // 5. Policy, and nothing that is not policy.
613    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    // 6. One bundle per repository, kept when its refs have not moved.
649    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    // 7. The manifest, then everything into place, the log last so a
696    //    backup is never a new manifest over an old log.
697    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}