This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / knot2 / crates / knot-xrpc / tests / lfs.rs
11 kB 299 lines
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}