1#![forbid(unsafe_code)]
22#![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 pub skipped: Vec<Finding>,
56 pub key_switches: Vec<KeySwitch>,
58}
59
60#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
61pub struct KeySwitch {
62 pub seq: u64,
63 pub kind: String,
65 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 pub deep: bool,
96 pub check_refs: bool,
100 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
240fn 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
275fn 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 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
377pub 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}