Skip to main content

mkit_core/verify/
push.rs

1//! Incremental push verification: check the history a push introduces
2//! before any ref moves (PRD §6.5, indexed mode).
3//!
4//! [`verify_push`] walks each new tip's closure through the same BFS as
5//! the closure verifiers (`closure::walk`, edges from
6//! [`crate::ops::graph::children`]), re-derives every object id, checks
7//! commit, remix and tag signatures with
8//! [`crate::sign::verify_object_signature`], and stops at a
9//! caller-supplied frontier of objects this repository already verified.
10
11use crate::hash::Hash;
12use crate::object::ObjectType;
13use crate::ops::graph::ClosureMode;
14use crate::verify::VerifyError;
15
16use super::closure::{ObjectSource, RootRule, walk};
17
18/// What [`verify_push`] found. The push is acceptable only when
19/// [`Self::is_accepted`] holds.
20#[derive(Debug, Clone, Default, PartialEq, Eq)]
21#[non_exhaustive]
22pub struct PushReport {
23    /// Objects fetched whose id re-derived correctly (their signatures,
24    /// if any, were checked; failures are in [`Self::bad_signatures`]).
25    pub verified: usize,
26    /// Frontier stops: ids the caller's `known` accepted. Never fetched,
27    /// never descended.
28    pub skipped_known: usize,
29    /// Referenced by a new object (or a tip) but not in the source:
30    /// the closure is not closed. Sorted.
31    pub missing: Vec<Hash>,
32    /// Bytes that do not deserialize, or whose derived id is not the
33    /// requested id, reported under the requested id. Sorted by id.
34    pub corrupt: Vec<(Hash, String)>,
35    /// Commits, remixes and tags whose signature does not verify under
36    /// their embedded signer. Sorted by id.
37    pub bad_signatures: Vec<(Hash, String)>,
38    /// Tips that are not a commit, remix or tag. Sorted by id.
39    pub bad_tips: Vec<(Hash, ObjectType)>,
40}
41
42impl PushReport {
43    /// True iff nothing is missing, corrupt, badly signed, or a bad tip.
44    #[must_use]
45    pub fn is_accepted(&self) -> bool {
46        self.missing.is_empty()
47            && self.corrupt.is_empty()
48            && self.bad_signatures.is_empty()
49            && self.bad_tips.is_empty()
50    }
51}
52
53/// Verify the history a push introduces, before refs move.
54///
55/// Breadth-first from every id in `new_tips` over
56/// [`crate::ops::graph::children`]`(obj, mode)`, fetching each id at
57/// most once from `source`. Every fetched object is deserialized and its
58/// id re-derived; each commit, remix and tag has its signature checked.
59/// Remix `sources` and `Delta.base_hash` are never followed. Everything
60/// wrong is collected into the [`PushReport`] rather than stopping at the
61/// first problem, so a server can reject with a complete reason.
62///
63/// # Repository isolation (PRD §6.5)
64///
65/// - `source` MUST serve only this repository's members plus the objects
66///   of the push being verified. A source backed by a global content
67///   store, or by another repository, turns this check into a cross-repo
68///   existence oracle and lets a push "close" over objects its
69///   repository never held.
70/// - `known(id)` is a frontier: ids for which it returns `true` are
71///   neither fetched nor descended. It MUST return `true` only for
72///   objects whose **whole closure in this `mode` was already verified
73///   in this repository** — never for "exists somewhere", and never for
74///   an object merely present in the pushed pack. A `known` tip is
75///   skipped entirely, including its commit/remix/tag type check; the
76///   caller's index already knows that object's type.
77///
78/// # Errors
79///
80/// Only a `source` error, or [`VerifyError::TooManyClosureObjects`] once
81/// the walk reaches more than [`crate::pack::MAX_ENTRIES`] ids (frontier
82/// stops included). Verification failures are report entries, not errors.
83pub fn verify_push(
84    new_tips: &[Hash],
85    mode: ClosureMode,
86    source: &mut impl ObjectSource,
87    known: impl FnMut(&Hash) -> bool,
88) -> Result<PushReport, VerifyError> {
89    let mut bad_signatures = Vec::new();
90    let walked = walk(
91        new_tips,
92        mode,
93        source,
94        known,
95        RootRule::Record,
96        |id, obj| {
97            if let Err(error) = crate::sign::verify_object_signature(obj) {
98                bad_signatures.push((*id, error.to_string()));
99            }
100            Ok(())
101        },
102    )?;
103    bad_signatures.sort_by_key(|(id, _)| *id);
104    Ok(PushReport {
105        verified: walked.verified,
106        skipped_known: walked.skipped_known,
107        missing: walked.missing,
108        corrupt: walked.corrupt,
109        bad_signatures,
110        bad_tips: walked.bad_roots,
111    })
112}
113
114#[cfg(test)]
115#[allow(clippy::unwrap_used)]
116mod tests {
117    use super::*;
118    use std::borrow::Cow;
119    use std::collections::{BTreeMap, BTreeSet};
120
121    use crate::hash::ZERO;
122    use crate::object::{
123        Blob, ChunkedBlob, Commit, EntryMode, Identity, Object, Remix, RemixSource, Tag, Tree,
124        TreeEntry,
125    };
126    use crate::sign::{KeyPair, sign_commit, sign_remix, sign_tag};
127
128    /// In-memory source that counts fetches and panics on ids listed in
129    /// `forbidden` or absent from both `objects` and `missing`.
130    #[derive(Default)]
131    struct Source {
132        objects: BTreeMap<Hash, Vec<u8>>,
133        forbidden: BTreeSet<Hash>,
134        fetches: BTreeMap<Hash, usize>,
135    }
136
137    impl Source {
138        fn put(&mut self, obj: &Object) -> Hash {
139            let bytes = crate::serialize::serialize(obj).unwrap();
140            let id = crate::object::id_from_object(obj, &bytes);
141            self.objects.insert(id, bytes);
142            id
143        }
144
145        fn blob(&mut self, data: &[u8]) -> Hash {
146            self.put(&Object::Blob(Blob {
147                data: data.to_vec(),
148            }))
149        }
150
151        fn tree(&mut self, entries: &[(&[u8], Hash)]) -> Hash {
152            self.put(&Object::Tree(Tree {
153                entries: entries
154                    .iter()
155                    .map(|(name, id)| TreeEntry {
156                        name: name.to_vec(),
157                        mode: EntryMode::Blob,
158                        object_hash: *id,
159                    })
160                    .collect(),
161            }))
162        }
163
164        fn commit(&mut self, tree_hash: Hash, parents: Vec<Hash>, msg: &[u8]) -> Hash {
165            let commit = signed_commit(tree_hash, parents, msg);
166            self.put(&Object::Commit(commit))
167        }
168
169        fn assert_each_fetched_at_most_once(&self) {
170            assert!(
171                self.fetches.values().all(|n| *n == 1),
172                "an id was fetched twice: {:?}",
173                self.fetches
174            );
175        }
176    }
177
178    impl ObjectSource for Source {
179        fn fetch(&mut self, id: &Hash) -> Result<Option<Cow<'_, [u8]>>, VerifyError> {
180            assert!(
181                !self.forbidden.contains(id),
182                "forbidden fetch: {}",
183                crate::hash::to_hex(id)
184            );
185            *self.fetches.entry(*id).or_default() += 1;
186            Ok(self.objects.get(id).map(|b| Cow::Borrowed(b.as_slice())))
187        }
188    }
189
190    fn kp() -> KeyPair {
191        KeyPair::from_seed([0x24; 32])
192    }
193
194    fn signed_commit(tree_hash: Hash, parents: Vec<Hash>, msg: &[u8]) -> Commit {
195        let kp = kp();
196        let mut commit = Commit {
197            tree_hash,
198            parents,
199            author: Identity::ed25519(kp.public.0),
200            signer: kp.public.0,
201            message: msg.to_vec(),
202            timestamp: 7,
203            message_hash: ZERO,
204            content_digest: ZERO,
205            signature: [0u8; 64],
206        };
207        commit.signature = sign_commit(&commit, &kp).unwrap().0;
208        commit
209    }
210
211    fn signed_tag(target: Hash, target_type: ObjectType, name: &[u8]) -> Tag {
212        let kp = kp();
213        let mut tag = Tag {
214            target,
215            target_type,
216            name: name.to_vec(),
217            tagger: Identity::ed25519(kp.public.0),
218            signer: kp.public.0,
219            message: b"tag".to_vec(),
220            timestamp: 9,
221            signature: [0u8; 64],
222        };
223        tag.signature = sign_tag(&tag, &kp).unwrap().0;
224        tag
225    }
226
227    /// `c2 -> c1`, each with its own one-file tree.
228    fn two_commits(src: &mut Source) -> (Hash, Hash) {
229        let b1 = src.blob(b"one");
230        let t1 = src.tree(&[(b"a", b1)]);
231        let c1 = src.commit(t1, vec![], b"first");
232        let b2 = src.blob(b"two");
233        let t2 = src.tree(&[(b"a", b2)]);
234        let c2 = src.commit(t2, vec![c1], b"second");
235        (c1, c2)
236    }
237
238    #[test]
239    fn good_push_verifies_all_new_objects() {
240        let mut src = Source::default();
241        let (_c1, c2) = two_commits(&mut src);
242        let report = verify_push(&[c2], ClosureMode::History, &mut src, |_| false).unwrap();
243        assert!(report.is_accepted(), "{report:?}");
244        assert_eq!(report.verified, 6);
245        assert_eq!(report.skipped_known, 0);
246        let all: BTreeSet<Hash> = src.objects.keys().copied().collect();
247        assert_eq!(src.fetches.keys().copied().collect::<BTreeSet<_>>(), all);
248        src.assert_each_fetched_at_most_once();
249    }
250
251    #[test]
252    fn frontier_stop() {
253        let mut src = Source::default();
254        let (c1, c2) = two_commits(&mut src);
255        // c1 and everything only it reaches must never be fetched.
256        let before: BTreeSet<Hash> = src.objects.keys().copied().collect();
257        let mut only_c1 = BTreeSet::from([c1]);
258        let Object::Commit(commit) = crate::serialize::deserialize(&src.objects[&c1]).unwrap()
259        else {
260            panic!("c1 is a commit");
261        };
262        only_c1.insert(commit.tree_hash);
263        let Object::Tree(tree) =
264            crate::serialize::deserialize(&src.objects[&commit.tree_hash]).unwrap()
265        else {
266            panic!("tree");
267        };
268        only_c1.insert(tree.entries[0].object_hash);
269        src.forbidden = only_c1.clone();
270
271        let report = verify_push(&[c2], ClosureMode::History, &mut src, |id| *id == c1).unwrap();
272        assert!(report.is_accepted(), "{report:?}");
273        assert_eq!(report.skipped_known, 1);
274        assert_eq!(report.verified, 3);
275        let fetched: BTreeSet<Hash> = src.fetches.keys().copied().collect();
276        assert_eq!(
277            fetched,
278            before
279                .difference(&only_c1)
280                .copied()
281                .collect::<BTreeSet<_>>()
282        );
283    }
284
285    #[test]
286    fn known_tip_is_noop() {
287        let mut src = Source::default();
288        let (_c1, c2) = two_commits(&mut src);
289        src.forbidden = src.objects.keys().copied().collect();
290        let report = verify_push(&[c2], ClosureMode::History, &mut src, |_| true).unwrap();
291        assert!(report.is_accepted());
292        assert_eq!(report.skipped_known, 1);
293        assert_eq!(report.verified, 0);
294        assert!(src.fetches.is_empty());
295    }
296
297    #[test]
298    fn unsigned_commit_rejected() {
299        let mut src = Source::default();
300        let blob = src.blob(b"x");
301        let tree = src.tree(&[(b"x", blob)]);
302        let mut commit = signed_commit(tree, vec![], b"unsigned");
303        commit.signature = [0u8; 64];
304        let c = src.put(&Object::Commit(commit));
305        let report = verify_push(&[c], ClosureMode::History, &mut src, |_| false).unwrap();
306        assert!(!report.is_accepted());
307        assert_eq!(report.bad_signatures.len(), 1);
308        assert_eq!(report.bad_signatures[0].0, c);
309        assert_eq!(
310            report.verified, 3,
311            "a bad signature still walks the closure"
312        );
313        assert!(report.missing.is_empty() && report.corrupt.is_empty());
314    }
315
316    #[test]
317    fn forged_tag_rejected() {
318        let mut src = Source::default();
319        let (_c1, c2) = two_commits(&mut src);
320        let mut tag = signed_tag(c2, ObjectType::Commit, b"v1");
321        tag.signer = KeyPair::from_seed([0x99; 32]).public.0;
322        let t = src.put(&Object::Tag(tag));
323        let report = verify_push(&[t], ClosureMode::History, &mut src, |_| false).unwrap();
324        assert_eq!(
325            report
326                .bad_signatures
327                .iter()
328                .map(|b| b.0)
329                .collect::<Vec<_>>(),
330            vec![t]
331        );
332        assert!(report.bad_tips.is_empty());
333    }
334
335    #[test]
336    fn remix_signature_checked() {
337        let mut src = Source::default();
338        let blob = src.blob(b"r");
339        let tree = src.tree(&[(b"r", blob)]);
340        let kp = kp();
341        let mut remix = Remix {
342            tree_hash: tree,
343            parents: vec![],
344            sources: vec![],
345            author: Identity::ed25519(kp.public.0),
346            signer: kp.public.0,
347            message: b"remix".to_vec(),
348            timestamp: 3,
349            signature: [0u8; 64],
350        };
351        remix.signature = sign_remix(&remix, &kp).unwrap().0;
352        let good = src.put(&Object::Remix(remix.clone()));
353        let report = verify_push(&[good], ClosureMode::History, &mut src, |_| false).unwrap();
354        assert!(report.is_accepted(), "{report:?}");
355
356        remix.message = b"tampered".to_vec();
357        let bad = src.put(&Object::Remix(remix));
358        let report = verify_push(&[bad], ClosureMode::History, &mut src, |_| false).unwrap();
359        assert_eq!(report.bad_signatures.len(), 1);
360        assert_eq!(report.bad_signatures[0].0, bad);
361    }
362
363    #[test]
364    fn tree_referencing_absent_blob_is_missing() {
365        let mut src = Source::default();
366        let absent = crate::hash::hash(b"never supplied");
367        let tree = src.tree(&[(b"gone", absent)]);
368        let c = src.commit(tree, vec![], b"dangling");
369        let report = verify_push(&[c], ClosureMode::History, &mut src, |_| false).unwrap();
370        assert_eq!(report.missing, vec![absent]);
371        assert!(!report.is_accepted());
372    }
373
374    #[test]
375    fn wrong_bytes_for_id_is_corrupt() {
376        let mut src = Source::default();
377        let blob = src.blob(b"genuine");
378        let tree = src.tree(&[(b"f", blob)]);
379        let c = src.commit(tree, vec![], b"c");
380        let other = crate::serialize::serialize(&Object::Blob(Blob {
381            data: b"impostor".to_vec(),
382        }))
383        .unwrap();
384        src.objects.insert(blob, other);
385        let report = verify_push(&[c], ClosureMode::History, &mut src, |_| false).unwrap();
386        assert_eq!(
387            report.corrupt.iter().map(|c| c.0).collect::<Vec<_>>(),
388            vec![blob]
389        );
390
391        src.objects.insert(blob, b"not an object".to_vec());
392        let report = verify_push(&[c], ClosureMode::History, &mut src, |_| false).unwrap();
393        assert_eq!(
394            report.corrupt.iter().map(|c| c.0).collect::<Vec<_>>(),
395            vec![blob]
396        );
397        assert!(!report.is_accepted());
398    }
399
400    #[test]
401    fn tip_is_tree_is_bad_tip() {
402        let mut src = Source::default();
403        let blob = src.blob(b"t");
404        let tree = src.tree(&[(b"t", blob)]);
405        let report = verify_push(&[tree], ClosureMode::History, &mut src, |_| false).unwrap();
406        assert_eq!(report.bad_tips, vec![(tree, ObjectType::Tree)]);
407        assert!(!report.is_accepted());
408    }
409
410    #[test]
411    fn tag_of_tag_of_commit_walks_through() {
412        let mut src = Source::default();
413        let (_c1, c2) = two_commits(&mut src);
414        let inner = src.put(&Object::Tag(signed_tag(c2, ObjectType::Commit, b"inner")));
415        let outer = src.put(&Object::Tag(signed_tag(inner, ObjectType::Tag, b"outer")));
416        let report = verify_push(&[outer], ClosureMode::History, &mut src, |_| false).unwrap();
417        assert!(report.is_accepted(), "{report:?}");
418        assert_eq!(report.verified, 8);
419        assert_eq!(src.fetches.len(), src.objects.len());
420    }
421
422    #[test]
423    fn chunked_blob_children_walked() {
424        let mut src = Source::default();
425        let chunk_a = src.blob(b"chunk-a");
426        let chunk_b = crate::hash::hash(b"chunk-b never supplied");
427        let manifest = src.put(&Object::ChunkedBlob(ChunkedBlob {
428            total_size: 14,
429            chunk_size: 7,
430            chunks: vec![chunk_a, chunk_b],
431        }));
432        let tree = src.tree(&[(b"big", manifest)]);
433        let c = src.commit(tree, vec![], b"chunked");
434        let report = verify_push(&[c], ClosureMode::History, &mut src, |_| false).unwrap();
435        assert_eq!(report.missing, vec![chunk_b]);
436        assert!(src.fetches.contains_key(&chunk_a));
437        assert_eq!(report.verified, 4);
438    }
439
440    #[test]
441    fn history_vs_snapshot() {
442        let mut src = Source::default();
443        let absent_parent = crate::hash::hash(b"parent not pushed");
444        let blob = src.blob(b"h");
445        let tree = src.tree(&[(b"h", blob)]);
446        let c = src.commit(tree, vec![absent_parent], b"child");
447
448        let history = verify_push(&[c], ClosureMode::History, &mut src, |_| false).unwrap();
449        assert_eq!(history.missing, vec![absent_parent]);
450
451        let snapshot = verify_push(&[c], ClosureMode::Snapshot, &mut src, |_| false).unwrap();
452        assert!(snapshot.is_accepted(), "{snapshot:?}");
453    }
454
455    #[test]
456    fn each_object_fetched_once_across_multiple_tips() {
457        let mut src = Source::default();
458        let (c1, c2) = two_commits(&mut src);
459        let shared_blob = src.blob(b"shared");
460        let t3 = src.tree(&[(b"s", shared_blob)]);
461        let c3 = src.commit(t3, vec![c1], b"branch-b");
462        let t4 = src.tree(&[(b"s", shared_blob), (b"t", shared_blob)]);
463        let c4 = src.commit(t4, vec![c2, c3], b"merge");
464        // Tips listed redundantly, including an ancestor of another tip.
465        let report = verify_push(
466            &[c4, c2, c3, c4, c1],
467            ClosureMode::History,
468            &mut src,
469            |_| false,
470        )
471        .unwrap();
472        assert!(report.is_accepted(), "{report:?}");
473        assert_eq!(report.verified, src.objects.len());
474        assert_eq!(src.fetches.len(), src.objects.len());
475        src.assert_each_fetched_at_most_once();
476    }
477
478    #[test]
479    fn remix_sources_never_followed() {
480        let mut src = Source::default();
481        let blob = src.blob(b"m");
482        let tree = src.tree(&[(b"m", blob)]);
483        let foreign = crate::hash::hash(b"foreign upstream commit");
484        src.forbidden.insert(foreign);
485        let kp = kp();
486        let mut remix = Remix {
487            tree_hash: tree,
488            parents: vec![],
489            sources: vec![RemixSource {
490                upstream_id: crate::hash::hash(b"upstream"),
491                commit_hash: foreign,
492            }],
493            author: Identity::ed25519(kp.public.0),
494            signer: kp.public.0,
495            message: b"remix with source".to_vec(),
496            timestamp: 4,
497            signature: [0u8; 64],
498        };
499        remix.signature = sign_remix(&remix, &kp).unwrap().0;
500        let r = src.put(&Object::Remix(remix));
501        for mode in [ClosureMode::History, ClosureMode::Snapshot] {
502            let report = verify_push(&[r], mode, &mut src, |_| false).unwrap();
503            assert!(report.is_accepted(), "{mode:?}: {report:?}");
504            assert!(report.missing.is_empty());
505        }
506    }
507
508    #[test]
509    fn missing_tip_is_missing() {
510        let mut src = Source::default();
511        let absent = crate::hash::hash(b"tip not pushed");
512        let report = verify_push(&[absent], ClosureMode::History, &mut src, |_| false).unwrap();
513        assert_eq!(report.missing, vec![absent]);
514        assert!(report.bad_tips.is_empty());
515        assert!(!report.is_accepted());
516    }
517
518    #[test]
519    fn empty_tips_is_accepted_noop() {
520        let mut src = Source::default();
521        let report = verify_push(&[], ClosureMode::History, &mut src, |_| false).unwrap();
522        assert_eq!(report, PushReport::default());
523        assert!(report.is_accepted());
524    }
525}