Skip to main content

choir_cli/
restore.rs

1//! `choir backup restore` — turn a backup back into a node, and refuse
2//! to say it worked until the restored node has accepted a write.
3//!
4//! The other half of a backup leg. That one proves a copy arrived; this
5//! proves the copy is a node. A backup nobody has restored is a
6//! hypothesis, and the only thing that settles it is a running daemon
7//! appending to the log it was handed.
8//!
9//! # Nothing here mints a secret
10//!
11//! Backups exclude them by design, so a restore has a hole in it only a
12//! person can fill: the daemon's signing key, the credential, and any
13//! TLS material. This stops and names them rather than inventing
14//! replacements, because a minted node key is a *new node* wearing the
15//! old node's log.
16//!
17//! # The ordering is the safety property
18//!
19//! Read, refuse, then write. A restore that fails halfway has already
20//! destroyed the thing an operator would fall back to, so everything
21//! checkable about the backup is checked before a byte reaches the
22//! target — and the policy archive is unpacked into a work directory
23//! rather than into the node's root for the same reason.
24//!
25//! # Examples
26//!
27//! ```
28//! use choir_cli::restore::Refusal;
29//!
30//! // Exit 3 is its own code: an operator decision, not a failed check.
31//! let decision = Refusal::decide("the signing key is not here");
32//! assert_eq!(decision.code, 3);
33//! ```
34
35use std::path::{Path, PathBuf};
36
37/// Why a restore stopped.
38///
39/// `code` is the process exit code, and the three are genuinely
40/// different outcomes: 1 is "this backup or this target is wrong", 3 is
41/// "the backup is fine and something only you can supply is missing".
42/// Collapsing them would make the documented recovery path — stop,
43/// supply the key, re-run — indistinguishable from a corrupt backup.
44#[derive(Debug)]
45pub struct Refusal {
46    /// Process exit code: 1 a check failed, 2 usage, 3 a decision.
47    pub code: i32,
48    /// What to print, already addressed to a person.
49    pub message: String,
50}
51
52impl Refusal {
53    /// A check failed: exit 1.
54    #[must_use]
55    pub fn fail(message: impl Into<String>) -> Refusal {
56        Refusal {
57            code: 1,
58            message: message.into(),
59        }
60    }
61
62    /// An operator decision is required: exit 3.
63    #[must_use]
64    pub fn decide(message: impl Into<String>) -> Refusal {
65        Refusal {
66            code: 3,
67            message: message.into(),
68        }
69    }
70}
71
72/// What the backup turned out to hold, after every read-side check.
73#[derive(Debug)]
74pub struct Backup {
75    /// The backup directory.
76    pub src: PathBuf,
77    /// Where the policy files are readable from — the backup's own
78    /// `policy/`, or a work directory the tar was unpacked into.
79    pub policy: PathBuf,
80    /// Repository names from `repos.list`, in file order.
81    pub repos: Vec<String>,
82    /// Lines in the backed-up log, which is also the seq the canary
83    /// will land at.
84    pub ops: usize,
85    /// Bytes in the backed-up log, for the prefix check at the end.
86    pub bytes: u64,
87}
88
89/// What a completed restore proved.
90#[derive(Debug)]
91pub struct Restored {
92    /// Operations replayed.
93    pub ops: usize,
94    /// Repositories unbundled.
95    pub repos: usize,
96    /// The canary ref, left in place as evidence.
97    pub canary: String,
98    /// The repository it landed in.
99    pub landed_in: String,
100}
101
102/// Policy a node cannot boot or serve restored refs without.
103pub const REQUIRED: &[&str] = &["keys", "reviewers", "repos.list"];
104
105/// Policy whose absence changes what the restored node enforces.
106///
107/// Named rather than refused: a restore that demanded all of them would
108/// refuse every backup from a node that protects no ref, and one that
109/// stayed quiet would hand back a node whose policy is weaker than the
110/// one it replaced.
111pub const OPTIONAL: &[&str] = &[
112    "protected-refs",
113    "newcomer-audit.jsonl",
114    "newcomer-adjudications.jsonl",
115    "review-adjudications.jsonl",
116    "acl",
117    "private-beta.manifest",
118];
119
120/// Reads and checks everything about the backup, writing nothing.
121///
122/// # Errors
123///
124/// Refuses, with exit code 1, on any of: no log, an empty log, a log the
125/// daemon will not verify, no policy in either shape, a missing required
126/// policy file, a secret anywhere in the backup, a `repos.list` naming
127/// nothing, or a repository with no bundle.
128pub fn read(src: &Path, work: &Path, daemon: &Path) -> Result<Backup, Refusal> {
129    let ops_path = src.join("ops.jsonl");
130    let text = std::fs::read_to_string(&ops_path).map_err(|_| {
131        Refusal::fail(format!(
132            "no ops.jsonl in {}: that is not a backup",
133            src.display()
134        ))
135    })?;
136    let ops = text.lines().count();
137    if ops == 0 {
138        return Err(Refusal::fail("the backed-up log is empty"));
139    }
140    let bytes = text.len() as u64;
141
142    // Before a byte reaches the target: unsupported formats, sequence
143    // gaps, broken parent links, recomputed-hash mismatches and torn
144    // tails, all rejected by the same code the daemon ships.
145    let verified = std::process::Command::new(daemon)
146        .args(["--verify-log", &ops_path.display().to_string()])
147        .output()
148        .is_ok_and(|out| out.status.success());
149    if !verified {
150        return Err(Refusal::fail(
151            "the backup failed format/sequence/parent/hash verification",
152        ));
153    }
154
155    // Either shape of the same thing. The tar goes to the work
156    // directory, so a backup that fails a check below has still written
157    // nothing where a node would read it.
158    let policy = if src.join("policy").is_dir() {
159        src.join("policy")
160    } else if src.join("policy.tar").is_file() {
161        let into = work.join("policy");
162        std::fs::create_dir_all(&into)
163            .map_err(|e| Refusal::fail(format!("create {}: {e}", into.display())))?;
164        let ok = std::process::Command::new("tar")
165            .arg("-xf")
166            .arg(src.join("policy.tar"))
167            .arg("-C")
168            .arg(&into)
169            .output()
170            .is_ok_and(|out| out.status.success());
171        if !ok {
172            return Err(Refusal::fail(format!(
173                "could not read {}",
174                src.join("policy.tar").display()
175            )));
176        }
177        into
178    } else {
179        return Err(Refusal::fail(format!(
180            "no policy in {} (neither policy/ nor policy.tar): a node restored \
181             without reviewers does not boot",
182            src.display()
183        )));
184    };
185
186    let missing: Vec<&str> = REQUIRED
187        .iter()
188        .copied()
189        .filter(|f| !policy.join(f).is_file())
190        .collect();
191    if !missing.is_empty() {
192        return Err(Refusal::fail(format!(
193            "policy files missing from the backup: {} (a node restored without \
194             reviewers does not boot)",
195            missing.join(" ")
196        )));
197    }
198
199    // The same assertion a backup leg makes about its own output, made
200    // again here about its input: it holds whoever put the file there.
201    let mut leaked = Vec::new();
202    for dir in [src, policy.as_path()] {
203        if let Ok(entries) = std::fs::read_dir(dir) {
204            for entry in entries.flatten() {
205                let name = entry.file_name().to_string_lossy().to_string();
206                if crate::backup::is_secret(&name) {
207                    leaked.push(name);
208                }
209            }
210        }
211    }
212    if !leaked.is_empty() {
213        leaked.sort();
214        leaked.dedup();
215        return Err(Refusal::fail(format!(
216            "SECRETS IN THE BACKUP: {} (a backup holding a token or key is a \
217             credential channel, and this restore will not spread it)",
218            leaked.join(" ")
219        )));
220    }
221
222    let listed = std::fs::read_to_string(policy.join("repos.list")).unwrap_or_default();
223    let repos: Vec<String> = listed
224        .lines()
225        .map(str::trim)
226        .filter(|line| !line.is_empty() && !line.starts_with('#'))
227        .map(str::to_string)
228        .collect();
229    if repos.is_empty() {
230        return Err(Refusal::fail(
231            "repos.list names no repositories: there is nothing to serve",
232        ));
233    }
234    for repo in &repos {
235        if bundle_for(src, repo).is_none() {
236            return Err(Refusal::fail(format!(
237                "no bundle for {repo}: the log's refs for it name commits nothing here holds"
238            )));
239        }
240    }
241
242    Ok(Backup {
243        src: src.to_path_buf(),
244        policy,
245        repos,
246        ops,
247        bytes,
248    })
249}
250
251/// The bundle for one repository, under either backup leg's naming.
252///
253/// The full repository path (`owner/name.git.bundle`) and the basename
254/// the flip-era leg writes (`name.bundle`). Resolved in one place so the
255/// existence check and the unbundle loop can never disagree about which
256/// file they mean.
257#[must_use]
258pub fn bundle_for(src: &Path, repo: &str) -> Option<PathBuf> {
259    let full = src.join("repos").join(format!("{repo}.bundle"));
260    if full.is_file() {
261        return Some(full);
262    }
263    let base = repo.rsplit('/').next().unwrap_or(repo);
264    let base = base.strip_suffix(".git").unwrap_or(base);
265    let short = src.join("repos").join(format!("{base}.bundle"));
266    short.is_file().then_some(short)
267}
268
269/// Whether this target is a resumed placement rather than a fresh one.
270///
271/// A log byte-identical to the backup's is this command's own earlier
272/// placement, from a run that stopped for a decision. Refusing it would
273/// make the documented recovery path unreachable: the first run places
274/// the files, and the second could never get past this check. Anything
275/// else is somebody's node, and which of the two logs is real is not a
276/// decision available here.
277///
278/// # Errors
279///
280/// Refuses when the target holds a different log, or already holds one
281/// of the repositories.
282pub fn resuming(root: &Path, backup: &Backup) -> Result<bool, Refusal> {
283    let placed = root.join(".choir/ops.jsonl");
284    let resume = if placed.exists() {
285        let same = std::fs::read(&placed).ok() == std::fs::read(backup.src.join("ops.jsonl")).ok();
286        if !same {
287            return Err(Refusal::fail(format!(
288                "{} already exists and is not this backup: restore into an empty \
289                 root, or move the existing log aside first (it is never \
290                 overwritten here)",
291                placed.display()
292            )));
293        }
294        true
295    } else {
296        false
297    };
298    if !resume {
299        for repo in &backup.repos {
300            if root.join(repo).exists() {
301                return Err(Refusal::fail(format!(
302                    "{} already exists: restore into an empty root",
303                    root.join(repo).display()
304                )));
305            }
306        }
307    }
308    Ok(resume)
309}
310
311/// Places the log, the policy and the git objects.
312///
313/// Objects go in before the first boot, never after. Startup
314/// reconciliation reads the log against what git holds, and a ref naming
315/// a commit the repository does not have is classed as unbackable — so
316/// the daemon appends a retraction and the log now agrees with the
317/// emptiness. Restoring into repositories the daemon made for itself
318/// would therefore erase, in signed ops, exactly the ref state being
319/// restored.
320///
321/// # Errors
322///
323/// Refuses on any filesystem error, or a bundle git will not clone.
324pub fn place(root: &Path, backup: &Backup, resume: bool) -> Result<Vec<String>, Refusal> {
325    let choir = root.join(".choir");
326    std::fs::create_dir_all(choir.join("policy"))
327        .map_err(|e| Refusal::fail(format!("create {}: {e}", choir.display())))?;
328    std::fs::copy(backup.src.join("ops.jsonl"), choir.join("ops.jsonl"))
329        .map_err(|e| Refusal::fail(format!("place the log: {e}")))?;
330
331    // Not on a resume. Deleting the fingerprint is the operator
332    // accepting that the log changes author, and it is done between two
333    // runs of this command — re-placing it would put back the pin they
334    // just removed, and the re-run they were told to make would stop at
335    // the same refusal forever.
336    if !resume && backup.src.join("node.fingerprint").is_file() {
337        std::fs::copy(
338            backup.src.join("node.fingerprint"),
339            choir.join("node.fingerprint"),
340        )
341        .map_err(|e| Refusal::fail(format!("place the fingerprint: {e}")))?;
342    }
343    if backup.src.join("refs.snapshot").is_file() {
344        std::fs::copy(
345            backup.src.join("refs.snapshot"),
346            choir.join("refs.snapshot"),
347        )
348        .map_err(|e| Refusal::fail(format!("place the attestation: {e}")))?;
349    }
350
351    let mut absent = Vec::new();
352    for name in REQUIRED.iter().chain(OPTIONAL) {
353        let from = backup.policy.join(name);
354        if from.is_file() {
355            std::fs::copy(&from, choir.join("policy").join(name))
356                .map_err(|e| Refusal::fail(format!("place {name}: {e}")))?;
357        } else if OPTIONAL.contains(name) {
358            absent.push((*name).to_string());
359        }
360    }
361    private(&choir)?;
362
363    for repo in &backup.repos {
364        let into = root.join(repo);
365        if into.exists() {
366            continue;
367        }
368        if let Some(parent) = into.parent() {
369            std::fs::create_dir_all(parent)
370                .map_err(|e| Refusal::fail(format!("create {}: {e}", parent.display())))?;
371        }
372        let Some(bundle) = bundle_for(&backup.src, repo) else {
373            return Err(Refusal::fail(format!("no bundle for {repo}")));
374        };
375        let ok = std::process::Command::new("git")
376            .args(["clone", "--bare", "--quiet"])
377            .arg(&bundle)
378            .arg(&into)
379            .output()
380            .is_ok_and(|out| out.status.success());
381        if !ok {
382            return Err(Refusal::fail(format!("could not unbundle {repo}")));
383        }
384        // A bundle clone leaves an `origin` pointing at the bundle file,
385        // which would make the restored repository fetch from a path
386        // that is about to be a temp directory on somebody's laptop.
387        let _ = std::process::Command::new("git")
388            .arg("--git-dir")
389            .arg(&into)
390            .args(["remote", "remove", "origin"])
391            .output();
392    }
393    Ok(absent)
394}
395
396/// 0700 on the state directory, 0600 on every policy file.
397fn private(choir: &Path) -> Result<(), Refusal> {
398    #[cfg(unix)]
399    {
400        use std::os::unix::fs::PermissionsExt;
401        let set = |path: &Path, mode: u32| {
402            std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
403                .map_err(|e| Refusal::fail(format!("chmod {}: {e}", path.display())))
404        };
405        set(choir, 0o700)?;
406        if let Ok(entries) = std::fs::read_dir(choir.join("policy")) {
407            for entry in entries.flatten() {
408                set(&entry.path(), 0o600)?;
409            }
410        }
411    }
412    #[cfg(not(unix))]
413    let _ = choir;
414    Ok(())
415}
416
417/// The two holes a backup deliberately does not fill.
418///
419/// Both are refusals rather than warnings: the rehearsal cannot run
420/// without them, and a restore that has not been rehearsed has not been
421/// done.
422///
423/// # Errors
424///
425/// Exit 3 when the signing key is absent and a fingerprint pins the
426/// identity that wrote the log, or when there is no credential to serve
427/// with. Returns the warning to print when there is neither key nor pin.
428pub fn secrets(root: &Path, auth: &Path, ops: usize) -> Result<Option<String>, Refusal> {
429    let choir = root.join(".choir");
430    if !choir.join("node.key").is_file() {
431        if choir.join("node.fingerprint").is_file() {
432            return Err(Refusal::decide(format!(
433                "the node's signing key is not here, and {} pins the identity that \
434                 wrote this log.\n  Files are in place; the daemon will refuse to \
435                 start until you choose:\n    (a) put the original 32-byte key at \
436                 {} (chmod 600) and re-run this, or\n    (b) accept that the log \
437                 changes author at this point:\n          rm {}\n        and \
438                 re-run. Every op after the seam is signed by a different actor.\n  \
439                 Option (b) is not reversible and not invisible: see \
440                 docs/runbook-restore.md.",
441                choir.join("node.fingerprint").display(),
442                choir.join("node.key").display(),
443                choir.join("node.fingerprint").display(),
444            )));
445        }
446        // No key and no pin: either this log never had an author to
447        // keep, or the operator has taken option (b) by deleting the
448        // fingerprint. Nothing here can tell those apart, and refusing
449        // both would leave (b) with no way forward at all — the key it
450        // asks for is the one that is gone. So it proceeds, loudly.
451        let warning = format!(
452            "NO SIGNING KEY AND NO PIN — the daemon will mint a fresh key on the \
453             start below.\n  Every op from seq {ops} on is signed by a different \
454             actor than seq 0..{}.\n  Anyone holding the old fingerprint should be \
455             told (docs/runbook-restore.md).",
456            ops.saturating_sub(1)
457        );
458        if !auth.is_file() {
459            return Err(no_credential(auth));
460        }
461        return Ok(Some(warning));
462    }
463    if !auth.is_file() {
464        return Err(no_credential(auth));
465    }
466    Ok(None)
467}
468
469fn no_credential(auth: &Path) -> Refusal {
470    Refusal::decide(format!(
471        "no auth file at {}. Backups carry no credentials, so mint one now:\n    \
472         printf '<operator>:%s\\n' \"$(openssl rand -hex 32)\" > {} && chmod 600 {}\n  \
473         Replace <operator> with a username the restored ACL grants ownership of a \
474         restored repository, then re-run. Reuse of the old token is not possible \
475         and not wanted: it was last seen on a host you are restoring away from.",
476        auth.display(),
477        auth.display(),
478        auth.display(),
479    ))
480}
481
482/// A daemon started for the rehearsal, killed when this is dropped.
483///
484/// A `Drop` rather than a `kill` at each exit: every refusal below
485/// returns early, and a rehearsal daemon left running holds the port and
486/// keeps appending to the log an operator is about to inspect.
487struct Rehearsal {
488    child: std::process::Child,
489    port: u16,
490}
491
492impl Drop for Rehearsal {
493    fn drop(&mut self) {
494        let _ = self.child.kill();
495        let _ = self.child.wait();
496    }
497}
498
499/// Starts the restored node on a port it picks itself.
500///
501/// Port 0, so a rehearsal never collides with the node it is rehearsing
502/// to replace. The wait is for the daemon's own marker rather than for a
503/// duration: a slow machine is not a failed restore, and a dead process
504/// is not a slow one.
505fn boot(
506    root: &Path,
507    auth: &Path,
508    daemon: &Path,
509    work: &Path,
510    backup: &Backup,
511) -> Result<Rehearsal, Refusal> {
512    let policy = root.join(".choir/policy");
513    let mut args: Vec<String> = vec![
514        root.display().to_string(),
515        "0".to_string(),
516        "--bind".to_string(),
517        "127.0.0.1".to_string(),
518        "--auth-file".to_string(),
519        auth.display().to_string(),
520        "--keys-file".to_string(),
521        policy.join("keys").display().to_string(),
522        "--reviewers-file".to_string(),
523        policy.join("reviewers").display().to_string(),
524        "--require-assignment".to_string(),
525        "--require-scope".to_string(),
526        "--read-only-browser".to_string(),
527        "--journal".to_string(),
528        root.join(".choir/journal.jsonl").display().to_string(),
529        "--request-log".to_string(),
530        root.join(".choir/requests.jsonl").display().to_string(),
531        "--request-log-max-bytes".to_string(),
532        "33554432".to_string(),
533        "--rate-limit-api".to_string(),
534        "120".to_string(),
535        "--rate-limit-git".to_string(),
536        "60".to_string(),
537        "--quota-push-bytes".to_string(),
538        "536870912".to_string(),
539        "--quota-workspaces".to_string(),
540        "8".to_string(),
541        "--api-body-limit".to_string(),
542        "1048576".to_string(),
543        "--batch-limit".to_string(),
544        "256".to_string(),
545        "--ready-min-free-bytes".to_string(),
546        "1073741824".to_string(),
547    ];
548    // Only where the file exists. A flag naming a path that is not there
549    // is not a smaller policy, it is a daemon that does not start.
550    //
551    // Both halves or neither for the review gate: `--require-review`
552    // without `--protected-refs` is refused by the daemon, because a
553    // gate over nothing is worse than no gate. A backup from a node that
554    // protects no ref restores into a node that protects no ref.
555    for (name, flag, also) in [
556        (
557            "protected-refs",
558            "--protected-refs",
559            Some("--require-review"),
560        ),
561        ("newcomer-audit.jsonl", "--newcomer-audit", None),
562        (
563            "newcomer-adjudications.jsonl",
564            "--newcomer-adjudications",
565            None,
566        ),
567        ("review-adjudications.jsonl", "--review-adjudications", None),
568        ("acl", "--acl-file", None),
569    ] {
570        let path = policy.join(name);
571        if !path.is_file() {
572            continue;
573        }
574        args.push(flag.to_string());
575        args.push(path.display().to_string());
576        if let Some(also) = also {
577            args.push(also.to_string());
578        }
579    }
580    for repo in &backup.repos {
581        args.push("--create".to_string());
582        args.push(repo.clone());
583    }
584
585    let err_path = work.join("node.err");
586    let err = std::fs::File::create(&err_path)
587        .map_err(|e| Refusal::fail(format!("create {}: {e}", err_path.display())))?;
588    let out = std::fs::File::create(work.join("node.out"))
589        .map_err(|e| Refusal::fail(format!("create node.out: {e}")))?;
590    let mut child = std::process::Command::new(daemon)
591        .args(&args)
592        .stdout(out)
593        .stderr(err)
594        .spawn()
595        .map_err(|e| Refusal::fail(format!("could not start {}: {e}", daemon.display())))?;
596
597    let mut port = None;
598    for _ in 0..600 {
599        let text = std::fs::read_to_string(&err_path).unwrap_or_default();
600        if let Some(found) = serving_port(&text) {
601            port = Some(found);
602            break;
603        }
604        // A dead process is not a slow one.
605        if matches!(child.try_wait(), Ok(Some(_))) {
606            break;
607        }
608        std::thread::sleep(std::time::Duration::from_millis(100));
609    }
610    let stderr = std::fs::read_to_string(&err_path).unwrap_or_default();
611    let Some(port) = port else {
612        let _ = child.kill();
613        return Err(Refusal::fail(format!(
614            "the restored node did not start. Its output:\n{stderr}"
615        )));
616    };
617    let rehearsal = Rehearsal { child, port };
618
619    // A retraction during a restore is the failure this whole ordering
620    // exists to prevent, and it is loud rather than fatal-by-accident:
621    // the log has already been appended to by the time it is printed.
622    let retracted: Vec<&str> = stderr
623        .lines()
624        .filter(|l| l.starts_with("choir: retracted"))
625        .collect();
626    if !retracted.is_empty() {
627        return Err(Refusal::fail(format!(
628            "the restored node RETRACTED refs on start:\n{}\n  The log named \
629             commits the repos do not hold. The restored log now has compensating \
630             ops in it and is no longer the backup. Start again from the backup \
631             into a clean root.",
632            retracted.join("\n")
633        )));
634    }
635    Ok(rehearsal)
636}
637
638/// The port out of the daemon's own start line.
639///
640/// Parsed rather than assumed because the rehearsal asks for port 0 and
641/// only the daemon knows what it got.
642#[must_use]
643pub fn serving_port(stderr: &str) -> Option<u16> {
644    stderr.lines().find_map(|line| {
645        let rest = line.strip_prefix("choir-node serving ")?;
646        let at = rest.rfind("http://")?;
647        let host = rest[at + "http://".len()..]
648            .split(|c: char| c == '/' || c.is_whitespace())
649            .next()?;
650        host.rsplit_once(':')?.1.parse().ok()
651    })
652}
653
654/// One authenticated GET against the rehearsal node.
655fn get(api: &str, credential: &str, path: &str) -> Option<serde_json::Value> {
656    let out = std::process::Command::new("curl")
657        .args(["-sS", "-u", credential])
658        .arg(format!("{api}{path}"))
659        .output()
660        .ok()?;
661    serde_json::from_slice(&out.stdout).ok()
662}
663
664/// The refs an attestation says this log ends at, in display form.
665///
666/// The attestation holds canonical [`choir_hash::ContentHash`] values
667/// and the served view holds their display form, so the first is
668/// projected into the second — through `ContentHash::to_hex` itself
669/// rather than through a format string, because a comparison written
670/// against imagined hex compares nothing at all.
671///
672/// # Errors
673///
674/// Returns a description when the file is not an attestation.
675pub fn attested_refs(
676    snapshot: &serde_json::Value,
677) -> Result<std::collections::BTreeMap<String, String>, String> {
678    let refs = snapshot
679        .get("refs")
680        .and_then(serde_json::Value::as_object)
681        .ok_or("no `refs` in the attestation")?;
682    let mut out = std::collections::BTreeMap::new();
683    for (name, value) in refs {
684        let hash: choir_hash::ContentHash = serde_json::from_value(value.clone())
685            .map_err(|e| format!("{name} is not a content hash: {e}"))?;
686        out.insert(name.clone(), hash.to_hex());
687    }
688    Ok(out)
689}
690
691/// The refs a served view reports, in the same form.
692#[must_use]
693pub fn served_refs(view: &serde_json::Value) -> std::collections::BTreeMap<String, String> {
694    view.get("refs")
695        .and_then(serde_json::Value::as_object)
696        .map(|refs| {
697            refs.iter()
698                .filter_map(|(name, value)| {
699                    value.as_str().map(|hex| (name.clone(), hex.to_string()))
700                })
701                .collect()
702        })
703        .unwrap_or_default()
704}
705
706/// Every difference between what was attested and what is served.
707#[must_use]
708pub fn ref_mismatches(
709    attested: &std::collections::BTreeMap<String, String>,
710    served: &std::collections::BTreeMap<String, String>,
711) -> Vec<String> {
712    let mut names: Vec<&String> = attested.keys().chain(served.keys()).collect();
713    names.sort();
714    names.dedup();
715    names
716        .into_iter()
717        .filter(|name| attested.get(*name) != served.get(*name))
718        .map(|name| {
719            format!(
720                "  {name}: attested {}, serving {}",
721                attested.get(name).map_or("nothing", String::as_str),
722                served.get(name).map_or("nothing", String::as_str)
723            )
724        })
725        .collect()
726}
727
728/// The whole restore: read, refuse, place, rehearse, prove.
729///
730/// # Errors
731///
732/// Every refusal above, plus the rehearsal's own: a node that serves no
733/// log head, a view that disagrees with the attestation, a canary the
734/// node refuses, a log that did not grow, an appended entry that does
735/// not chain onto what the node replayed to, or a restored log that is
736/// not a byte-exact continuation of the backup.
737pub fn run(
738    src: &Path,
739    root: &Path,
740    daemon: &Path,
741    auth: &Path,
742    say: &mut dyn FnMut(&str),
743) -> Result<Restored, Refusal> {
744    let work = scratch()?;
745    let backup = read(src, &work, daemon)?;
746    let resume = resuming(root, &backup)?;
747    if resume {
748        say(&format!(
749            "resuming — {} is this backup, placed and not appended to",
750            root.join(".choir/ops.jsonl").display()
751        ));
752    }
753    let absent = place(root, &backup, resume)?;
754    for name in &absent {
755        say(&format!(
756            "{name} is not in this backup — the restored node starts without it"
757        ));
758    }
759    if let Some(warning) = secrets(root, auth, backup.ops)? {
760        say(&warning);
761    }
762
763    let rehearsal = boot(root, auth, daemon, &work, &backup)?;
764    let api = format!("http://127.0.0.1:{}", rehearsal.port);
765    let credential = std::fs::read_to_string(auth)
766        .map_err(|e| Refusal::fail(format!("read {}: {e}", auth.display())))?
767        .lines()
768        .next()
769        .unwrap_or_default()
770        .to_string();
771
772    // What the node replayed to, before anything is written. The claim
773    // is checked below, where the canary's own `parent` has to be this
774    // hash — an entry naming it is the log itself agreeing, rather than
775    // the node being asked to confirm its own arithmetic.
776    let view = get(&api, &credential, "/api/view")
777        .ok_or_else(|| Refusal::fail("the restored node served no view"))?;
778    let head_before = view["log"]["head"].as_str().unwrap_or_default().to_string();
779    if head_before.is_empty() {
780        return Err(Refusal::fail(
781            "the restored node serves no log head; it did not replay the log it was given",
782        ));
783    }
784
785    // The half of a restore a checksum cannot reach: the bytes can
786    // arrive perfectly and still be replayed into a different view.
787    let snapshot_path = root.join(".choir/refs.snapshot");
788    if snapshot_path.is_file() {
789        let snapshot: serde_json::Value = std::fs::read_to_string(&snapshot_path)
790            .ok()
791            .and_then(|t| serde_json::from_str(&t).ok())
792            .ok_or_else(|| Refusal::fail("refs.snapshot is not readable JSON"))?;
793        let attested = attested_refs(&snapshot).map_err(Refusal::fail)?;
794        let served = served_refs(&view);
795        let differences = ref_mismatches(&attested, &served);
796        if !differences.is_empty() {
797            return Err(Refusal::fail(format!(
798                "the restored view does not match the backup's ref attestation:\n{}",
799                differences.join("\n")
800            )));
801        }
802        say(&format!(
803            "view matches the attestation at seq {} ({} refs)",
804            snapshot["at_seq"],
805            attested.len()
806        ));
807    }
808
809    let landed_in = canary(&backup, &work, &credential, rehearsal.port)?;
810    let canary_ref = landed_in.1;
811    let landed_in = landed_in.0;
812
813    // The append happened, and it happened on top of what the node
814    // replayed. An entry whose parent is the head read above is the
815    // log's own statement that the restored bytes were folded to
816    // exactly that point.
817    let placed = root.join(".choir/ops.jsonl");
818    let after = std::fs::read_to_string(&placed).unwrap_or_default();
819    let lines: Vec<&str> = after.lines().collect();
820    if lines.len() <= backup.ops {
821        return Err(Refusal::fail(
822            "the canary push returned success but the log did not grow. The repo \
823             is being served without its pre-receive hook, so pushes bypass the \
824             sequencer.",
825        ));
826    }
827    let entry: serde_json::Value = serde_json::from_str(lines[backup.ops])
828        .map_err(|e| Refusal::fail(format!("the appended entry is not JSON: {e}")))?;
829    let parent: choir_hash::ContentHash = serde_json::from_value(entry["parent"].clone())
830        .map_err(|e| Refusal::fail(format!("the appended entry has no parent hash: {e}")))?;
831    if parent.to_hex() != head_before {
832        return Err(Refusal::fail(format!(
833            "the first appended entry chains onto {}, but the node served head \
834             {head_before}: the replay and the log do not agree",
835            parent.to_hex()
836        )));
837    }
838
839    // Append-only, asserted rather than assumed: everything restored is
840    // still byte-for-byte where it was, with the canary after it. A
841    // restore that rewrote history would pass every other check here.
842    let restored_bytes = std::fs::read(&placed).unwrap_or_default();
843    let backup_bytes = std::fs::read(backup.src.join("ops.jsonl")).unwrap_or_default();
844    if restored_bytes.len() < backup_bytes.len()
845        || restored_bytes[..backup_bytes.len()] != backup_bytes[..]
846    {
847        return Err(Refusal::fail(
848            "the restored log is not a byte-exact continuation of the backup: \
849             something rewrote history",
850        ));
851    }
852
853    drop(rehearsal);
854    std::fs::remove_dir_all(&work).ok();
855    Ok(Restored {
856        ops: backup.ops,
857        repos: backup.repos.len(),
858        canary: canary_ref,
859        landed_in,
860    })
861}
862
863/// A real push over the real transport.
864///
865/// http-backend, the `pre-receive` hook, the sequencer, and an append to
866/// the log that was just restored. Nothing short of that distinguishes a
867/// node from a directory of files.
868///
869/// Returns the repository it landed in and the ref it created.
870fn canary(
871    backup: &Backup,
872    work: &Path,
873    credential: &str,
874    port: u16,
875) -> Result<(String, String), Refusal> {
876    let stamp = std::time::SystemTime::now()
877        .duration_since(std::time::UNIX_EPOCH)
878        .map(|d| d.as_secs())
879        .unwrap_or_default();
880    let canary = format!("refs/heads/restore-canary-{stamp}");
881    for repo in &backup.repos {
882        let url = format!("http://{credential}@127.0.0.1:{port}/{repo}");
883        let clone = work.join("canary");
884        std::fs::remove_dir_all(&clone).ok();
885        let cloned = std::process::Command::new("git")
886            .args(["clone", "--quiet", &url])
887            .arg(&clone)
888            .output()
889            .is_ok_and(|out| out.status.success());
890        if !cloned {
891            continue;
892        }
893        // Any commit will do, and HEAD is not reliably one: a repository
894        // rebuilt from a bundle keeps whatever HEAD the original had, so
895        // one whose default branch is not among the restored refs clones
896        // with an unborn HEAD and nothing checked out. Falling back to a
897        // fetched branch is what keeps a restore that worked from
898        // reporting that it proved nothing.
899        let tip = git_line(&clone, &["rev-parse", "--verify", "--quiet", "HEAD"]).or_else(|| {
900            git_line(
901                &clone,
902                &[
903                    "for-each-ref",
904                    "--count=1",
905                    "--format=%(objectname)",
906                    "refs/remotes/origin/",
907                ],
908            )
909        });
910        // An empty repository clones fine and has nothing to push. Not a
911        // failure of this repository, only a reason to try the next.
912        let Some(tip) = tip else { continue };
913        let pushed = std::process::Command::new("git")
914            .arg("-C")
915            .arg(&clone)
916            .args(["push", "--quiet", &url, &format!("{tip}:{canary}")])
917            .output()
918            .is_ok_and(|out| out.status.success());
919        if !pushed {
920            return Err(Refusal::fail(format!(
921                "the canary push to {repo} was refused. The restored node serves, \
922                 but it does not accept writes."
923            )));
924        }
925        return Ok((repo.clone(), canary));
926    }
927    Err(Refusal::fail(
928        "no restored repo has a commit to push, so nothing proved the node accepts \
929         writes. This is not a pass.",
930    ))
931}
932
933/// One line of git output, or nothing when git failed or said nothing.
934fn git_line(dir: &Path, args: &[&str]) -> Option<String> {
935    let out = std::process::Command::new("git")
936        .arg("-C")
937        .arg(dir)
938        .args(args)
939        .output()
940        .ok()?;
941    if !out.status.success() {
942        return None;
943    }
944    let line = String::from_utf8_lossy(&out.stdout).trim().to_string();
945    (!line.is_empty()).then_some(line)
946}
947
948/// A private work directory for the tar, the clone and the daemon's output.
949fn scratch() -> Result<PathBuf, Refusal> {
950    static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
951    let at = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
952    let dir = std::env::temp_dir().join(format!("choir-restore-{}-{at}", std::process::id()));
953    std::fs::remove_dir_all(&dir).ok();
954    std::fs::create_dir_all(&dir)
955        .map_err(|e| Refusal::fail(format!("create {}: {e}", dir.display())))?;
956    Ok(dir)
957}