Skip to main content

choir_node/
replica.rs

1//! A seed (D80): a node whose log is a copy of another node's, taken one
2//! way, verified before it is kept, and never written to by anybody here.
3//!
4//! **Seeds, not peers.** A home is the one node whose sequencer admits
5//! submissions for its log. A seed replicates that log page by page over
6//! `GET /api/log`, checks every page with SYNC.md's three checks, and
7//! appends what passed through its *own* writer thread with
8//! [`Platform::replicate`] — so invariant 5 holds on a seed exactly as on
9//! a home, and nothing here is a second writer. It fetches the git
10//! objects the log points at, checks every ref the view names against the
11//! bare repository at the same oid, and serves what it holds.
12//!
13//! # One key, one grant
14//!
15//! A seed is a named reader of its home, never an anonymous one: `/api/log`
16//! needs a node-wide read grant and the ACL parser refuses to give that to
17//! `@anon`. So the home's operator registers the seed's node public key,
18//! binds it as an operator, and grants that operator `@node auditor` (the
19//! node-wide read) plus `read` on the repositories it should hold. The
20//! credential the home issued for that principal is what this module
21//! presents, and the node key is what a seed signs witness statements
22//! with, so the name in the home's bindings and the key on a statement are
23//! one identity.
24//!
25//! Public keys come from the home's `GET /api/signers`, because the log
26//! carries a key's id and never the key. The home's own node key is
27//! pinned beside this seed's log on first contact, the same shape a node
28//! uses to pin its own key: a later start against a home signing with a
29//! different key refuses, since a copy that quietly follows its source to
30//! a new identity is not a copy of anything in particular.
31//!
32//! # What stops replication, and what does not
33//!
34//! A page that fails a check, and an entry this build cannot fold, both
35//! **halt** replication at that seq. What was verified before it stays and
36//! is still served; nothing after it is attempted, and the halt holds
37//! until the process restarts. Skipping the entry instead would make this
38//! node a fork of its home that looks like a copy. An unreachable home is
39//! not a halt: the next round tries again.
40//!
41//! An entry whose author key the home does not list is **unverified**,
42//! counted, and still replicated. Continuity and recomputation passed, and
43//! the home admitted it; what nobody here can say is who signed it, and
44//! the count is how a reader is told so.
45//!
46//! A home that has evicted the entries this seed needs (`409
47//! log_evicted`) cannot be followed at all. Its window resumes at a later
48//! seq, and this log's next position is fixed by what it holds: the writer
49//! appends only at `log.len()` and the view folds only from genesis, so a
50//! chain with a hole in it cannot be held here, and SYNC.md's gap rule
51//! forbids reporting two pieces as one. The seed records `gap`, stops, and
52//! keeps serving what it has. A home with a persisted log never answers
53//! that way.
54
55use crate::platform::Platform;
56use choir_hash::ContentHash;
57use choir_identity::{sync, ActorKey, Registry};
58use choir_sequencer::Sequenced;
59use choir_view::{OpKind, ViewOp};
60use std::io::Write as _;
61use std::path::{Path, PathBuf};
62use std::sync::{Arc, Mutex};
63
64/// Seconds between replication rounds in the daemon.
65///
66/// One small request per round when nothing moved, so there is no reason
67/// for a long-poll yet; tests call [`Replica::replicate_once`] directly and
68/// never wait on this.
69pub const INTERVAL_SECS: u64 = 5;
70
71/// Statements `GET /api/witness` serves beside the latest.
72pub const WITNESS_HISTORY: usize = 64;
73
74/// The channel a witness statement is signed on.
75///
76/// Fixed, so the bytes a seed signs are the op scheme's own
77/// `[channel, payload]` with nothing the signer chooses, and a signature
78/// over a statement can never verify as an op on any channel an actor
79/// could be bound to: `/` is refused in an operator name.
80pub const WITNESS_CHANNEL: &str = "seed/witness";
81
82/// Most log pages one round reads before it turns to git.
83///
84/// A home admitting faster than one page per round would otherwise keep
85/// a round in its log loop forever and never fetch an object. The next
86/// round continues where this one stopped.
87const MAX_PAGES_PER_ROUND: usize = 64;
88
89/// The home a seed copies: its base URL and the node key it signs with.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct Home {
92    /// Base URL, without a trailing slash: `<url>/api/log`, and
93    /// `<url>/<owner>/<name>.git` for git.
94    pub url: String,
95    /// The home's node actor id, as its `GET /api/signers` named it and
96    /// this seed pinned it.
97    pub node_id: ContentHash,
98}
99
100/// The credential a home issued to a seed's principal: one `user:token`
101/// line, the shape of a line in the home's `--auth-file`.
102///
103/// It never reaches an argv, where `ps` would show it, and never a URL:
104/// curl reads it from stdin, and git from its environment.
105#[derive(Clone)]
106pub struct Credential {
107    user: String,
108    token: String,
109}
110
111impl std::fmt::Debug for Credential {
112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        write!(f, "Credential({}:<redacted>)", self.user)
114    }
115}
116
117impl Credential {
118    /// Reads `path`: its first non-blank, non-comment line, as `user:token`.
119    ///
120    /// # Errors
121    ///
122    /// The file cannot be read, holds no such line, or carries a quote,
123    /// backslash or control character, any of which could turn a quoted
124    /// curl config line into a second directive.
125    pub fn read(path: &Path) -> Result<Self, String> {
126        let text = std::fs::read_to_string(path)
127            .map_err(|e| format!("reading the seed credential {}: {e}", path.display()))?;
128        let line = text
129            .lines()
130            .map(str::trim)
131            .find(|line| !line.is_empty() && !line.starts_with('#'))
132            .ok_or_else(|| format!("{} holds no `user:token` line", path.display()))?;
133        Self::parse(line).map_err(|e| format!("{}: {e}", path.display()))
134    }
135
136    /// Parses one `user:token` line.
137    ///
138    /// # Errors
139    ///
140    /// As [`Credential::read`].
141    pub fn parse(line: &str) -> Result<Self, String> {
142        let (user, token) = line
143            .split_once(':')
144            .ok_or("a seed credential is one `user:token` line")?;
145        if user.is_empty() || token.is_empty() {
146            return Err("a seed credential needs both a user and a token".to_string());
147        }
148        if line
149            .chars()
150            .any(|c| c == '"' || c == '\\' || c.is_control())
151        {
152            return Err(
153                "a seed credential may not carry quotes, backslashes or control \
154                        characters"
155                    .to_string(),
156            );
157        }
158        Ok(Self {
159            user: user.to_string(),
160            token: token.to_string(),
161        })
162    }
163
164    /// The principal the home knows this seed as.
165    #[must_use]
166    pub fn user(&self) -> &str {
167        &self.user
168    }
169
170    /// The value of an `Authorization` header carrying this credential.
171    fn basic(&self) -> String {
172        format!(
173            "Basic {}",
174            crate::base64_encode(format!("{}:{}", self.user, self.token).as_bytes())
175        )
176    }
177}
178
179/// One authenticated `GET` against the home, as `(status, body)`.
180///
181/// `curl` with the credential on stdin, the workspace's one way out: no
182/// HTTP client crate, and no secret on a command line.
183fn get(base: &str, credential: &Credential, path: &str) -> Result<(u16, String), String> {
184    let mut child = std::process::Command::new("curl")
185        .args(["-sS", "-w", "\n%{http_code}", "--config", "-"])
186        .arg(format!("{base}{path}"))
187        .stdin(std::process::Stdio::piped())
188        .stdout(std::process::Stdio::piped())
189        .stderr(std::process::Stdio::piped())
190        .spawn()
191        .map_err(|e| format!("could not start curl: {e}"))?;
192    child
193        .stdin
194        .take()
195        .expect("piped curl stdin")
196        .write_all(format!("user = \"{}:{}\"\n", credential.user, credential.token).as_bytes())
197        .map_err(|e| format!("could not configure curl: {e}"))?;
198    let out = child
199        .wait_with_output()
200        .map_err(|e| format!("could not wait for curl: {e}"))?;
201    if !out.status.success() {
202        return Err(format!(
203            "could not reach the home: {}",
204            String::from_utf8_lossy(&out.stderr).trim()
205        ));
206    }
207    let text = String::from_utf8_lossy(&out.stdout);
208    let (body, status) = text
209        .rsplit_once('\n')
210        .ok_or("curl returned no HTTP status")?;
211    let status = status
212        .trim()
213        .parse()
214        .map_err(|_| "curl returned an invalid HTTP status".to_string())?;
215    Ok((status, body.to_string()))
216}
217
218/// The home's signer table, as `GET /api/signers` served it.
219#[derive(Debug, Clone)]
220pub struct Signers {
221    /// The home's node actor id.
222    pub node_id: ContentHash,
223    /// Every public key the table lists, the node's first.
224    pub keys: Vec<[u8; 32]>,
225}
226
227impl Signers {
228    /// A registry holding every key this table lists.
229    fn registry(&self) -> Registry {
230        let mut registry = Registry::new();
231        for key in &self.keys {
232            // A key the table lists but ed25519 refuses verifies nothing,
233            // which is what leaving it out does too: its entries count as
234            // unverified rather than halting a copy over a bad row.
235            let _ = registry.register(key);
236        }
237        registry
238    }
239}
240
241/// `GET /api/signers` on the home at `base`, checked: every key's id is
242/// the hash of the key it is listed beside, and the node's too.
243///
244/// # Errors
245///
246/// The home cannot be reached, refuses the grant, or serves a body this
247/// build does not read.
248pub fn fetch_signers(base: &str, credential: &Credential) -> Result<Signers, String> {
249    let (status, body) = get(base, credential, "/api/signers")?;
250    if status != 200 {
251        return Err(format!(
252            "GET /api/signers answered {status}; a seed needs the home's `@node auditor` \
253             grant for its principal `{}`: {}",
254            credential.user,
255            body.trim()
256        ));
257    }
258    let value: serde_json::Value = serde_json::from_str(&body)
259        .map_err(|e| format!("GET /api/signers served a body that is not JSON: {e}"))?;
260    if value["format_version"].as_u64() != Some(1) {
261        return Err(format!(
262            "GET /api/signers is format_version {}, and this build reads 1",
263            value["format_version"]
264        ));
265    }
266    let key = |row: &serde_json::Value| -> Result<(ContentHash, [u8; 32]), String> {
267        let id = row["actor_id"]
268            .as_str()
269            .and_then(ContentHash::from_hex)
270            .ok_or("a signer row names no actor id")?;
271        let bytes: [u8; 32] = row["public_key_hex"]
272            .as_str()
273            .and_then(crate::platform::hex_decode)
274            .and_then(|bytes| bytes.try_into().ok())
275            .ok_or("a signer row carries no 32-byte public key")?;
276        if ContentHash::blake3(&bytes) != id {
277            return Err(format!(
278                "signer {} is listed beside a key that does not hash to it",
279                id.to_hex()
280            ));
281        }
282        Ok((id, bytes))
283    };
284    let (node_id, node_key) = key(&value["node"])?;
285    let mut keys = vec![node_key];
286    for row in value["signers"].as_array().into_iter().flatten() {
287        keys.push(key(row)?.1);
288    }
289    Ok(Signers { node_id, keys })
290}
291
292/// Where a seed pins its home's node key: beside its own log.
293fn pin_path(root: &Path) -> PathBuf {
294    root.join(".choir").join("home.fingerprint")
295}
296
297/// Pins `node_id` as this seed's home on first contact, and refuses a
298/// different one afterwards.
299///
300/// # Errors
301///
302/// The pin names another key, or cannot be read or written.
303pub fn pin_home(root: &Path, url: &str, node_id: &ContentHash) -> Result<(), String> {
304    let path = pin_path(root);
305    let fingerprint = node_id.to_hex();
306    match std::fs::read_to_string(&path) {
307        Ok(pinned) if pinned.trim() == fingerprint => Ok(()),
308        Ok(pinned) => Err(format!(
309            "home identity changed: {} pins {}, but {url} now signs as {fingerprint}. A seed \
310             does not follow its home to a new key; if the home really was re-keyed, delete \
311             the pin and accept that this copy now claims a different home",
312            path.display(),
313            pinned.trim()
314        )),
315        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
316            std::fs::create_dir_all(root.join(".choir"))
317                .map_err(|e| format!("creating {}: {e}", root.join(".choir").display()))?;
318            // Atomic: a torn pin would refuse every later start.
319            choir_fs::write_atomic(&path, format!("{fingerprint}\n"))
320                .map_err(|e| format!("writing {}: {e}", path.display()))
321        }
322        Err(e) => Err(format!("reading {}: {e}", path.display())),
323    }
324}
325
326/// First contact with a home: its signer table fetched, and its node key
327/// pinned beside this seed's log (or checked against the pin already
328/// there).
329///
330/// # Errors
331///
332/// The home cannot be read, or signs with a key other than the pinned one.
333pub fn contact(root: &Path, url: &str, credential: &Credential) -> Result<Home, String> {
334    let url = url.trim_end_matches('/').to_string();
335    match fetch_signers(&url, credential) {
336        Ok(signers) => {
337            pin_home(root, &url, &signers.node_id)?;
338            Ok(Home {
339                url,
340                node_id: signers.node_id,
341            })
342        }
343        // A seed that has met its home before starts on the pin when the
344        // home is down: a dead home means a frozen copy that still serves,
345        // not a copy that refuses to come up. Every round re-checks the
346        // pin once the home answers again.
347        Err(unreachable) => match pinned(root) {
348            Some(node_id) => Ok(Home { url, node_id }),
349            None => Err(format!(
350                "{unreachable}; a seed's first start needs its home, to pin the key it \
351                 signs with"
352            )),
353        },
354    }
355}
356
357/// The home key this seed pinned, if it has met its home before.
358#[must_use]
359pub fn pinned(root: &Path) -> Option<ContentHash> {
360    std::fs::read_to_string(pin_path(root))
361        .ok()
362        .and_then(|text| ContentHash::from_hex(text.trim()))
363}
364
365/// A seed's word that it folded the home's attestation `snapshot` (D80).
366///
367/// Its own object, under its own `format_version`, served and never
368/// written into anybody's log: a seed that cosigned through the home
369/// would have to win a race against every new attestation, and a
370/// statement compared by ancestry does not care how many landed since.
371#[derive(Debug, Clone, PartialEq, Eq)]
372pub struct WitnessStatement {
373    /// Version of this object's shape; 1.
374    pub format_version: u16,
375    /// The seed's actor id: the hash of the key that signs this.
376    pub witness: String,
377    /// The home's node actor id, which signed the attestation.
378    pub home_node_id: String,
379    /// The attestation's id, [`choir_view::RefSnapshot::id`], as hex.
380    pub snapshot: String,
381    /// The log position the attestation occupies.
382    pub at_seq: u64,
383    /// The last seq the seed had folded when it signed.
384    pub seen_at_seq: u64,
385}
386
387/// A JSON string literal for `text`.
388fn quoted(text: &str) -> String {
389    serde_json::Value::String(text.to_string()).to_string()
390}
391
392impl WitnessStatement {
393    /// The bytes that are signed: compact JSON, fields in declaration
394    /// order.
395    ///
396    /// Written out rather than left to a map's iteration order, because
397    /// whether `serde_json` sorts object keys depends on a feature any
398    /// crate in a build can switch on, and a signature over bytes that
399    /// move with the build is a signature over nothing.
400    #[must_use]
401    pub fn canonical_bytes(&self) -> Vec<u8> {
402        format!(
403            "{{\"format_version\":{},\"witness\":{},\"home_node_id\":{},\"snapshot\":{},\"at_seq\":{},\"seen_at_seq\":{}}}",
404            self.format_version,
405            quoted(&self.witness),
406            quoted(&self.home_node_id),
407            quoted(&self.snapshot),
408            self.at_seq,
409            self.seen_at_seq
410        )
411        .into_bytes()
412    }
413
414    /// Reads one back from its JSON form.
415    #[must_use]
416    pub fn from_json(value: &serde_json::Value) -> Option<Self> {
417        let text = |key: &str| value.get(key)?.as_str().map(str::to_string);
418        Some(Self {
419            format_version: u16::try_from(value.get("format_version")?.as_u64()?).ok()?,
420            witness: text("witness")?,
421            home_node_id: text("home_node_id")?,
422            snapshot: text("snapshot")?,
423            at_seq: value.get("at_seq")?.as_u64()?,
424            seen_at_seq: value.get("seen_at_seq")?.as_u64()?,
425        })
426    }
427
428    /// Its JSON form.
429    #[must_use]
430    pub fn to_json(&self) -> serde_json::Value {
431        serde_json::from_slice(&self.canonical_bytes()).expect("canonical bytes are JSON")
432    }
433}
434
435/// A statement, the public key that signed it, and the signature.
436///
437/// The key travels with the statement because a reader checking a seed
438/// may hold nothing else, and it costs no trust: the statement names its
439/// witness by the key's hash, so a substituted key names somebody else.
440#[derive(Debug, Clone, PartialEq, Eq)]
441pub struct SignedStatement {
442    /// What was said.
443    pub statement: WitnessStatement,
444    /// The seed's ed25519 public key.
445    pub public_key: [u8; 32],
446    /// Its signature, made the way an op's author signs one: over the
447    /// signing hash of `[WITNESS_CHANNEL, canonical_bytes]`.
448    pub signature: Vec<u8>,
449}
450
451impl SignedStatement {
452    /// Signs `statement` with `key`.
453    #[must_use]
454    pub fn sign(key: &ActorKey, statement: WitnessStatement) -> Self {
455        let witness = key.sign_submission(WITNESS_CHANNEL, &statement.canonical_bytes());
456        Self {
457            statement,
458            public_key: key.public_key_bytes(),
459            signature: witness.signature,
460        }
461    }
462
463    /// Checks the statement is one this build reads, is signed by the key
464    /// it carries, and that key is the witness it names.
465    ///
466    /// # Errors
467    ///
468    /// Which of the three failed.
469    pub fn verify(&self) -> Result<(), String> {
470        if self.statement.format_version != 1 {
471            return Err(format!(
472                "statement is format_version {}, and this build reads 1",
473                self.statement.format_version
474            ));
475        }
476        let id = ContentHash::blake3(&self.public_key).to_hex();
477        if id != self.statement.witness {
478            return Err(format!(
479                "the statement names witness {}, and the key it carries is {id}",
480                self.statement.witness
481            ));
482        }
483        let mut registry = Registry::new();
484        registry
485            .register(&self.public_key)
486            .map_err(|e| format!("the statement's key is not an ed25519 key: {e:?}"))?;
487        registry
488            .verify_submission(
489                WITNESS_CHANNEL,
490                &self.statement.canonical_bytes(),
491                &choir_oplog::Witness::ed25519(id, self.signature.clone()),
492            )
493            .map(|_| ())
494            .map_err(|e| format!("the statement's signature does not verify: {e:?}"))
495    }
496
497    /// Its JSON form, as `/api/witness` serves it.
498    #[must_use]
499    pub fn to_json(&self) -> serde_json::Value {
500        serde_json::json!({
501            "statement": self.statement.to_json(),
502            "public_key_hex": crate::platform::hex_encode(&self.public_key),
503            "signature_hex": crate::platform::hex_encode(&self.signature),
504        })
505    }
506
507    /// Reads one back from its JSON form, unchecked: see
508    /// [`SignedStatement::verify`].
509    #[must_use]
510    pub fn from_json(value: &serde_json::Value) -> Option<Self> {
511        Some(Self {
512            statement: WitnessStatement::from_json(value.get("statement")?)?,
513            public_key: value
514                .get("public_key_hex")?
515                .as_str()
516                .and_then(crate::platform::hex_decode)?
517                .try_into()
518                .ok()?,
519            signature: value
520                .get("signature_hex")?
521                .as_str()
522                .and_then(crate::platform::hex_decode)?,
523        })
524    }
525}
526
527/// The home's attestation chain, as far as a reader has read its log:
528/// each `RecordRefSnapshot`'s id and the id its `prev_snapshot` names.
529///
530/// Built from `/api/log` pages, which a reader holds, rather than from
531/// anything a seed says about itself.
532#[derive(Debug, Default, Clone)]
533pub struct SnapshotChain {
534    prev: std::collections::BTreeMap<String, Option<String>>,
535}
536
537/// How two attestations stand by the fork rule (D80).
538#[derive(Debug, Clone, Copy, PartialEq, Eq)]
539pub enum Relation {
540    /// The same attestation.
541    Same,
542    /// The first is an ancestor of the second, this many links back.
543    Behind(u64),
544    /// The second is an ancestor of the first, this many links back.
545    Ahead(u64),
546    /// Neither is an ancestor of the other: two histories.
547    Fork,
548}
549
550impl SnapshotChain {
551    /// Adds every attestation in a page of `/api/log` entries, and says
552    /// how many it found.
553    pub fn read_page(&mut self, entries: &[serde_json::Value]) -> usize {
554        let mut found = 0;
555        for entry in entries {
556            let Some(payload) = entry["payload_hex"]
557                .as_str()
558                .and_then(crate::platform::hex_decode)
559            else {
560                continue;
561            };
562            if let Ok(ViewOp {
563                kind: OpKind::RecordRefSnapshot { snapshot },
564                ..
565            }) = ViewOp::from_payload(&payload)
566            {
567                self.insert(
568                    snapshot.id().to_hex(),
569                    snapshot.prev_snapshot.as_ref().map(ContentHash::to_hex),
570                );
571                found += 1;
572            }
573        }
574        found
575    }
576
577    /// Records one link: attestation `id` follows `prev`.
578    pub fn insert(&mut self, id: String, prev: Option<String>) {
579        self.prev.insert(id, prev);
580    }
581
582    /// How many `prev_snapshot` links lead from `from` back to `to`, or
583    /// `None` when `to` is not an ancestor of `from` in what was read.
584    #[must_use]
585    pub fn steps_back(&self, from: &str, to: &str) -> Option<u64> {
586        let mut here = from;
587        let mut steps = 0u64;
588        loop {
589            if here == to {
590                return Some(steps);
591            }
592            here = self.prev.get(here)?.as_deref()?;
593            steps += 1;
594            // A cycle is impossible in a log a fold accepted, and this
595            // reads pages nobody has checked.
596            if steps > self.prev.len() as u64 {
597                return None;
598            }
599        }
600    }
601
602    /// The fork rule: `a` and `b` agree when one is the other's ancestor,
603    /// or they are equal, and are a fork when neither is.
604    ///
605    /// A statement over `S` endorses `S` and everything reachable from it
606    /// by `prev_snapshot`, so how many attestations landed between two
607    /// readers' looks does not matter, and a seed need not race the home.
608    /// For the walk to be complete, the chain must hold every attestation
609    /// from the lower of the two positions onward.
610    #[must_use]
611    pub fn relate(&self, a: &str, b: &str) -> Relation {
612        if a == b {
613            return Relation::Same;
614        }
615        if let Some(steps) = self.steps_back(b, a) {
616            return Relation::Behind(steps);
617        }
618        if let Some(steps) = self.steps_back(a, b) {
619            return Relation::Ahead(steps);
620        }
621        Relation::Fork
622    }
623}
624
625/// Where replication stands, as `/api/view` reports it on a seed.
626#[derive(Debug, Clone, Default, PartialEq, Eq)]
627pub struct Status {
628    /// The seq of the last entry this seed holds; `None` while empty.
629    pub head_seq: Option<u64>,
630    /// The highest seq the home has served this seed. A statement about
631    /// the last contact, not about now: the home may have moved since.
632    pub home_head_seq: Option<u64>,
633    /// Refs the view names that the bare repositories here hold at the
634    /// same oid.
635    pub refs_verified: usize,
636    /// Refs the view names that are not held here at that oid yet, or
637    /// that no repository could hold.
638    pub refs_pending: usize,
639    /// Entries replicated whose author key the home's table does not
640    /// list. Never silently zero: this is the reader's warning.
641    pub unverified_entries: u64,
642    /// Set after a `409 log_evicted`, and never cleared: this seed's copy
643    /// cannot continue the home's chain, so it is a copy and not a
644    /// witness.
645    pub gap: bool,
646    /// The seq replication halted at and why, once it has.
647    pub halted: Option<(u64, String)>,
648    /// The last round's transient failure, if the last round failed.
649    pub last_error: Option<String>,
650}
651
652impl Status {
653    /// The `replica` section of `/api/view` on a seed, under its own
654    /// `format_version`.
655    #[must_use]
656    pub fn to_json(&self, home: &Home) -> serde_json::Value {
657        serde_json::json!({
658            "format_version": 1,
659            "home": home.url,
660            "home_node_id": home.node_id.to_hex(),
661            "head_seq": self.head_seq,
662            "home_head_seq": self.home_head_seq,
663            "refs_verified": self.refs_verified,
664            "refs_pending": self.refs_pending,
665            "unverified_entries": self.unverified_entries,
666            "gap": self.gap,
667            "halted": self.halted.as_ref().map(|(seq, reason)| serde_json::json!({
668                "seq": seq,
669                "reason": reason,
670            })),
671            "last_error": self.last_error,
672        })
673    }
674}
675
676/// What one successful round did.
677#[derive(Debug, Clone, Default, PartialEq, Eq)]
678pub struct Progress {
679    /// Entries appended this round.
680    pub appended: u64,
681    /// The status after the round.
682    pub status: Status,
683}
684
685/// Why a round stopped.
686#[derive(Debug, Clone, PartialEq, Eq)]
687pub enum ReplicaError {
688    /// The home could not be reached or answered something this round
689    /// cannot use. Transient: the next round tries again.
690    Unreachable(String),
691    /// A page failed a check, an entry could not be folded, or the home
692    /// changed key. Replication stops at `seq` for the life of the
693    /// process; everything before it is kept and served.
694    Halted {
695        /// The first seq not taken.
696        seq: u64,
697        /// Why.
698        reason: String,
699    },
700    /// The home evicted entries this seed needs and has no persisted log
701    /// to serve them from. Replication stops; see the module docs.
702    Gap {
703        /// Where the home's window resumes.
704        window_base: u64,
705    },
706}
707
708impl ReplicaError {
709    /// Whether this stops replication for the life of the process, as
710    /// opposed to a round the next one retries.
711    #[must_use]
712    pub fn is_halt(&self) -> bool {
713        !matches!(self, Self::Unreachable(_))
714    }
715}
716
717impl std::fmt::Display for ReplicaError {
718    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
719        match self {
720            Self::Unreachable(why) => write!(f, "replication round failed: {why}"),
721            Self::Halted { seq, reason } => {
722                write!(f, "replication halted at seq {seq}: {reason}")
723            }
724            Self::Gap { window_base } => write!(
725                f,
726                "replication stopped at a gap: the home has evicted everything before seq \
727                 {window_base} and keeps no persisted log, so this copy cannot continue its \
728                 chain"
729            ),
730        }
731    }
732}
733
734/// What one page did.
735#[derive(Debug, Default)]
736struct Page {
737    /// Entries the home served.
738    served: usize,
739    /// Entries appended.
740    appended: u64,
741}
742
743/// What a seed shares with the platform serving it, so `/api/view` can
744/// say where replication stands without holding the replicator.
745#[derive(Debug, Default)]
746pub struct Shared {
747    pub(crate) status: Mutex<Status>,
748    /// The statements this seed has signed, oldest first, at most
749    /// [`WITNESS_HISTORY`].
750    pub(crate) statements: Mutex<std::collections::VecDeque<SignedStatement>>,
751}
752
753impl Shared {
754    /// `GET /api/witness` on a seed.
755    pub(crate) fn witness_json(&self, home: &Home, key: &ActorKey) -> serde_json::Value {
756        let statements = self.statements.lock().expect("replica statements lock");
757        let status = self.status.lock().expect("replica status lock");
758        serde_json::json!({
759            "format_version": 1,
760            "witness": key.actor_id().to_hex(),
761            "public_key_hex": crate::platform::hex_encode(&key.public_key_bytes()),
762            "home": home.url,
763            "home_node_id": home.node_id.to_hex(),
764            "gap": status.gap,
765            // A halt is the event a witness exists to surface, so it is
766            // here beside the statement it froze, not only in `/api/view`.
767            "halted": status.halted.as_ref().map(|(seq, reason)| serde_json::json!({
768                "seq": seq,
769                "reason": reason,
770            })),
771            "latest": statements.back().map(SignedStatement::to_json),
772            "history": statements.iter().map(SignedStatement::to_json).collect::<Vec<_>>(),
773        })
774    }
775}
776
777/// Where a seed keeps the statements it signed: beside its log, one JSON
778/// object per line.
779fn witness_path(root: &Path) -> PathBuf {
780    root.join(".choir").join("witness.jsonl")
781}
782
783/// The last [`WITNESS_HISTORY`] statements in `root`'s file, oldest first.
784/// A line that does not read is skipped: the file is this seed's own
785/// record, and the log is the history that matters.
786fn load_statements(root: &Path) -> std::collections::VecDeque<SignedStatement> {
787    let text = std::fs::read_to_string(witness_path(root)).unwrap_or_default();
788    let mut out: std::collections::VecDeque<SignedStatement> = text
789        .lines()
790        .filter_map(|line| serde_json::from_str(line).ok())
791        .filter_map(|value: serde_json::Value| SignedStatement::from_json(&value))
792        .collect();
793    while out.len() > WITNESS_HISTORY {
794        out.pop_front();
795    }
796    out
797}
798
799/// A running seed: the home it copies, the credential it copies with, and
800/// the platform it copies into.
801pub struct Replica {
802    home: Home,
803    credential: Credential,
804    root: PathBuf,
805    platform: Arc<Platform>,
806    /// Every key the home's signer table listed at the last round.
807    registry: Mutex<Registry>,
808    shared: Arc<Shared>,
809}
810
811impl Replica {
812    /// A seed of `home`, writing into `platform` and fetching git into
813    /// the bare repositories under `root`.
814    ///
815    /// `platform` should have been made a seed of the same home with
816    /// [`Platform::as_seed_of`]; [`contact`] is how both learn it. If it
817    /// was not, it is made one here, since a platform being replicated
818    /// into is a seed whether or not anybody said so.
819    #[must_use]
820    pub fn new(root: PathBuf, home: Home, credential: Credential, platform: Arc<Platform>) -> Self {
821        let shared = Arc::new(Shared {
822            status: Mutex::new(Status {
823                head_seq: platform.view_seq().checked_sub(1),
824                ..Status::default()
825            }),
826            statements: Mutex::new(load_statements(&root)),
827        });
828        platform.attach_replica(home.clone(), shared.clone());
829        Self {
830            home,
831            credential,
832            root,
833            platform,
834            registry: Mutex::new(Registry::new()),
835            shared,
836        }
837    }
838
839    /// The home this seed copies.
840    #[must_use]
841    pub fn home(&self) -> &Home {
842        &self.home
843    }
844
845    /// Where replication stands.
846    #[must_use]
847    pub fn status(&self) -> Status {
848        self.shared
849            .status
850            .lock()
851            .expect("replica status lock")
852            .clone()
853    }
854
855    /// Re-reads the home's signer table into this seed's registry, and
856    /// checks the home still signs with the pinned key.
857    ///
858    /// # Errors
859    ///
860    /// [`ReplicaError::Unreachable`] when the table cannot be read, and
861    /// [`ReplicaError::Halted`] when the home's key is not the pinned one.
862    pub fn fetch_signers(&self) -> Result<usize, ReplicaError> {
863        let signers =
864            fetch_signers(&self.home.url, &self.credential).map_err(ReplicaError::Unreachable)?;
865        if signers.node_id != self.home.node_id {
866            return Err(ReplicaError::Halted {
867                seq: self.platform.view_seq(),
868                reason: format!(
869                    "the home now signs as {}, and this seed pinned {}",
870                    signers.node_id.to_hex(),
871                    self.home.node_id.to_hex()
872                ),
873            });
874        }
875        let count = signers.keys.len();
876        *self.registry.lock().expect("replica registry lock") = signers.registry();
877        Ok(count)
878    }
879
880    /// Revocation positions for verifying `entries`: every one this seed
881    /// has already folded, and every `RevokeKey` inside the page itself.
882    ///
883    /// The second half matters because a page is a window. An entry
884    /// signed after its key's revocation *in the same page* is exactly the
885    /// case SYNC.md's third check exists to fail, and the view has not
886    /// folded that revocation yet when the page is checked.
887    fn revocations(&self, entries: &[serde_json::Value]) -> sync::Revocations {
888        let mut revoked: sync::Revocations = self.platform.with_view(|view| {
889            view.bindings
890                .iter()
891                .filter_map(|(key, binding)| binding.revoked.as_ref().map(|r| (key.clone(), r.at)))
892                .collect()
893        });
894        for entry in entries.iter().filter_map(sync::rebuild) {
895            if let Ok(ViewOp {
896                kind: OpKind::RevokeKey { key, .. },
897                ..
898            }) = ViewOp::from_payload(&entry.payload)
899            {
900                revoked.entry(key.to_hex()).or_insert(entry.seq);
901            }
902        }
903        revoked
904    }
905
906    /// Fetches the page at `from`, verifies it by SYNC.md's three checks,
907    /// and replicates every entry before the first one that fails.
908    fn fetch_page(&self, from: u64) -> Result<Page, ReplicaError> {
909        let (status, body) = get(
910            &self.home.url,
911            &self.credential,
912            &format!("/api/log?from={from}"),
913        )
914        .map_err(ReplicaError::Unreachable)?;
915        let value: serde_json::Value = serde_json::from_str(&body).map_err(|_| {
916            ReplicaError::Unreachable(format!(
917                "GET /api/log answered {status} with a body that is not JSON"
918            ))
919        })?;
920        if status == 409 && value["code"] == "log_evicted" {
921            return Err(ReplicaError::Gap {
922                window_base: value["window_base"].as_u64().unwrap_or_default(),
923            });
924        }
925        if status != 200 {
926            return Err(ReplicaError::Unreachable(format!(
927                "GET /api/log answered {status}: {}",
928                value["error"].as_str().unwrap_or_default()
929            )));
930        }
931        let entries = value["entries"].as_array().cloned().unwrap_or_default();
932        let Some(last) = entries.last() else {
933            return Ok(Page::default());
934        };
935        let last_seq = last["seq"].as_u64();
936        // Recorded before anything is checked: how far the home has shown
937        // this seed is a fact about the home whether or not the page
938        // verifies, and after a halt it is how far behind the copy is.
939        {
940            let mut status = self.shared.status.lock().expect("replica status lock");
941            status.home_head_seq = status.home_head_seq.max(last_seq);
942        }
943
944        let registry = self.registry.lock().expect("replica registry lock");
945        let revoked = self.revocations(&entries);
946        let report = sync::page(&entries, &registry, &revoked);
947        // Everything before the first failure passed all three checks.
948        let keep = match report.first_failure {
949            Some(seq) => entries
950                .iter()
951                .position(|entry| entry["seq"].as_u64() == Some(seq))
952                .unwrap_or(0),
953            None => entries.len(),
954        };
955        drop(registry);
956
957        let mut batch = Vec::with_capacity(keep);
958        // Which entries are attestations, so a statement is signed once
959        // one of them has actually been folded.
960        let mut attests = Vec::with_capacity(keep);
961        for entry in &entries[..keep] {
962            // Both passed the checks just made, which rebuilt the one and
963            // compared the other, so neither can be absent here.
964            let (Some(rebuilt), Some(hash)) = (
965                sync::rebuild(entry),
966                entry["hash"].as_str().and_then(ContentHash::from_hex),
967            ) else {
968                return Err(ReplicaError::Halted {
969                    seq: entry["seq"].as_u64().unwrap_or(from),
970                    reason: "an entry that verified could not be rebuilt".to_string(),
971                });
972            };
973            attests.push(matches!(
974                ViewOp::from_payload(&rebuilt.payload),
975                Ok(ViewOp {
976                    kind: OpKind::RecordRefSnapshot { .. },
977                    ..
978                })
979            ));
980            batch.push(Sequenced {
981                entry: rebuilt,
982                hash,
983            });
984        }
985        let (appended, stopped) = if batch.is_empty() {
986            (0, None)
987        } else {
988            match self.platform.replicate(batch) {
989                Ok(done) => (done.appended, None),
990                Err(stopped) => (stopped.appended, Some(stopped)),
991            }
992        };
993        // Counted over exactly what was appended, so a halt part-way
994        // through a page neither loses nor invents an unverified entry.
995        let taken = usize::try_from(appended)
996            .unwrap_or(usize::MAX)
997            .min(entries.len());
998        if attests[..taken.min(attests.len())].contains(&true) {
999            self.witness();
1000        }
1001        let unverified = if taken == entries.len() {
1002            report.unverified
1003        } else {
1004            let registry = self.registry.lock().expect("replica registry lock");
1005            sync::page(&entries[..taken], &registry, &revoked).unverified
1006        };
1007        self.shared
1008            .status
1009            .lock()
1010            .expect("replica status lock")
1011            .unverified_entries += unverified as u64;
1012        if let Some(stopped) = stopped {
1013            return Err(ReplicaError::Halted {
1014                seq: stopped.seq,
1015                reason: stopped.reason,
1016            });
1017        }
1018        if let Some(seq) = report.first_failure {
1019            return Err(ReplicaError::Halted {
1020                seq,
1021                // The verifier says which seq in every sentence; the halt
1022                // already does, so it is said once.
1023                reason: report.failures.first().map_or_else(
1024                    || "the page failed verification".to_string(),
1025                    |first| {
1026                        first
1027                            .strip_prefix(&format!("seq {seq}: "))
1028                            .unwrap_or(first)
1029                            .to_string()
1030                    },
1031                ),
1032            });
1033        }
1034        Ok(Page {
1035            served: entries.len(),
1036            appended,
1037        })
1038    }
1039
1040    /// Signs a statement over the latest attestation this seed has
1041    /// folded, keeps it, and records it beside the log.
1042    ///
1043    /// Never after a gap: a copy with a hole in it has not seen the chain
1044    /// it would be vouching for.
1045    fn witness(&self) {
1046        if self.shared.status.lock().expect("replica status lock").gap {
1047            return;
1048        }
1049        let Some((snapshot, at_seq)) = self.platform.with_view(|view| {
1050            view.latest_snapshot
1051                .as_ref()
1052                .map(|latest| (latest.id().to_hex(), latest.at_seq))
1053        }) else {
1054            return;
1055        };
1056        let key = self.platform.node_key();
1057        let signed = SignedStatement::sign(
1058            key,
1059            WitnessStatement {
1060                format_version: 1,
1061                witness: key.actor_id().to_hex(),
1062                home_node_id: self.home.node_id.to_hex(),
1063                snapshot,
1064                at_seq,
1065                seen_at_seq: self.platform.view_seq().saturating_sub(1),
1066            },
1067        );
1068        let line = format!("{}\n", signed.to_json());
1069        let written = std::fs::OpenOptions::new()
1070            .create(true)
1071            .append(true)
1072            .open(witness_path(&self.root))
1073            .and_then(|mut file| file.write_all(line.as_bytes()));
1074        if let Err(e) = written {
1075            // Served either way; the file is how a restart remembers.
1076            self.shared
1077                .status
1078                .lock()
1079                .expect("replica status lock")
1080                .last_error = Some(format!("recording a witness statement: {e}"));
1081        }
1082        let mut statements = self
1083            .shared
1084            .statements
1085            .lock()
1086            .expect("replica statements lock");
1087        statements.push_back(signed);
1088        while statements.len() > WITNESS_HISTORY {
1089            statements.pop_front();
1090        }
1091    }
1092
1093    /// The statements this seed has signed, oldest first.
1094    #[must_use]
1095    pub fn statements(&self) -> Vec<SignedStatement> {
1096        self.shared
1097            .statements
1098            .lock()
1099            .expect("replica statements lock")
1100            .iter()
1101            .cloned()
1102            .collect()
1103    }
1104
1105    /// Fetches `repo`'s objects and refs from the home into the bare
1106    /// repository here, then checks every ref the view names for it
1107    /// against what the repository holds.
1108    ///
1109    /// # Errors
1110    ///
1111    /// The name is not a repository path, or git could not create or
1112    /// fetch into the repository.
1113    pub fn fetch_objects(&self, repo: &str) -> Result<crate::portable::RefCheck, String> {
1114        if !repo.ends_with(".git") {
1115            return Err(format!("`{repo}` is not a repository path"));
1116        }
1117        let bare = crate::repo_path_in(&self.root, repo).map_err(|e| format!("{repo}: {e}"))?;
1118        if !bare.join("HEAD").is_file() {
1119            crate::create_repo_in(&self.root, repo).map_err(|e| format!("creating {repo}: {e}"))?;
1120        }
1121        let out = std::process::Command::new("git")
1122            .args([
1123                "-c",
1124                "credential.helper=",
1125                "fetch",
1126                "--quiet",
1127                "--prune",
1128                "--no-tags",
1129            ])
1130            .arg(format!("{}/{repo}", self.home.url))
1131            .arg("+refs/*:refs/*")
1132            .current_dir(&bare)
1133            .env("GIT_TERMINAL_PROMPT", "0")
1134            // The credential as configuration in the environment, where
1135            // `ps` does not show it, rather than on the argv or in the URL.
1136            .env("GIT_CONFIG_COUNT", "1")
1137            .env("GIT_CONFIG_KEY_0", "http.extraHeader")
1138            .env(
1139                "GIT_CONFIG_VALUE_0",
1140                format!("Authorization: {}", self.credential.basic()),
1141            )
1142            .output()
1143            .map_err(|e| format!("spawn git: {e}"))?;
1144        if !out.status.success() {
1145            return Err(format!(
1146                "git fetch {repo}: {}",
1147                String::from_utf8_lossy(&out.stderr).trim()
1148            ));
1149        }
1150        let held = crate::platform::read_git_refs(&bare)
1151            .ok_or_else(|| format!("{repo}: the fetched repository's refs cannot be read"))?;
1152        let wanted = self
1153            .platform
1154            .with_view(crate::portable::view_refs)
1155            .by_repo
1156            .remove(repo)
1157            .unwrap_or_default();
1158        Ok(crate::portable::check_refs(&wanted, &held))
1159    }
1160
1161    /// One round: the signer table, every log page the home has past this
1162    /// seed's head, then the git objects for every repository the view
1163    /// names. The daemon runs this every [`INTERVAL_SECS`]; tests call it
1164    /// directly.
1165    ///
1166    /// # Errors
1167    ///
1168    /// See [`ReplicaError`]. A halt is recorded in [`Replica::status`] and
1169    /// every later round returns it without contacting the home.
1170    pub fn replicate_once(&self) -> Result<Progress, ReplicaError> {
1171        let outcome = self.round();
1172        let mut status = self.shared.status.lock().expect("replica status lock");
1173        status.head_seq = self.platform.view_seq().checked_sub(1);
1174        match &outcome {
1175            Ok(_) => status.last_error = None,
1176            Err(ReplicaError::Unreachable(why)) => status.last_error = Some(why.clone()),
1177            Err(ReplicaError::Halted { seq, reason }) => {
1178                status.halted = Some((*seq, reason.clone()));
1179            }
1180            Err(ReplicaError::Gap { window_base }) => {
1181                status.gap = true;
1182                status.halted = Some((
1183                    self.platform.view_seq(),
1184                    format!(
1185                        "the home's log now starts at seq {window_base}; everything before it \
1186                         is evicted and this copy cannot continue the chain"
1187                    ),
1188                ));
1189            }
1190        }
1191        outcome.map(|appended| Progress {
1192            appended,
1193            status: status.clone(),
1194        })
1195    }
1196
1197    fn round(&self) -> Result<u64, ReplicaError> {
1198        {
1199            let status = self.shared.status.lock().expect("replica status lock");
1200            if let Some((seq, reason)) = &status.halted {
1201                return Err(ReplicaError::Halted {
1202                    seq: *seq,
1203                    reason: reason.clone(),
1204                });
1205            }
1206        }
1207        self.fetch_signers()?;
1208        let mut appended = 0;
1209        for _ in 0..MAX_PAGES_PER_ROUND {
1210            let page = self.fetch_page(self.platform.view_seq())?;
1211            appended += page.appended;
1212            if page.served == 0 {
1213                break;
1214            }
1215        }
1216        let refs = self.platform.with_view(crate::portable::view_refs);
1217        let (mut verified, mut pending) = (0, refs.unbacked.len());
1218        let mut failure = None;
1219        for (repo, wanted) in &refs.by_repo {
1220            match self.fetch_objects(repo) {
1221                Ok(check) => {
1222                    verified += check.matched;
1223                    pending += check.disagreements.len();
1224                }
1225                Err(why) => {
1226                    pending += wanted.len();
1227                    failure.get_or_insert(why);
1228                }
1229            }
1230        }
1231        {
1232            let mut status = self.shared.status.lock().expect("replica status lock");
1233            status.refs_verified = verified;
1234            status.refs_pending = pending;
1235        }
1236        match failure {
1237            Some(why) => Err(ReplicaError::Unreachable(why)),
1238            None => Ok(appended),
1239        }
1240    }
1241}
1242
1243/// Runs `replica` forever, a round every [`INTERVAL_SECS`], on the calling
1244/// thread.
1245///
1246/// A halt is said once and replication stops there while the node keeps
1247/// serving what it verified, unless `strict`, in which case the process
1248/// exits nonzero so the operator's supervisor sees it. A transient failure
1249/// is said when it changes, not every round.
1250pub fn run(replica: &Replica, strict: bool) -> ! {
1251    let mut said: Option<String> = None;
1252    loop {
1253        // The same exit a serving node takes (see `Node::serve_forever`),
1254        // here because an archival seed has no accept loop to take it.
1255        if replica.platform.durability_failed() {
1256            eprintln!(
1257                "choir: the op log is no longer durable, so this seed has stopped \
1258                 replicating; exiting so supervision restarts it"
1259            );
1260            std::process::exit(75);
1261        }
1262        match replica.replicate_once() {
1263            Ok(progress) => {
1264                if progress.appended > 0 {
1265                    eprintln!(
1266                        "choir: seed took {} entries from {} (head {}, refs {} verified, {} \
1267                         pending)",
1268                        progress.appended,
1269                        replica.home.url,
1270                        progress
1271                            .status
1272                            .head_seq
1273                            .map_or_else(|| "none".to_string(), |seq| seq.to_string()),
1274                        progress.status.refs_verified,
1275                        progress.status.refs_pending,
1276                    );
1277                }
1278                said = None;
1279            }
1280            Err(error) => {
1281                let text = error.to_string();
1282                if said.as_deref() != Some(text.as_str()) {
1283                    eprintln!("choir: {text}");
1284                    said = Some(text);
1285                }
1286                if error.is_halt() && strict {
1287                    std::process::exit(1);
1288                }
1289            }
1290        }
1291        std::thread::sleep(std::time::Duration::from_secs(INTERVAL_SECS));
1292    }
1293}
1294
1295#[cfg(test)]
1296mod tests {
1297    use super::{Relation, SignedStatement, SnapshotChain, WitnessStatement};
1298
1299    fn statement() -> WitnessStatement {
1300        WitnessStatement {
1301            format_version: 1,
1302            witness: "1e-aa".into(),
1303            home_node_id: "1e-bb".into(),
1304            snapshot: "1e-cc".into(),
1305            at_seq: 4,
1306            seen_at_seq: 9,
1307        }
1308    }
1309
1310    /// The signed bytes are pinned: a reader in another language rebuilds
1311    /// exactly these from SYNC.md, so a reordering is a format change.
1312    #[test]
1313    fn a_statements_signed_bytes_are_its_fields_in_order() {
1314        assert_eq!(
1315            String::from_utf8(statement().canonical_bytes()).unwrap(),
1316            r#"{"format_version":1,"witness":"1e-aa","home_node_id":"1e-bb","snapshot":"1e-cc","at_seq":4,"seen_at_seq":9}"#
1317        );
1318        let back = WitnessStatement::from_json(&statement().to_json()).unwrap();
1319        assert_eq!(back, statement());
1320    }
1321
1322    /// A statement names its witness by key hash, so it verifies only
1323    /// under the key it carries, and one naming anybody else does not.
1324    #[test]
1325    fn a_statement_verifies_only_as_the_witness_it_names() {
1326        let key = choir_identity::ActorKey::generate();
1327        let own = WitnessStatement {
1328            witness: key.actor_id().to_hex(),
1329            ..statement()
1330        };
1331        let signed = SignedStatement::sign(&key, own);
1332        signed.verify().unwrap();
1333        let round = SignedStatement::from_json(&signed.to_json()).unwrap();
1334        assert_eq!(round, signed);
1335        let claimed = SignedStatement::sign(&key, statement());
1336        assert!(claimed.verify().unwrap_err().contains("names witness"));
1337    }
1338
1339    /// The fork rule over a chain a, b, c, with x branching off a.
1340    #[test]
1341    fn two_attestations_agree_by_ancestry_and_fork_otherwise() {
1342        let mut chain = SnapshotChain::default();
1343        chain.insert("a".into(), None);
1344        chain.insert("b".into(), Some("a".into()));
1345        chain.insert("c".into(), Some("b".into()));
1346        chain.insert("x".into(), Some("a".into()));
1347        assert_eq!(chain.relate("c", "c"), Relation::Same);
1348        assert_eq!(chain.relate("a", "c"), Relation::Behind(2));
1349        assert_eq!(chain.relate("c", "b"), Relation::Ahead(1));
1350        assert_eq!(chain.relate("x", "c"), Relation::Fork);
1351        assert_eq!(chain.relate("nowhere", "c"), Relation::Fork);
1352    }
1353}