This repository has no description
0

Configure Feed

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

core / knot2 / crates / knot-pack / tests / serving.rs
10 kB 312 lines
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}