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}