Skip to main content

lonca_audit/
lib.rs

1//! The independent auditor ("the gatekeeper's gatekeeper").
2//!
3//! Reads a repository (the daemon's store, a pushed remote or a CI checkout)
4//! without writing to it and checks that:
5//! 1. the operation log is an unbroken, correctly signed hash chain whose
6//!    time never goes backwards;
7//! 2. every decision is reproduced by the decision function from the state
8//!    before it and the policy in force at that point;
9//! 3. every recorded fact set equals the facts recomputed from the Git
10//!    objects, and every merged commit equals the recomputed merge, both
11//!    with the symbol engine version recorded in the entry;
12//! 4. every daemon key switch (`daemon.rotate`, `daemon.recover`) was
13//!    authorised: its decision replays as allowed, so a forged human
14//!    signature can not move the log to another key;
15//! 5. trunk and policy refs equal the replayed state;
16//! 6. (`compare`) a pushed log is a prefix of the local one, so history that
17//!    was published can not be rewritten unnoticed.
18//!
19//! It contains no AI and calls no network.
20
21#![forbid(unsafe_code)]
22// The trusted core: a panic in the single writer stops every repository,
23// so bad input must become an error or a denial (clippy.toml lets tests panic).
24#![warn(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)]
25
26use std::collections::BTreeMap;
27use std::sync::Arc;
28
29use lonca_core::oplog::{OpLog, SignedOp, verify_chain};
30use lonca_core::ops::Op;
31use lonca_core::{ChangeId, GitId, Result, Store};
32use lonca_policy::config::{CONFIG_FILE, Config, POLICY_FILE, PolicyBundle};
33use lonca_policy::decide::{Ctx, decide};
34use lonca_policy::state::State;
35use lonca_symbols::engine;
36use lonca_symbols::integrate::{facts_of, integrate_joining};
37use serde::Serialize;
38
39pub const TRUNK_REF: &str = "refs/heads/main";
40pub const POLICY_REF: &str = "refs/lonca/policy";
41
42#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
43pub struct Finding {
44    pub seq: Option<u64>,
45    pub kind: String,
46    pub detail: String,
47}
48
49#[derive(Clone, Debug, Default, Serialize)]
50pub struct Report {
51    pub ops: u64,
52    pub head_hash: String,
53    pub findings: Vec<Finding>,
54    /// Rechecks skipped because objects were missing (e.g. a shallow clone).
55    pub skipped: Vec<Finding>,
56    /// Every daemon key switch along the log, for the reader to see.
57    pub key_switches: Vec<KeySwitch>,
58}
59
60#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
61pub struct KeySwitch {
62    pub seq: u64,
63    /// `daemon.rotate` or `daemon.recover`.
64    pub kind: String,
65    /// Who requested it (`human:<name>`).
66    pub actor: String,
67    pub key: String,
68}
69
70impl Report {
71    pub fn ok(&self) -> bool {
72        self.findings.is_empty()
73    }
74
75    fn find(&mut self, seq: Option<u64>, kind: &str, detail: impl Into<String>) {
76        self.findings.push(Finding {
77            seq,
78            kind: kind.into(),
79            detail: detail.into(),
80        });
81    }
82
83    fn skip(&mut self, seq: u64, kind: &str, detail: impl Into<String>) {
84        self.skipped.push(Finding {
85            seq: Some(seq),
86            kind: kind.into(),
87            detail: detail.into(),
88        });
89    }
90}
91
92#[derive(Clone, Copy, Debug)]
93pub struct Options {
94    /// Recompute facts and merges (slower, needs every object).
95    pub deep: bool,
96    /// Compare the refs with the replayed state: trunk, policy, every
97    /// proposal's ref, and no name under `refs/lonca/` the daemon never
98    /// writes.
99    pub check_refs: bool,
100    /// Hold every ref to that, not only Lonca's: for the daemon's own store,
101    /// or a remote nothing but Lonca pushes to. A clone has its remotes'
102    /// branches and tags as well, which are not the log's.
103    pub all_refs: bool,
104}
105
106impl Default for Options {
107    fn default() -> Self {
108        Self {
109            deep: true,
110            check_refs: true,
111            all_refs: false,
112        }
113    }
114}
115
116fn load_policy(store: &Store, commit: &GitId) -> std::result::Result<PolicyBundle, String> {
117    let read = |f: &str| -> std::result::Result<String, String> {
118        let bytes = store
119            .read_file(commit, f)
120            .map_err(|e| e.to_string())?
121            .ok_or_else(|| format!("missing {f}"))?;
122        String::from_utf8(bytes).map_err(|_| format!("{f} is not UTF-8"))
123    };
124    PolicyBundle::parse(&read(CONFIG_FILE)?, &read(POLICY_FILE)?)
125}
126
127pub fn audit(store: &Store, opts: Options) -> Result<Report> {
128    let ops = OpLog::new(store).read_all()?;
129    Ok(audit_ops(store, &ops, opts))
130}
131
132pub fn audit_ops(store: &Store, ops: &[SignedOp], opts: Options) -> Report {
133    let mut rep = Report {
134        ops: ops.len() as u64,
135        ..Default::default()
136    };
137    match verify_chain(ops) {
138        Ok(h) => rep.head_hash = h,
139        Err(e) => {
140            rep.find(None, "chain", e.to_string());
141            return rep;
142        }
143    }
144    let default_policy = Arc::new(PolicyBundle::default_bundle());
145    let mut policies: BTreeMap<GitId, Arc<PolicyBundle>> = BTreeMap::new();
146    let mut state = State::new();
147    for op in ops {
148        let e = &op.entry;
149        let policy = match &state.policy {
150            None => default_policy.clone(),
151            Some(id) => match policies.get(id) {
152                Some(p) => p.clone(),
153                None => match load_policy(store, id) {
154                    Ok(p) => {
155                        let p = Arc::new(p);
156                        policies.insert(id.clone(), p.clone());
157                        p
158                    }
159                    Err(err) => {
160                        rep.find(
161                            Some(e.seq),
162                            "policy",
163                            format!("policy {} unreadable: {err}", id.short()),
164                        );
165                        return rep;
166                    }
167                },
168            },
169        };
170        let engine_known = match e.engine().map(engine::check) {
171            Some(Err(err)) => {
172                rep.find(Some(e.seq), "engine", err.to_string());
173                false
174            }
175            _ => true,
176        };
177        if opts.deep && engine_known {
178            recheck_objects(store, &state, &policy.config, op, &mut rep);
179        }
180        let d = decide(
181            &state,
182            &e.actor,
183            &e.op,
184            e.human.as_ref(),
185            &Ctx {
186                format: e.format,
187                time_ms: e.time_ms,
188                policy: &policy,
189                facts: e.facts.as_ref(),
190                evidence: &e.evidence,
191                symbols_engine: e.symbols_engine,
192            },
193        );
194        if let Some(key) = e.switches_daemon_key() {
195            rep.key_switches.push(KeySwitch {
196                seq: e.seq,
197                kind: e.op.kind().as_str().into(),
198                actor: e.actor.label(),
199                key: key.into(),
200            });
201        }
202        if d != e.decision {
203            let kind = if e.op.daemon_key_switch().is_some() {
204                "daemon_key"
205            } else {
206                "decision"
207            };
208            rep.find(
209                Some(e.seq),
210                kind,
211                format!(
212                    "{}: recorded {:?} {:?}, replay gives {:?} {:?}",
213                    e.op.kind().as_str(),
214                    e.decision.outcome,
215                    e.decision.reasons,
216                    d.outcome,
217                    d.reasons
218                ),
219            );
220        }
221        state.apply(op);
222    }
223    if opts.check_refs {
224        for (name, want) in [(TRUNK_REF, &state.trunk), (POLICY_REF, &state.policy)] {
225            match store.ref_get(name) {
226                Ok(found) if found.as_ref() == want.as_ref() => {}
227                Ok(found) => rep.find(
228                    None,
229                    "ref",
230                    format!("{name} is {found:?}, the log says {want:?}"),
231                ),
232                Err(err) => rep.find(None, "ref", format!("{name}: {err}")),
233            }
234        }
235        check_other_refs(store, &state, opts.all_refs, &mut rep);
236    }
237    rep
238}
239
240/// The refs besides trunk and policy, as the daemon's `reconcile()` holds
241/// them (`lonca_policy::refs`). A proposal's ref past the end of the log is
242/// reported too: the log was cut short, or the daemon stopped between
243/// writing the ref and logging the proposal (it removes such a ref when it
244/// starts).
245fn check_other_refs(store: &Store, state: &State, all: bool, rep: &mut Report) {
246    use lonca_policy::refs::{RefCheck, check, missing_changes};
247    let refs = match store.refs_with_prefix(if all { "refs/" } else { "refs/lonca/" }) {
248        Ok(r) => r,
249        Err(err) => return rep.find(None, "ref", format!("listing refs: {err}")),
250    };
251    let present = refs.iter().map(|(n, _)| n.clone()).collect();
252    for (name, found) in &refs {
253        if name == TRUNK_REF || name == POLICY_REF {
254            continue;
255        }
256        let said = match check(state, name, found) {
257            RefCheck::Fine => continue,
258            RefCheck::Differs(want) => format!("{name} is {found}, the log says {want:?}"),
259            RefCheck::NotLogged => {
260                format!("{name} names a proposal past the end of the log")
261            }
262            RefCheck::Unknown => format!("{name} ({found}) is no ref the log has"),
263        };
264        rep.find(None, "ref", said);
265    }
266    for (name, commit) in missing_changes(state, &present) {
267        rep.find(
268            None,
269            "ref",
270            format!("{name} is missing; the log says {commit}"),
271        );
272    }
273}
274
275/// Recomputes facts and merges. `state` is the one before `op`: what a merge
276/// may join comes from it, as it did in the daemon.
277fn recheck_objects(store: &Store, state: &State, config: &Config, op: &SignedOp, rep: &mut Report) {
278    let e = &op.entry;
279    let engine = e.engine().unwrap_or(engine::ENGINE_CURRENT);
280    match &e.op {
281        Op::ChangePropose { base, commit, .. } => {
282            if !store.has_object(commit) || !store.has_object(base) {
283                rep.skip(e.seq, "facts", "proposal objects not present");
284                return;
285            }
286            match facts_of(store, engine, base, commit) {
287                Ok(a) if Some(&a.facts) == e.facts.as_ref() => {}
288                Ok(a) => rep.find(
289                    Some(e.seq),
290                    "facts",
291                    format!("recorded facts differ from recomputed {:?}", a.facts),
292                ),
293                Err(err) => rep.find(Some(e.seq), "facts", err.to_string()),
294            }
295        }
296        Op::ChangeIntegrate {
297            change,
298            trunk_before,
299            merged,
300            conflicts,
301        } => {
302            let Some(rec) = state.changes.get(change) else {
303                return;
304            };
305            if !store.has_object(&rec.commit) || !store.has_object(trunk_before) {
306                rep.skip(e.seq, "merge", "integration objects not present");
307                return;
308            }
309            let joins = state.joins(*change, &[], config);
310            let may_join = |s: &str| joins.allows(s);
311            match integrate_joining(
312                store,
313                engine,
314                &rec.base,
315                trunk_before,
316                &rec.commit,
317                &may_join,
318            ) {
319                Ok(i) => {
320                    if &i.merged != merged || &i.conflicts != conflicts {
321                        rep.find(
322                            Some(e.seq),
323                            "merge",
324                            format!(
325                                "recorded {merged:?} {conflicts:?}, recomputed {:?} {:?}",
326                                i.merged, i.conflicts
327                            ),
328                        );
329                    }
330                }
331                Err(err) => rep.find(Some(e.seq), "merge", err.to_string()),
332            }
333        }
334        Op::ChangeIntegrateBatch {
335            trunk_before,
336            merged,
337        } => {
338            let mut trunk = trunk_before.clone();
339            let members: Vec<ChangeId> = merged.iter().map(|(c, _)| *c).collect();
340            for (k, (change, commit)) in merged.iter().enumerate() {
341                let Some(rec) = state.changes.get(change) else {
342                    return;
343                };
344                if !store.has_object(&rec.commit) || !store.has_object(&trunk) {
345                    rep.skip(e.seq, "merge", "integration objects not present");
346                    return;
347                }
348                let joins = state.joins(*change, members.get(..k).unwrap_or_default(), config);
349                let may_join = |s: &str| joins.allows(s);
350                match integrate_joining(store, engine, &rec.base, &trunk, &rec.commit, &may_join) {
351                    Ok(i) if i.merged.as_ref() == Some(commit) => {}
352                    Ok(i) => {
353                        let found = format!("recorded {commit:?}, recomputed {:?}", i.merged);
354                        rep.find(Some(e.seq), "merge", found);
355                        return;
356                    }
357                    Err(err) => {
358                        rep.find(Some(e.seq), "merge", err.to_string());
359                        return;
360                    }
361                }
362                trunk = commit.clone();
363            }
364            // What was verified is the tip, which is what trunk became.
365            match store.read_commit(&trunk) {
366                Ok(tip)
367                    if !e.evidence.is_empty()
368                        && e.evidence.iter().all(|a| a.subject_tree == tip.tree) => {}
369                Ok(_) => rep.find(Some(e.seq), "merge", "evidence of another tree".to_string()),
370                Err(err) => rep.find(Some(e.seq), "merge", err.to_string()),
371            }
372        }
373        _ => {}
374    }
375}
376
377/// Checks that `published` is a prefix of `local`: everything that was
378/// pushed is still in the local log, unchanged.
379pub fn compare(local: &[SignedOp], published: &[SignedOp]) -> Vec<Finding> {
380    let mut out = Vec::new();
381    if published.len() > local.len() {
382        out.push(Finding {
383            seq: Some(local.len() as u64),
384            kind: "anchor".into(),
385            detail: format!(
386                "published log has {} entries, local only {}",
387                published.len(),
388                local.len()
389            ),
390        });
391    }
392    for (l, p) in local.iter().zip(published) {
393        if l.hash != p.hash {
394            out.push(Finding {
395                seq: Some(p.entry.seq),
396                kind: "anchor".into(),
397                detail: "local history differs from what was published".into(),
398            });
399            break;
400        }
401    }
402    out
403}