This repository has no description
1mod common;
2
3use axum::body::Body;
4use http::{Request, StatusCode, header};
5use sha2::{Digest, Sha256};
6use tower::ServiceExt;
7
8use knot_lfs::{LfsOid, LfsStore};
9use knot_types::RepoDid;
10
11use common::{OWNER, World, empty_repo, get};
12
13const MEDIA: &[u8] = b"\xff\x00heavy media bytes that live outside the odb";
14
15fn seeded_object(world: &World, repo: &RepoDid) -> (LfsOid, usize) {
16 let oid = LfsOid::from_digest(Sha256::digest(MEDIA).into());
17 world
18 .state
19 .lfs
20 .as_ref()
21 .unwrap()
22 .handle
23 .store
24 .put(
25 repo,
26 &oid,
27 knot_lfs::ClaimedSize::new(MEDIA.len() as u64),
28 &mut &MEDIA[..],
29 )
30 .unwrap();
31 (oid, MEDIA.len())
32}
33
34fn absent_oid() -> LfsOid {
35 LfsOid::from_digest(Sha256::digest(b"never uploaded anywhere").into())
36}
37
38async fn post_batch(world: &World, path: &str, body: String) -> (StatusCode, serde_json::Value) {
39 let request = Request::post(path)
40 .header(header::CONTENT_TYPE, "application/vnd.git-lfs+json")
41 .body(Body::from(body))
42 .unwrap();
43 let response = world.router.clone().oneshot(request).await.unwrap();
44 let status = response.status();
45 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
46 .await
47 .unwrap();
48 let value = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null);
49 (status, value)
50}
51
52fn batch_body(operation: &str, oids: &[(String, u64)]) -> String {
53 let objects: Vec<serde_json::Value> = oids
54 .iter()
55 .map(|(oid, size)| serde_json::json!({"oid": oid, "size": size}))
56 .collect();
57 serde_json::json!({
58 "operation": operation,
59 "transfers": ["basic", "ssh"],
60 "objects": objects,
61 "hash_algo": "sha256",
62 })
63 .to_string()
64}
65
66#[tokio::test]
67async fn the_batch_download_surface_answers_every_addressing_form() {
68 let world = World::new();
69 let (did, _bare, _work) = empty_repo(&world, "barnacle");
70 let (oid, size) = seeded_object(&world, &did);
71 let missing = absent_oid();
72
73 let body = batch_body(
74 "download",
75 &[
76 (oid.as_str().to_string(), size as u64),
77 (missing.as_str().to_string(), 9),
78 ],
79 );
80 let paths = [
81 format!("/{did}/info/lfs/objects/batch"),
82 format!("/{did}.git/info/lfs/objects/batch"),
83 format!("/{OWNER}/barnacle/info/lfs/objects/batch"),
84 format!("/{OWNER}/barnacle.git/info/lfs/objects/batch"),
85 ];
86 futures::future::join_all(paths.iter().map(|path| {
87 let world = &world;
88 let body = body.clone();
89 let oid = oid.clone();
90 async move {
91 let (status, json) = post_batch(world, path, body).await;
92 assert_eq!(status, StatusCode::OK, "batch at {path}");
93 assert_eq!(json["transfer"], "basic");
94 assert_eq!(json["hash_algo"], "sha256");
95 let objects = json["objects"].as_array().unwrap();
96 assert_eq!(objects.len(), 2);
97 assert_eq!(objects[0]["oid"], oid.as_str());
98 assert_eq!(objects[0]["size"], size as u64);
99 assert_eq!(objects[0]["authenticated"], true);
100 let href = objects[0]["actions"]["download"]["href"].as_str().unwrap();
101 assert!(
102 href.ends_with(&format!("/info/lfs/objects/{oid}")),
103 "href {href}"
104 );
105 assert!(href.starts_with("https://"), "href {href}");
106 assert!(objects[0].get("error").is_none());
107 assert_eq!(objects[1]["error"]["code"], 404);
108 assert!(objects[1].get("actions").is_none());
109 }
110 }))
111 .await;
112
113 let upload = batch_body("upload", &[(oid.as_str().to_string(), size as u64)]);
114 let (status, _) = post_batch(&world, &format!("/{did}/info/lfs/objects/batch"), upload).await;
115 assert_eq!(
116 status,
117 StatusCode::UNAUTHORIZED,
118 "an unauthenticated HTTP upload batch is challenged for credentials, never answered"
119 );
120}
121
122#[tokio::test]
123async fn the_object_route_streams_ranges_and_stays_anonymous() {
124 let world = World::new();
125 let (did, _bare, _work) = empty_repo(&world, "limpet");
126 let (oid, size) = seeded_object(&world, &did);
127
128 let request = Request::get(format!("/{did}/info/lfs/objects/{oid}"))
129 .body(Body::empty())
130 .unwrap();
131 let response = world.router.clone().oneshot(request).await.unwrap();
132 assert_eq!(response.status(), StatusCode::OK);
133 assert_eq!(
134 response.headers()[header::CONTENT_TYPE],
135 "application/octet-stream"
136 );
137 assert_eq!(
138 response.headers()[header::CACHE_CONTROL],
139 "public, max-age=31536000, immutable"
140 );
141 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
142 .await
143 .unwrap();
144 assert_eq!(&bytes[..], MEDIA);
145
146 let request = Request::get(format!("/{OWNER}/limpet.git/info/lfs/objects/{oid}"))
147 .header(header::RANGE, "bytes=3-6")
148 .body(Body::empty())
149 .unwrap();
150 let response = world.router.clone().oneshot(request).await.unwrap();
151 assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
152 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
153 .await
154 .unwrap();
155 assert_eq!(&bytes[..], &MEDIA[3..=6]);
156
157 let request = Request::get(format!("/{did}/info/lfs/objects/{oid}"))
158 .header(header::IF_NONE_MATCH, format!("\"{oid}\""))
159 .body(Body::empty())
160 .unwrap();
161 let response = world.router.clone().oneshot(request).await.unwrap();
162 assert_eq!(response.status(), StatusCode::NOT_MODIFIED);
163 assert_eq!(
164 response.headers()[header::ETAG],
165 format!("\"{oid}\"").as_str()
166 );
167 let _ = size;
168}
169
170#[tokio::test]
171async fn missing_and_hostile_objects_get_typed_404s() {
172 let world = World::new();
173 let (did, _bare, _work) = empty_repo(&world, "scallop");
174 seeded_object(&world, &did);
175
176 let absent = absent_oid();
177 let cases = [
178 format!("/{did}/info/lfs/objects/{absent}"),
179 format!("/{did}/info/lfs/objects/deadbeef"),
180 format!("/{did}/info/lfs/objects/..%2f..%2fetc%2fpasswd"),
181 format!("/did:plc:nowhere/info/lfs/objects/{absent}"),
182 ];
183 futures::future::join_all(cases.iter().map(|path| {
184 let world = &world;
185 async move {
186 let request = Request::get(path).body(Body::empty()).unwrap();
187 let response = world.router.clone().oneshot(request).await.unwrap();
188 assert_eq!(response.status(), StatusCode::NOT_FOUND, "GET {path}");
189 assert_eq!(
190 response.headers()[header::CONTENT_TYPE],
191 "application/vnd.git-lfs+json",
192 "GET {path}"
193 );
194 }
195 }))
196 .await;
197
198 let request = Request::get(format!("/{did}/info/lfs/objects/{absent}"))
199 .header(header::IF_NONE_MATCH, "*")
200 .body(Body::empty())
201 .unwrap();
202 let response = world.router.clone().oneshot(request).await.unwrap();
203 assert_eq!(
204 response.status(),
205 StatusCode::NOT_FOUND,
206 "an absent object must 404 even when revalidated"
207 );
208}
209
210#[tokio::test]
211async fn readiness_reflects_the_lfs_store() {
212 let world = World::new();
213 let (status, _, _) = get(&world, "/xrpc/_health").await;
214 assert_eq!(status, StatusCode::OK, "a writable store reports ready");
215
216 let incoming = world.lfs_dir.join(".incoming");
217 std::fs::remove_dir(&incoming).unwrap();
218 let (status, _, body) = get(&world, "/xrpc/_health").await;
219 assert_eq!(
220 status,
221 StatusCode::SERVICE_UNAVAILABLE,
222 "an unwritable store reports unready: {}",
223 String::from_utf8_lossy(&body)
224 );
225
226 std::fs::create_dir_all(&incoming).unwrap();
227 let (status, _, _) = get(&world, "/xrpc/_health").await;
228 assert_eq!(status, StatusCode::OK, "a recovered store reports ready");
229}
230
231#[tokio::test]
232async fn hostile_batches_get_clean_typed_rejections() {
233 let world = World::new();
234 let (did, _bare, _work) = empty_repo(&world, "conch");
235 let (oid, size) = seeded_object(&world, &did);
236 let base = format!("/{did}/info/lfs/objects/batch");
237
238 let (status, _) = post_batch(&world, &base, "not json at all".to_string()).await;
239 assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
240
241 let traversal = serde_json::json!({
242 "operation": "download",
243 "objects": [{"oid": "../../../../etc/passwd", "size": 1}],
244 })
245 .to_string();
246 let (status, _) = post_batch(&world, &base, traversal).await;
247 assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
248
249 let oversized_list: Vec<(String, u64)> = (0..1001)
250 .map(|index| {
251 let digest = Sha256::digest(index.to_string().as_bytes());
252 (LfsOid::from_digest(digest.into()).as_str().to_string(), 1)
253 })
254 .collect();
255 let (status, _) = post_batch(&world, &base, batch_body("download", &oversized_list)).await;
256 assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
257
258 let foreign_algo = serde_json::json!({
259 "operation": "download",
260 "objects": [{"oid": oid.as_str(), "size": size}],
261 "hash_algo": "sha1",
262 })
263 .to_string();
264 let (status, _) = post_batch(&world, &base, foreign_algo).await;
265 assert_eq!(status, StatusCode::CONFLICT);
266
267 let no_common_transfer = serde_json::json!({
268 "operation": "download",
269 "transfers": ["tus"],
270 "objects": [{"oid": oid.as_str(), "size": size}],
271 })
272 .to_string();
273 let (status, _) = post_batch(&world, &base, no_common_transfer).await;
274 assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
275
276 let over_limit = format!(
277 r#"{{"operation":"download","objects":[],"pad":"{}"}}"#,
278 "a".repeat(1024 * 1024 + 1)
279 );
280 let (status, _) = post_batch(&world, &base, over_limit).await;
281 assert_eq!(
282 status,
283 StatusCode::PAYLOAD_TOO_LARGE,
284 "a batch body over the limit is refused before buffering"
285 );
286
287 let absurd_size = serde_json::json!({
288 "operation": "download",
289 "objects": [{"oid": oid.as_str(), "size": u64::MAX}],
290 })
291 .to_string();
292 let (status, json) = post_batch(&world, &base, absurd_size).await;
293 assert_eq!(status, StatusCode::OK);
294 assert_eq!(
295 json["objects"][0]["size"], size as u64,
296 "a download answer reports the stored size, ignoring the client's absurd claim"
297 );
298 assert!(json["objects"][0]["actions"]["download"]["href"].is_string());
299}