1use 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
21#[non_exhaustive]
22pub struct PushReport {
23 pub verified: usize,
26 pub skipped_known: usize,
29 pub missing: Vec<Hash>,
32 pub corrupt: Vec<(Hash, String)>,
35 pub bad_signatures: Vec<(Hash, String)>,
38 pub bad_tips: Vec<(Hash, ObjectType)>,
40}
41
42impl PushReport {
43 #[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
53pub 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 #[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 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 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 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}