Skip to main content

choir_node/
portable.rs

1//! Exporting a node root, importing one, and settling what an export
2//! claims (§E).
3//!
4//! The plan's standing rule for a one-way door is that no persisted
5//! format ships without three things: a version field, a written
6//! evolution policy, and export/import tooling. The op log has had the
7//! first two since its first byte — every entry carries
8//! `format_version`, and [`crate::platform`] documents the additive
9//! rule. This is the third, and it is deliberately a *format* tool
10//! rather than a deployment one.
11//!
12//! That distinction is the reason this exists beside `scripts/pull_backup.sh`
13//! instead of replacing it. That script is disaster recovery for one
14//! deployment: it pulls over ssh from a fixed remote path, refuses to run
15//! on the machine that holds the log, takes its repository list from a
16//! policy file, needs python3 to project a hash, and checks the bundles
17//! against the D25 attestation. All of that is right for the job it does
18//! and none of it travels. Here there is no network, no ssh key, no
19//! `repos.list`, and no interpreter: a directory goes in, a directory
20//! comes out, and the log itself says what the export must contain.
21//!
22//! # What an export claims, and how the claim is settled
23//!
24//! An export is not a pile of files, it is an assertion with two halves
25//! that can disagree: *this op log* and *these repositories* describe the
26//! same node. [`verify`] settles it by folding the log into a
27//! [`choir_view::View`] and requiring every ref the view names to be
28//! present in that repository's bundle at the same oid.
29//!
30//! The check is deliberately one-directional. A bundle may carry refs the
31//! log does not name; a log may not name refs no bundle carries. That
32//! asymmetry is what makes exporting a *running* node meaningful: the log
33//! is read first and the bundles after, so a push landing mid-export puts
34//! the bundles ahead, which is a state the export can honestly describe.
35//! The reverse — the log naming a commit no bundle holds — is the failure
36//! that matters, because restoring it produces a node whose view points
37//! at objects it does not have, and startup reconciliation answers that
38//! by appending signed retractions of exactly the refs being restored.
39//! [`Report::ahead`] counts the benign direction rather than hiding it,
40//! so an operator can see drift instead of inferring it.
41//!
42//! # What an export does not contain
43//!
44//! Secrets, by construction and then by inspection. Only four names are
45//! ever copied out of `.choir`, and the finished directory is walked
46//! again afterwards and refused if it holds anything matching
47//! [`is_secret`] — so the guarantee is a property of the output rather
48//! than of the care taken while writing it. The node's signing key stays
49//! with the node that owns it, and `docs/runbook-restore.md` covers what
50//! its absence means.
51//!
52//! Policy files are also absent, and that is not an oversight worth
53//! quietly tolerating: `--acl-file`, `--auth-file`, `--keys-file` and the
54//! rest name paths anywhere on the host, so a root does not know where
55//! they are and an export taken from one cannot honestly claim to hold
56//! them. The manifest says so in a field rather than leaving the reader
57//! to notice. Provisioned workspaces and checkouts under `.choir` are
58//! left behind too, being derived from refs the export does carry.
59
60use std::collections::BTreeMap;
61use std::path::{Path, PathBuf};
62
63/// Version of the export directory's own layout, carried in the manifest.
64///
65/// Separate from the op log's `format_version`, which the manifest also
66/// records: a change to how this directory is arranged is not a change to
67/// the entries inside it, and conflating the two would make either
68/// version bump imply the other.
69pub const FORMAT_VERSION: u32 = 1;
70
71/// Whether a file name is one an export must never carry.
72///
73/// Named rather than enumerated: `--tls-key`, a per-actor key and a
74/// GitHub App PEM are all secrets that no list of literal file names
75/// would keep up with, so the rule is the shape of the name. `auth` is
76/// the one literal, being the bearer-token table.
77#[must_use]
78pub fn is_secret(name: &str) -> bool {
79    name == "auth" || name.ends_with(".key") || name.ends_with(".pem")
80}
81
82/// What an export holds, counted from the files rather than claimed.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct Report {
85    /// Op-log records the export carries.
86    pub records: u64,
87    /// The log's head hash as hex, or `None` for an empty log.
88    pub head: Option<String>,
89    /// Repositories the export carries a bundle for.
90    pub bundles: usize,
91    /// Refs the log names that a bundle carries at the same oid.
92    pub refs: usize,
93    /// Refs a bundle carries that the log does not name.
94    ///
95    /// Benign by itself — see the module docs on why the check runs in
96    /// one direction — and reported so that drift is visible.
97    pub ahead: usize,
98}
99
100/// Writes an export of `root` into `dest`, then verifies its own output.
101///
102/// `dest` must not already exist. An export never writes into a
103/// directory it did not create, for the same reason
104/// `scripts/restore_from_backup.sh` never writes over a log: the thing
105/// being overwritten is the fallback.
106///
107/// # Errors
108///
109/// Returns a description when `root` holds no op log, when that log does
110/// not verify, when a repository the log names refs for cannot be
111/// bundled, or when the finished directory fails [`verify`].
112pub fn export(root: &Path, dest: &Path) -> Result<Report, String> {
113    let state = root.join(".choir");
114    let log = state.join("ops.jsonl");
115    if !log.is_file() {
116        return Err(format!(
117            "{} holds no op log at .choir/ops.jsonl; a node started without \
118             --keys-file runs no sequencer and writes none",
119            root.display()
120        ));
121    }
122    if dest.exists() {
123        return Err(format!(
124            "{} already exists; an export writes a new directory rather than into one",
125            dest.display()
126        ));
127    }
128
129    // The log is read before a single bundle is taken, which is what
130    // puts a concurrent push in the benign direction rather than the
131    // failing one. See the module docs.
132    let chain = chain_report(&log)?;
133    let refs = log_refs(&log)?;
134
135    std::fs::create_dir_all(dest.join("repos")).map_err(|e| format!("create {dest:?}: {e}"))?;
136    copy(&log, &dest.join("ops.jsonl"))?;
137    for name in ["node.fingerprint", "refs.snapshot"] {
138        let from = state.join(name);
139        if from.is_file() {
140            copy(&from, &dest.join(name))?;
141        }
142    }
143
144    let mut rows = Vec::new();
145    for repo in repos(root)? {
146        let dir = root.join(&repo);
147        let bundle = dest.join("repos").join(format!("{repo}.bundle"));
148        if let Some(parent) = bundle.parent() {
149            std::fs::create_dir_all(parent).map_err(|e| format!("create {parent:?}: {e}"))?;
150        }
151        // A repository with no refs cannot be bundled: git refuses
152        // rather than writing a zero-ref bundle. Recording it as an
153        // empty row keeps the export's repository list complete, which
154        // is what lets an import recreate a repo somebody made and has
155        // not pushed to yet.
156        let bundled = !git(&["for-each-ref", "--format=%(refname)"], &dir)?
157            .trim()
158            .is_empty();
159        if bundled {
160            let path = bundle.to_str().ok_or("bundle path is not utf-8")?;
161            git(&["bundle", "create", path, "--all"], &dir)?;
162        }
163        rows.push((repo, bundled));
164    }
165
166    let manifest = serde_json::json!({
167        "format_version": FORMAT_VERSION,
168        "log_format_version": choir_oplog::FORMAT_VERSION,
169        "records": chain.0,
170        "head": chain.1,
171        "repos": rows.iter().map(|(name, bundled)| serde_json::json!({
172            "name": name,
173            "bundle": bundled.then(|| format!("repos/{name}.bundle")),
174            "refs": refs.get(name).map_or(0, BTreeMap::len),
175        })).collect::<Vec<_>>(),
176        "policy_files": "not included: named by flags and stored outside the root",
177        "secrets": "not included: see docs/runbook-restore.md",
178    });
179    std::fs::write(
180        dest.join("manifest.json"),
181        format!(
182            "{}\n",
183            serde_json::to_string_pretty(&manifest).map_err(|e| e.to_string())?
184        ),
185    )
186    .map_err(|e| format!("write manifest: {e}"))?;
187
188    // An export that does not verify is not an export. Running the
189    // reader over the writer's output is the only thing that makes the
190    // two agree about the format a year from now.
191    verify(dest)
192}
193
194/// Settles what an export at `dir` claims: the log verifies, the manifest
195/// describes it, and every ref the log names is in a bundle at that oid.
196///
197/// # Errors
198///
199/// Returns a description naming the first disagreement found.
200pub fn verify(dir: &Path) -> Result<Report, String> {
201    let manifest: serde_json::Value = serde_json::from_slice(
202        &std::fs::read(dir.join("manifest.json"))
203            .map_err(|e| format!("{}: no manifest.json ({e})", dir.display()))?,
204    )
205    .map_err(|e| format!("manifest.json does not parse: {e}"))?;
206    let version = manifest
207        .get("format_version")
208        .and_then(serde_json::Value::as_u64);
209    if version != Some(u64::from(FORMAT_VERSION)) {
210        return Err(format!(
211            "export format_version is {}, this build reads {FORMAT_VERSION}",
212            version.map_or_else(|| "absent".to_string(), |v| v.to_string())
213        ));
214    }
215
216    let log = dir.join("ops.jsonl");
217    if !log.is_file() {
218        return Err("export holds no ops.jsonl".to_string());
219    }
220    let (records, head) = chain_report(&log)?;
221    if manifest.get("records").and_then(serde_json::Value::as_u64) != Some(records) {
222        return Err(format!(
223            "manifest claims {} records, the log verifies {records}",
224            manifest["records"]
225        ));
226    }
227    if manifest.get("head").and_then(serde_json::Value::as_str) != head.as_deref() {
228        return Err(format!(
229            "manifest claims head {}, the log ends at {}",
230            manifest["head"],
231            head.as_deref().unwrap_or("nothing")
232        ));
233    }
234
235    // Every name in the manifest, so that a repository dropped from the
236    // export is caught even when the log names no ref for it.
237    let listed = rows(&manifest)?;
238
239    let (mut matched, mut ahead, mut bundles) = (0usize, 0usize, 0usize);
240    let refs = log_refs(&log)?;
241    for (repo, wanted) in &refs {
242        if !listed.contains_key(repo) {
243            return Err(format!(
244                "the log names {} refs for {repo}, which the manifest does not list",
245                wanted.len()
246            ));
247        }
248    }
249    for (repo, bundled) in &listed {
250        let empty = BTreeMap::new();
251        let wanted = refs.get(repo).unwrap_or(&empty);
252        if !bundled {
253            if wanted.is_empty() {
254                continue;
255            }
256            return Err(format!(
257                "the log names {} refs for {repo}, which the export carries no bundle for",
258                wanted.len()
259            ));
260        }
261        bundles += 1;
262        let path = dir.join("repos").join(format!("{repo}.bundle"));
263        if !path.is_file() {
264            return Err(format!(
265                "{repo}: the manifest names a bundle that is not here"
266            ));
267        }
268        let held = bundle_heads(&path)?;
269        for (name, oid) in wanted {
270            match held.get(name) {
271                Some(found) if found == oid => matched += 1,
272                Some(found) => {
273                    return Err(format!(
274                        "{repo}: the log has {name} at {oid}, the bundle has it at {found}"
275                    ))
276                }
277                None => {
278                    return Err(format!(
279                        "{repo}: the log names {name} at {oid}, the bundle does not carry it"
280                    ))
281                }
282            }
283        }
284        ahead += held
285            .keys()
286            .filter(|name| !wanted.contains_key(*name))
287            .count();
288    }
289
290    for found in walk(dir)? {
291        let name = found
292            .file_name()
293            .map_or_else(String::new, |n| n.to_string_lossy().into_owned());
294        if is_secret(&name) {
295            return Err(format!(
296                "{} is a secret and an export must not carry one",
297                found.display()
298            ));
299        }
300    }
301
302    Ok(Report {
303        records,
304        head,
305        bundles,
306        refs: matched,
307        ahead,
308    })
309}
310
311/// Places the export at `dir` into a fresh node `root`.
312///
313/// Verifies before writing a byte, then refuses a root that already
314/// holds a log or any repository the manifest names — the thing being
315/// written over is the fallback, which is the same reason
316/// `scripts/restore_from_backup.sh` refuses both.
317///
318/// What this does *not* do is claim the result works. A restored node
319/// that serves is not a restored node; what settles it is one that
320/// accepts a write, and the secrets that boot needs are deliberately not
321/// here. `docs/runbook-restore.md` covers them. Hooks and git config are
322/// not written either: the daemon adopts a repository it finds under its
323/// root, so writing them here would be a second copy of that rule, drifting.
324///
325/// # Errors
326///
327/// Returns a description when the export does not verify, when `root`
328/// already holds a log or a named repository, or when git refuses to
329/// unbundle one.
330pub fn import(dir: &Path, root: &Path) -> Result<Report, String> {
331    let report = verify(dir)?;
332    let manifest: serde_json::Value = serde_json::from_slice(
333        &std::fs::read(dir.join("manifest.json")).map_err(|e| e.to_string())?,
334    )
335    .map_err(|e| e.to_string())?;
336    let listed = rows(&manifest)?;
337
338    let state = root.join(".choir");
339    if state.join("ops.jsonl").exists() {
340        return Err(format!(
341            "{} already holds a log: import into a fresh root, or move the existing \
342             log aside first, because nothing here overwrites one",
343            state.display()
344        ));
345    }
346    for name in listed.keys() {
347        if root.join(name).exists() {
348            return Err(format!(
349                "{} already exists: import into a fresh root",
350                root.join(name).display()
351            ));
352        }
353    }
354
355    std::fs::create_dir_all(&state).map_err(|e| format!("create {state:?}: {e}"))?;
356    copy(&dir.join("ops.jsonl"), &state.join("ops.jsonl"))?;
357    for name in ["node.fingerprint", "refs.snapshot"] {
358        let from = dir.join(name);
359        if from.is_file() {
360            copy(&from, &state.join(name))?;
361        }
362    }
363
364    for (name, bundled) in &listed {
365        let target = root.join(name);
366        let target = target.to_str().ok_or("repository path is not utf-8")?;
367        if *bundled {
368            let bundle = dir.join("repos").join(format!("{name}.bundle"));
369            let bundle = bundle.to_str().ok_or("bundle path is not utf-8")?;
370            git(
371                &["clone", "--bare", "--quiet", bundle, target],
372                Path::new("."),
373            )?;
374            // A bundle clone leaves an `origin` pointing at the bundle
375            // file, which will not be there once the export is gone.
376            git(&["remote", "remove", "origin"], Path::new(target))?;
377        } else {
378            git(&["init", "--bare", "--quiet", target], Path::new("."))?;
379        }
380    }
381    Ok(report)
382}
383
384/// The manifest's repository list as `name -> has a bundle`, with every
385/// name checked before it is ever joined to a path.
386///
387/// The check is here rather than at each use because both halves take
388/// this list from a directory somebody else produced: a name of `../..`
389/// would otherwise write outside the root being imported into, and read
390/// outside the export being verified.
391fn rows(manifest: &serde_json::Value) -> Result<BTreeMap<String, bool>, String> {
392    let mut out = BTreeMap::new();
393    for row in manifest
394        .get("repos")
395        .and_then(serde_json::Value::as_array)
396        .ok_or("manifest names no repository list")?
397    {
398        let name = row
399            .get("name")
400            .and_then(serde_json::Value::as_str)
401            .ok_or("a manifest row names no repository")?;
402        if !name.ends_with(".git")
403            || name.starts_with('/')
404            || name.split('/').any(|part| part.is_empty() || part == "..")
405        {
406            return Err(format!(
407                "manifest names `{name}`, which is not a repository path"
408            ));
409        }
410        out.insert(
411            name.to_string(),
412            !row.get("bundle").is_none_or(serde_json::Value::is_null),
413        );
414    }
415    Ok(out)
416}
417
418/// Verifies the chain and returns `(records, head-hex)`.
419fn chain_report(log: &Path) -> Result<(u64, Option<String>), String> {
420    let report =
421        choir_oplog::repair::verify(log).map_err(|e| format!("{}: {e:?}", log.display()))?;
422    if let Some(fault) = report.fault {
423        return Err(format!(
424            "{}: op log refused at record {}: {fault}",
425            log.display(),
426            fault.position()
427        ));
428    }
429    if report.torn_tail_bytes != 0 {
430        return Err(format!(
431            "{}: op log has an unterminated {}-byte tail; an export needs a complete record boundary",
432            log.display(),
433            report.torn_tail_bytes
434        ));
435    }
436    Ok((
437        report.intact_records,
438        report.head.as_ref().map(choir_hash::ContentHash::to_hex),
439    ))
440}
441
442/// Folds the log and groups its refs by repository, as git oids.
443fn log_refs(log: &Path) -> Result<BTreeMap<String, BTreeMap<String, String>>, String> {
444    let backend = choir_oplog::FileLog::open(log).map_err(|e| format!("open log: {e:?}"))?;
445    let view = choir_view::View::materialize(&backend).map_err(|e| format!("fold log: {e:?}"))?;
446    let mut out: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
447    for (key, hash) in &view.refs {
448        let (repo, name) = key
449            .split_once(':')
450            .ok_or_else(|| format!("view ref key `{key}` names no repository"))?;
451        let oid = hash
452            .git_oid()
453            .ok_or_else(|| format!("view ref `{key}` does not hold a git oid"))?;
454        out.entry(repo.to_string())
455            .or_default()
456            .insert(name.to_string(), oid);
457    }
458    Ok(out)
459}
460
461/// Bare repositories under `root`, as `owner/name.git` paths.
462///
463/// Public because the export is not the only caller that needs to know
464/// what is actually on disk. Startup needs it too: a repository can
465/// arrive without ever being named in `--create` — restored from a
466/// bundle, or created against a running node — and one that is served
467/// without being adopted is served with no `pre-receive` hook, which
468/// means every push into it bypasses the sequencer.
469///
470/// A directory counts as a repository when its name ends `.git` and it
471/// holds a `HEAD` file, so a half-written directory is not mistaken for
472/// one. `.choir` is skipped, and symlinks are never followed: a link out
473/// of the root would otherwise let an export copy, or a startup adopt,
474/// whatever it pointed at.
475///
476/// # Errors
477///
478/// Returns a description when a directory under `root` cannot be read.
479pub fn repos(root: &Path) -> Result<Vec<String>, String> {
480    let mut found = Vec::new();
481    let mut stack = vec![(root.to_path_buf(), String::new())];
482    while let Some((dir, prefix)) = stack.pop() {
483        for entry in std::fs::read_dir(&dir).map_err(|e| format!("read {dir:?}: {e}"))? {
484            let entry = entry.map_err(|e| format!("read {dir:?}: {e}"))?;
485            // `DirEntry::file_type` does not follow symlinks, so a link
486            // out of the root is skipped rather than followed. An export
487            // that chased one would copy whatever it pointed at.
488            if !entry
489                .file_type()
490                .map_err(|e| format!("stat: {e}"))?
491                .is_dir()
492            {
493                continue;
494            }
495            let name = entry.file_name().to_string_lossy().into_owned();
496            if name == ".choir" {
497                continue;
498            }
499            let rel = if prefix.is_empty() {
500                name.clone()
501            } else {
502                format!("{prefix}/{name}")
503            };
504            if name.ends_with(".git") && entry.path().join("HEAD").is_file() {
505                found.push(rel);
506            } else {
507                stack.push((entry.path(), rel));
508            }
509        }
510    }
511    found.sort();
512    Ok(found)
513}
514
515/// Every file under `dir`, recursively.
516fn walk(dir: &Path) -> Result<Vec<PathBuf>, String> {
517    let mut found = Vec::new();
518    let mut stack = vec![dir.to_path_buf()];
519    while let Some(dir) = stack.pop() {
520        for entry in std::fs::read_dir(&dir).map_err(|e| format!("read {dir:?}: {e}"))? {
521            let entry = entry.map_err(|e| format!("read {dir:?}: {e}"))?;
522            if entry
523                .file_type()
524                .map_err(|e| format!("stat: {e}"))?
525                .is_dir()
526            {
527                stack.push(entry.path());
528            } else {
529                found.push(entry.path());
530            }
531        }
532    }
533    Ok(found)
534}
535
536/// `refname -> oid` for a bundle, read from its header alone.
537fn bundle_heads(bundle: &Path) -> Result<BTreeMap<String, String>, String> {
538    let path = bundle.to_str().ok_or("bundle path is not utf-8")?;
539    let out = git(&["bundle", "list-heads", path], Path::new("."))?;
540    let mut heads = BTreeMap::new();
541    for line in out.lines() {
542        // `HEAD` is listed beside the refs and is a symbolic pointer, not
543        // a ref any op ever names.
544        if let Some((oid, name)) = line.split_once(' ') {
545            if name.starts_with("refs/") {
546                heads.insert(name.to_string(), oid.to_string());
547            }
548        }
549    }
550    Ok(heads)
551}
552
553/// Copies one file, reporting which one failed.
554fn copy(from: &Path, to: &Path) -> Result<(), String> {
555    std::fs::copy(from, to)
556        .map(|_| ())
557        .map_err(|e| format!("copy {} to {}: {e}", from.display(), to.display()))
558}
559
560/// Runs git in `dir`, returning stdout or a failure description.
561fn git(args: &[&str], dir: &Path) -> Result<String, String> {
562    let out = std::process::Command::new("git")
563        .args(args)
564        .current_dir(dir)
565        .env("GIT_TERMINAL_PROMPT", "0")
566        .output()
567        .map_err(|e| format!("spawn git: {e}"))?;
568    if out.status.success() {
569        Ok(String::from_utf8_lossy(&out.stdout).into_owned())
570    } else {
571        Err(format!(
572            "git {} in {}: {}",
573            args.join(" "),
574            dir.display(),
575            String::from_utf8_lossy(&out.stderr).trim()
576        ))
577    }
578}