This repository has no description
1use std::io::Write;
2use std::path::{Path, PathBuf};
3use std::process::Stdio;
4use std::sync::Arc;
5
6use axum::http;
7use knot_git::{Layout, Repo, Staging};
8use knot_pack::{FetchError, HaveOids, PackLimits, WantOids, ingest_pack, local_pack, local_refs};
9use knot_runtime::{FakeHttp, HttpRequest, HttpResponse, HttpTransport};
10use knot_types::{Oid, RefName, RepoDid};
11use url::Url;
12
13mod common;
14use common::{commit, must, pack_objects};
15
16fn seed_source(dir: &Path) -> PathBuf {
17 let work = dir.join("work");
18 std::fs::create_dir_all(&work).unwrap();
19 must(&work, &["init", "-q", "-b", "main"]);
20 commit(&work, "reef.txt", "kelp forest\n", "first");
21 commit(&work, "tide.txt", "rock pool\n", "second");
22 must(&work, &["tag", "v1"]);
23 must(&work, &["branch", "anemone"]);
24 let bare = dir.join("source.git");
25 must(
26 dir,
27 &[
28 "clone",
29 "-q",
30 "--bare",
31 work.to_str().unwrap(),
32 bare.to_str().unwrap(),
33 ],
34 );
35 bare
36}
37
38fn stock_git_server(repo: PathBuf) -> Arc<dyn HttpTransport> {
39 Arc::new(FakeHttp::new(move |request: &HttpRequest| {
40 let response = |body: Vec<u8>| {
41 Ok(HttpResponse {
42 status: http::StatusCode::OK,
43 headers: http::HeaderMap::new(),
44 body: body.into(),
45 })
46 };
47 if request.url.path().ends_with("/info/refs") {
48 let out = knot_fixtures::command(&repo)
49 .args([
50 "upload-pack",
51 "--stateless-rpc",
52 "--http-backend-info-refs",
53 ".",
54 ])
55 .env("GIT_PROTOCOL", "version=2")
56 .output()
57 .expect("git upload-pack advertises");
58 assert!(out.status.success());
59 let mut body = b"001e# service=git-upload-pack\n0000".to_vec();
60 body.extend_from_slice(&out.stdout);
61 return response(body);
62 }
63 let mut child = knot_fixtures::command(&repo)
64 .args(["upload-pack", "--stateless-rpc", "."])
65 .env("GIT_PROTOCOL", "version=2")
66 .stdin(Stdio::piped())
67 .stdout(Stdio::piped())
68 .stderr(Stdio::piped())
69 .spawn()
70 .expect("git upload-pack serves");
71 child
72 .stdin
73 .take()
74 .unwrap()
75 .write_all(request.body.as_deref().unwrap_or_default())
76 .unwrap();
77 let out = child.wait_with_output().expect("git upload-pack finishes");
78 assert!(
79 out.status.success(),
80 "upload-pack: {}",
81 String::from_utf8_lossy(&out.stderr)
82 );
83 response(out.stdout)
84 }))
85}
86
87fn knot_server(repo_path: PathBuf) -> Arc<dyn HttpTransport> {
88 Arc::new(FakeHttp::new(move |request: &HttpRequest| {
89 let repo = Repo::open(&repo_path).unwrap();
90 let body = if request.url.path().ends_with("/info/refs") {
91 knot_pack::advertise_upload(&repo).unwrap()
92 } else {
93 knot_pack::upload_pack(&repo, request.body.as_deref().unwrap_or_default()).unwrap()
94 };
95 Ok(HttpResponse {
96 status: http::StatusCode::OK,
97 headers: http::HeaderMap::new(),
98 body: body.into(),
99 })
100 }))
101}
102
103fn base_url() -> Url {
104 Url::parse("https://kelp.oyster.cafe/did:plc:squid/uni").unwrap()
105}
106
107const CAP: u64 = 64 * 1024 * 1024;
108
109fn refnames(records: &[knot_git::RefRecord]) -> Vec<&str> {
110 records.iter().map(|record| record.name.as_str()).collect()
111}
112
113async fn clone_through(http: &dyn HttpTransport, source: &Repo, target: &Repo) {
114 let refs = knot_pack::remote_refs(http, &base_url(), &["HEAD", "refs/heads/", "refs/tags/"])
115 .await
116 .unwrap();
117 assert_eq!(
118 refs.head_symref.as_ref().map(RefName::as_str),
119 Some("refs/heads/main")
120 );
121 let mut expected = source.advertised_refs().unwrap().to_vec();
122 expected.sort_by(|a, b| a.name.as_str().cmp(b.name.as_str()));
123 let mut got = refs.refs.clone();
124 got.sort_by(|a, b| a.name.as_str().cmp(b.name.as_str()));
125 assert_eq!(refnames(&got), refnames(&expected));
126 assert_eq!(got, expected);
127
128 let pack = knot_pack::remote_pack(
129 http,
130 &base_url(),
131 &WantOids::new(refs.tips()),
132 &HaveOids::default(),
133 CAP,
134 )
135 .await
136 .unwrap();
137 ingest_pack(
138 &target.objects_dir(),
139 &pack,
140 &PackLimits::default(),
141 target.object_format().kind(),
142 )
143 .unwrap();
144 let closure = target
145 .select_pack_objects(
146 knot_git::Wants::new(&refs.tips()),
147 knot_git::Haves::new(&[]),
148 )
149 .unwrap();
150 assert!(closure.iter().all(|oid| target.contains(*oid)));
151}
152
153#[tokio::test]
154async fn the_client_clones_from_both_stock_git_and_native_knot_servers() {
155 let dir = tempfile::tempdir().unwrap();
156 let source_path = seed_source(dir.path());
157 let source = Repo::open(&source_path).unwrap();
158
159 let stock = Repo::create(dir.path().join("fork-stock.git")).unwrap();
160 clone_through(
161 stock_git_server(source_path.clone()).as_ref(),
162 &source,
163 &stock,
164 )
165 .await;
166
167 let knot = Repo::create(dir.path().join("fork-knot.git")).unwrap();
168 clone_through(knot_server(source_path.clone()).as_ref(), &source, &knot).await;
169}
170
171#[tokio::test]
172async fn an_incremental_pull_completes_through_staging() {
173 let dir = tempfile::tempdir().unwrap();
174 let source_path = seed_source(dir.path());
175 let source = Repo::open(&source_path).unwrap();
176 let clone = Repo::create(dir.path().join("fork.git")).unwrap();
177 let http = knot_server(source_path.clone());
178 clone_through(http.as_ref(), &source, &clone).await;
179 let main = RefName::new("refs/heads/main").unwrap();
180 let old_tip = source.find_ref(&main).unwrap().unwrap();
181 clone
182 .update_ref(&knot_git::RefUpdate::Create {
183 name: main.clone(),
184 new: old_tip,
185 })
186 .unwrap();
187
188 let work = dir.path().join("work");
189 commit(&work, "spray.txt", "salt\n", "third");
190 let new_hex = must(&work, &["rev-parse", "HEAD"]);
191 let new_tip = Oid::from_hex(&new_hex).unwrap();
192 let oids: Vec<String> = must(
193 &work,
194 &[
195 "rev-list",
196 "--objects",
197 &new_hex,
198 "--not",
199 &old_tip.to_hex(),
200 ],
201 )
202 .lines()
203 .filter_map(|line| line.split_whitespace().next())
204 .map(str::to_string)
205 .collect();
206 let pack = pack_objects(&work, &oids);
207 ingest_pack(
208 &source.objects_dir(),
209 &pack,
210 &PackLimits::default(),
211 source.object_format().kind(),
212 )
213 .unwrap();
214 source
215 .update_ref(&knot_git::RefUpdate::Update {
216 name: main.clone(),
217 old: old_tip,
218 new: new_tip,
219 })
220 .unwrap();
221 assert_ne!(old_tip, new_tip);
222
223 let pack = knot_pack::remote_pack(
224 http.as_ref(),
225 &base_url(),
226 &WantOids::new(vec![new_tip]),
227 &HaveOids::new(vec![old_tip]),
228 CAP,
229 )
230 .await
231 .unwrap();
232 let staging = Staging::new(&clone).unwrap();
233 ingest_pack(
234 &staging.repo().objects_dir(),
235 &pack,
236 &PackLimits::default(),
237 clone.object_format().kind(),
238 )
239 .unwrap();
240 let closure = staging
241 .repo()
242 .select_pack_objects(
243 knot_git::Wants::new(&[new_tip]),
244 knot_git::Haves::new(&[old_tip]),
245 )
246 .unwrap();
247 assert!(closure.iter().all(|oid| staging.repo().contains(*oid)));
248 staging.migrate_into(&clone).unwrap();
249 assert!(clone.contains(new_tip));
250}
251
252#[tokio::test]
253async fn a_tiny_pack_limit_refuses_both_remote_and_local_transfers() {
254 let dir = tempfile::tempdir().unwrap();
255 let source_path = seed_source(dir.path());
256 let source = Repo::open(&source_path).unwrap();
257 let tips: Vec<Oid> = source
258 .advertised_refs()
259 .unwrap()
260 .iter()
261 .map(|record| record.target)
262 .collect();
263
264 let http = knot_server(source_path);
265 let remote = knot_pack::remote_pack(
266 http.as_ref(),
267 &base_url(),
268 &WantOids::new(tips.clone()),
269 &HaveOids::default(),
270 16,
271 )
272 .await;
273 assert!(matches!(
274 remote,
275 Err(FetchError::PackTooLarge { limit: 16 })
276 ));
277
278 let local = local_pack(&source, &WantOids::new(tips), &HaveOids::default(), 16);
279 assert!(matches!(local, Err(FetchError::PackTooLarge { limit: 16 })));
280}
281
282#[test]
283fn local_refs_and_pack_mirror_a_same_knot_source() {
284 let dir = tempfile::tempdir().unwrap();
285 let source_path = seed_source(dir.path());
286 let source = Repo::open(&source_path).unwrap();
287 let hidden = RefName::new("refs/hidden/feature/main").unwrap();
288 let main = RefName::new("refs/heads/main").unwrap();
289 let tip = source.find_ref(&main).unwrap().unwrap();
290 source
291 .update_ref(&knot_git::RefUpdate::Create {
292 name: hidden,
293 new: tip,
294 })
295 .unwrap();
296
297 let refs = local_refs(&source, &["HEAD", "refs/heads/", "refs/tags/"]).unwrap();
298 assert_eq!(
299 refs.head_symref.as_ref().map(RefName::as_str),
300 Some("refs/heads/main")
301 );
302 assert!(
303 refs.refs
304 .iter()
305 .all(|record| !record.name.as_str().starts_with("refs/hidden/")),
306 "hidden ref must never leave source repo through a fork"
307 );
308
309 let layout = Layout::new(dir.path().join("scan"));
310 let fork = layout
311 .create(&RepoDid::new("did:plc:limpet").unwrap())
312 .unwrap();
313 let pack = local_pack(
314 &source,
315 &WantOids::new(refs.tips()),
316 &HaveOids::default(),
317 CAP,
318 )
319 .unwrap();
320 ingest_pack(
321 &fork.objects_dir(),
322 &pack,
323 &PackLimits::default(),
324 fork.object_format().kind(),
325 )
326 .unwrap();
327 let closure = fork
328 .select_pack_objects(
329 knot_git::Wants::new(&refs.tips()),
330 knot_git::Haves::new(&[]),
331 )
332 .unwrap();
333 assert!(closure.iter().all(|oid| fork.contains(*oid)));
334}