This repository has no description
1mod common;
2
3use common::{oid_of, repo};
4use knot_lfs::{
5 ClaimedSize, DiskStore, LfsError, LfsOid, LfsSize, LfsStore, LfsStorePath, MemoryStore,
6};
7use proptest::prelude::*;
8
9fn round_trip(store: &dyn LfsStore, body: &[u8]) -> Result<(), TestCaseError> {
10 let repo = repo();
11 let oid = oid_of(body);
12 let bytes = body.len() as u64;
13 store
14 .put(&repo, &oid, ClaimedSize::new(bytes), &mut &body[..])
15 .expect("put with matching size and oid succeeds");
16 prop_assert_eq!(
17 store.probe(&repo, &oid).expect("probe"),
18 Some(LfsSize::new(bytes))
19 );
20 let mut out = Vec::new();
21 store
22 .read(&repo, &oid)
23 .expect("stored object opens")
24 .read_to_end(&mut out)
25 .expect("stored object reads");
26 prop_assert_eq!(out, body);
27 Ok(())
28}
29
30#[derive(Debug, Clone)]
31enum Tamper {
32 Flip { at: usize, xor: u8 },
33 Truncate { keep: usize },
34 Extend { extra: Vec<u8> },
35}
36
37fn tampered(body: &[u8], tamper: &Tamper) -> Vec<u8> {
38 match tamper {
39 Tamper::Flip { at, xor } => {
40 let mut bytes = body.to_vec();
41 bytes[at % body.len()] ^= xor;
42 bytes
43 }
44 Tamper::Truncate { keep } => body[..keep % body.len()].to_vec(),
45 Tamper::Extend { extra } => [body, extra].concat(),
46 }
47}
48
49fn tamper_strategy() -> impl Strategy<Value = Tamper> {
50 prop_oneof![
51 (any::<usize>(), 1u8..).prop_map(|(at, xor)| Tamper::Flip { at, xor }),
52 any::<usize>().prop_map(|keep| Tamper::Truncate { keep }),
53 proptest::collection::vec(any::<u8>(), 1..64).prop_map(|extra| Tamper::Extend { extra }),
54 ]
55}
56
57fn rejects_tampering(
58 store: &dyn LfsStore,
59 body: &[u8],
60 tamper: &Tamper,
61) -> Result<(), TestCaseError> {
62 let repo = repo();
63 let oid = oid_of(body);
64 let size = ClaimedSize::new(body.len() as u64);
65 let forged = tampered(body, tamper);
66 let verdict = store.put(&repo, &oid, size, &mut &forged[..]);
67 prop_assert!(
68 matches!(
69 verdict,
70 Err(LfsError::HashMismatch { .. } | LfsError::SizeMismatch { .. })
71 ),
72 "a tampered body must fail the verifier, got {verdict:?}"
73 );
74 prop_assert_eq!(store.probe(&repo, &oid).expect("probe"), None);
75 Ok(())
76}
77
78fn disk_store() -> (DiskStore, tempfile::TempDir) {
79 let dir = tempfile::tempdir().expect("tempdir");
80 let store = DiskStore::open(LfsStorePath::new(dir.path())).expect("store opens");
81 (store, dir)
82}
83
84proptest! {
85 #![proptest_config(ProptestConfig::with_cases(64))]
86
87 #[test]
88 fn any_body_round_trips_through_both_stores(
89 body in proptest::collection::vec(any::<u8>(), 0..4096)
90 ) {
91 round_trip(&MemoryStore::new(), &body)?;
92 let (store, _dir) = disk_store();
93 round_trip(&store, &body)?;
94 }
95
96 #[test]
97 fn the_verifier_rejects_any_tampered_or_truncated_body(
98 body in proptest::collection::vec(any::<u8>(), 1..2048),
99 tamper in tamper_strategy(),
100 ) {
101 prop_assume!(!matches!(&tamper, Tamper::Flip { xor: 0, .. }));
102 rejects_tampering(&MemoryStore::new(), &body, &tamper)?;
103 let (store, _dir) = disk_store();
104 rejects_tampering(&store, &body, &tamper)?;
105 }
106
107 #[test]
108 fn a_pointer_file_round_trips(digest in any::<[u8; 32]>(), size in any::<u64>()) {
109 let oid = LfsOid::from_digest(digest);
110 let text = format!(
111 "version https://git-lfs.github.com/spec/v1\noid sha256:{oid}\nsize {size}\n"
112 );
113 let parsed = knot_lfs::parse_pointer(text.as_bytes()).expect("a spec pointer parses");
114 prop_assert_eq!(parsed.oid, oid);
115 prop_assert_eq!(parsed.size, ClaimedSize::new(size));
116 }
117}