This repository has no description
1use std::sync::Arc;
2
3use axum::Router;
4use axum::body::Body;
5use axum::http::{Request, StatusCode, header};
6use http_body_util::BodyExt;
7use knot_git::{
8 EntryKind, Identity, Layout, NewCommit, RefUpdate, Repo, StagedAction, StagedChange,
9};
10use knot_pack::{PackError, PackLimits, RepoLookup, RepoResolver, RepoTarget, ingest_pack};
11use knot_types::{AuthorName, BranchName, Email, ObjectFormat, Oid, RefName, RepoDid, UnixSeconds};
12use tempfile::TempDir;
13use tower::ServiceExt;
14
15mod common;
16use common::pkt;
17
18fn serve_dids() -> Arc<dyn RepoResolver> {
19 Arc::new(|target: &RepoTarget| match target {
20 RepoTarget::Did(did) => RepoLookup::Hosted(did.clone()),
21 RepoTarget::OwnerPath(_, _) => RepoLookup::Unhosted,
22 })
23}
24
25fn commit_blob(repo: &Repo, path: &str, content: &[u8], parents: Vec<Oid>) -> Oid {
26 let base = Oid::from(gix::ObjectId::empty_tree(repo.object_format().kind()));
27 let id = Identity {
28 name: AuthorName::new("nel"),
29 email: Email::new("nel@oyster.cafe"),
30 time: UnixSeconds::new(1_700_000_000),
31 offset_seconds: 0,
32 };
33 let tree = repo
34 .write_staged_tree(
35 base,
36 &[StagedChange {
37 path: knot_types::RepoPath::new(path).unwrap(),
38 action: StagedAction::Put {
39 content: content.to_vec(),
40 kind: EntryKind::Blob,
41 },
42 }],
43 )
44 .unwrap();
45 repo.write_commit(&NewCommit {
46 tree,
47 parents,
48 author: id.clone(),
49 committer: id,
50 message: "c".to_string(),
51 extra_headers: Vec::new(),
52 })
53 .unwrap()
54}
55
56fn create_ref(repo: &Repo, name: &str, new: Oid) {
57 repo.update_ref(&RefUpdate::Create {
58 name: RefName::new(name).unwrap(),
59 new,
60 })
61 .unwrap();
62}
63
64fn seed(format: ObjectFormat) -> (TempDir, Layout, RepoDid, Oid) {
65 let dir = tempfile::tempdir().unwrap();
66 let layout = Layout::new(dir.path().join("scan"))
67 .with_object_format(format)
68 .with_default_branch(BranchName::new("main").unwrap());
69 let did = RepoDid::new("did:plc:squid").unwrap();
70 let repo = layout.create(&did).unwrap();
71 let tip = commit_blob(&repo, "reef.txt", b"kelp forest\n", Vec::new());
72 create_ref(&repo, "refs/heads/main", tip);
73 (dir, layout, did, tip)
74}
75
76fn v2_fetch_body(tip: Oid, server_option: bool) -> Vec<u8> {
77 let mut body = pkt(b"command=fetch\n");
78 body.extend_from_slice(b"0001");
79 body.extend(pkt(format!("want {tip}\n").as_bytes()));
80 if server_option {
81 body.extend(pkt(b"server-option=ci-skip\n"));
82 }
83 body.extend(pkt(b"done\n"));
84 body.extend_from_slice(b"0000");
85 body
86}
87
88async fn post(router: &Router, did: &str, body: Vec<u8>) -> axum::http::Response<Body> {
89 let request = Request::builder()
90 .method("POST")
91 .uri(format!("/{did}/git-upload-pack"))
92 .header("git-protocol", "version=2")
93 .header(
94 header::CONTENT_TYPE,
95 "application/x-git-upload-pack-request",
96 )
97 .body(Body::from(body))
98 .unwrap();
99 router.clone().oneshot(request).await.unwrap()
100}
101
102async fn post_upload(router: &Router, did: &str, body: Vec<u8>) -> Vec<u8> {
103 let response = post(router, did, body).await;
104 assert_eq!(response.status(), StatusCode::OK);
105 response
106 .into_body()
107 .collect()
108 .await
109 .unwrap()
110 .to_bytes()
111 .to_vec()
112}
113
114fn hide_secret_ref(repo: &Repo) {
115 let path = repo.git().git_dir().join("config");
116 let mut config = std::fs::read_to_string(&path).unwrap();
117 config.push_str("\n[uploadpack]\n\thideRefs = refs/heads/secret\n");
118 std::fs::write(&path, config).unwrap();
119}
120
121fn refused(result: Result<Vec<u8>, PackError>) -> bool {
122 matches!(result, Err(PackError::Protocol(_)))
123}
124
125#[test]
126fn the_v2_advertisement_offers_server_option_and_a_fetch_using_it_is_served() {
127 [ObjectFormat::SHA1, ObjectFormat::SHA256]
128 .into_iter()
129 .for_each(|format| {
130 let (_dir, layout, did, tip) = seed(format);
131 let repo = layout.open(&did).unwrap();
132 let advert = knot_pack::advertise_upload(&repo).unwrap();
133 assert!(
134 String::from_utf8_lossy(&advert).contains("server-option"),
135 "{format:?} advert"
136 );
137 let served = knot_pack::upload_pack(&repo, &v2_fetch_body(tip, true)).unwrap();
138 assert!(
139 String::from_utf8_lossy(&served).contains("packfile"),
140 "{format:?} server-option fetch"
141 );
142 });
143}
144
145#[test]
146fn upload_pack_refuses_malformed_unreachable_and_hidden_wants() {
147 let (_dir, layout, did, tip) = seed(ObjectFormat::SHA1);
148 let repo = layout.open(&did).unwrap();
149
150 let dangling = commit_blob(&repo, "dangle.txt", b"dangling\n", Vec::new());
151 assert!(
152 refused(knot_pack::upload_pack(
153 &repo,
154 &v2_fetch_body(dangling, false)
155 )),
156 "unreachable want"
157 );
158
159 let mut malformed = pkt(b"command=fetch\n");
160 malformed.extend_from_slice(b"0001");
161 malformed.extend(pkt(b"want not-a-valid-object-id\n"));
162 malformed.extend(pkt(b"done\n"));
163 malformed.extend_from_slice(b"0000");
164 assert!(
165 refused(knot_pack::upload_pack(&repo, &malformed)),
166 "malformed want line"
167 );
168
169 let writer = layout.open(&did).unwrap();
170 let secret = commit_blob(&writer, "secret.txt", b"hidden\n", vec![tip]);
171 create_ref(&writer, "refs/heads/secret", secret);
172 hide_secret_ref(&writer);
173
174 let repo = layout.open(&did).unwrap();
175 let named = |scope| {
176 repo.advertised_refs_for(scope)
177 .unwrap()
178 .iter()
179 .any(|record| record.name.as_str() == "refs/heads/secret")
180 };
181 assert!(
182 named(knot_git::AdvertScope::Receive),
183 "hidden ref still public on receive advert"
184 );
185 assert!(
186 !named(knot_git::AdvertScope::Upload),
187 "hideRefs strips it from upload advert"
188 );
189 assert!(
190 refused(knot_pack::upload_pack(&repo, &v2_fetch_body(secret, false))),
191 "upload-hidden ref by oid"
192 );
193}
194
195#[tokio::test]
196async fn the_pack_cache_replays_then_invalidates_when_a_ref_is_hidden() {
197 let (dir, layout, did, tip) = seed(ObjectFormat::SHA1);
198 let writer = layout.open(&did).unwrap();
199 let secret = commit_blob(&writer, "secret.txt", b"hidden\n", vec![tip]);
200 create_ref(&writer, "refs/heads/secret", secret);
201
202 let router = knot_pack::router(
203 layout,
204 serve_dids(),
205 std::sync::Arc::new(knot_runtime::SystemClock),
206 );
207
208 let body = v2_fetch_body(tip, false);
209 let first = post_upload(&router, did.as_str(), body.clone()).await;
210 let second = post_upload(&router, did.as_str(), body).await;
211 assert_eq!(
212 first, second,
213 "a cache hit replays the leader's bytes exactly"
214 );
215 let fork = Repo::create(dir.path().join("fork.git")).unwrap();
216 ingest_pack(
217 &fork.objects_dir(),
218 &common::unsideband(&first),
219 &PackLimits::default(),
220 fork.object_format().kind(),
221 )
222 .unwrap();
223 assert!(
224 fork.contains(tip),
225 "the cached pack contains the wanted tip"
226 );
227
228 let secret_body = v2_fetch_body(secret, false);
229 let warm = post_upload(&router, did.as_str(), secret_body.clone()).await;
230 assert!(
231 !common::unsideband(&warm).is_empty(),
232 "the visible secret want is served and cached"
233 );
234
235 hide_secret_ref(&writer);
236 let after = post(&router, did.as_str(), secret_body).await.status();
237 assert_eq!(
238 after,
239 StatusCode::BAD_REQUEST,
240 "once hidden the cached pack isn't replayed"
241 );
242}
243
244fn maint_opts() -> knot_maintenance::Options {
245 knot_maintenance::Options {
246 repack_max_objects: knot_maintenance::ObjectCount::new(1_000_000),
247 geometric_factor: knot_maintenance::GeometricFactor::full_repack(),
248 prune_grace: knot_maintenance::PruneGrace::from_secs(0),
249 reflog_floor: knot_maintenance::ReflogRetention::from_secs(i64::MAX as u64 / 4),
250 commit_graph: false,
251 multi_pack_index: false,
252 bitmap: true,
253 }
254}
255
256fn pack_object_count(pack: &[u8]) -> u32 {
257 assert_eq!(
258 &pack[..4],
259 b"PACK",
260 "a served body begins with the pack signature"
261 );
262 u32::from_be_bytes([pack[8], pack[9], pack[10], pack[11]])
263}
264
265#[test]
266fn the_bitmap_fast_path_serves_the_same_pack_as_the_object_walk() {
267 [ObjectFormat::SHA1, ObjectFormat::SHA256]
268 .into_iter()
269 .for_each(|format| {
270 let (dir, layout, did, tip) = seed(format);
271 let repo = layout.open(&did).unwrap();
272 let body = v2_fetch_body(tip, false);
273 let walk = common::unsideband(&knot_pack::upload_pack(&repo, &body).unwrap());
274
275 let now = UnixSeconds::new(1_700_000_500);
276 assert!(
277 knot_maintenance::run_repo(&repo, now, &maint_opts())
278 .unwrap()
279 .bitmap,
280 "{format:?} seed packs a bitmap"
281 );
282
283 let fast = common::unsideband(&knot_pack::upload_pack(&repo, &body).unwrap());
284 assert_eq!(
285 pack_object_count(&fast),
286 pack_object_count(&walk),
287 "{format:?} reuse vs walk count"
288 );
289
290 let did = RepoDid::new("did:plc:clam").unwrap();
291 let fork = Layout::new(dir.path().join("fork"))
292 .with_object_format(format)
293 .create(&did)
294 .unwrap();
295 ingest_pack(
296 &fork.objects_dir(),
297 &fast,
298 &PackLimits::default(),
299 fork.object_format().kind(),
300 )
301 .unwrap();
302 let closure: std::collections::HashSet<Oid> = fork
303 .select_pack_objects(knot_git::Wants::new(&[tip]), knot_git::Haves::new(&[]))
304 .unwrap()
305 .into_iter()
306 .collect();
307 assert!(
308 fork.contains(tip) && closure.len() as u32 == pack_object_count(&fast),
309 "{format:?} fast-path ingests as tip closure"
310 );
311 });
312}