1use std::collections::HashMap;
11use std::path::Path;
12use std::str::FromStr;
13use std::sync::Arc;
14
15use serde_json::json;
16use wiremock::{
17 Mock, MockServer, Request, ResponseTemplate,
18 matchers::{header_exists, method, path},
19};
20
21use uv_distribution_filename::WheelFilename;
22use uv_normalize::PackageName;
23use uv_pep440::VersionSpecifiers;
24
25use crate::http_server::{HttpServer, content_type_for_filename};
26use crate::vendor::{VendorArtifact, vendor_artifacts};
27
28use super::scenario::{Scenario, WheelTag};
29use super::scenarios_dir;
30use super::wheel::{generate_sdist, generate_wheel, sha256_hex};
31
32const PACKSE_UPLOAD_TIME: &str = "2024-03-24T00:00:00Z";
33
34struct DistInfo {
36 filename: String,
37 sha256: String,
38 requires_python: Option<VersionSpecifiers>,
39 upload_time: &'static str,
40 yanked: bool,
41}
42
43struct PackageEntry {
45 dists: Vec<DistInfo>,
46}
47
48enum FileData {
49 Bytes(Arc<[u8]>),
50 Vendor(&'static VendorArtifact),
51}
52
53impl FileData {
54 fn bytes(&self) -> anyhow::Result<Arc<[u8]>> {
55 match self {
56 Self::Bytes(bytes) => Ok(Arc::clone(bytes)),
57 Self::Vendor(artifact) => artifact.bytes(),
58 }
59 }
60}
61
62struct ServerIndex {
64 packages: HashMap<PackageName, PackageEntry>,
66 files: HashMap<String, FileData>,
68}
69
70pub struct PackseServer {
75 server: HttpServer,
76}
77
78impl PackseServer {
79 pub fn new(scenario_path: &str) -> Self {
82 let full_path = scenarios_dir().join(scenario_path);
83 let scenario =
84 Scenario::from_path(&full_path).expect("vendored Packse scenario should parse");
85 Self::from_scenario(&scenario)
86 }
87
88 pub fn empty() -> Self {
92 Self::from_scenario(&Scenario::empty())
93 }
94
95 pub fn from_scenario(scenario: &Scenario) -> Self {
97 let index = Arc::new(build_server_index(scenario));
98 let server = HttpServer::start(move |request, server_uri| {
99 handle_request(request, server_uri, &index)
100 });
101
102 Self { server }
103 }
104
105 pub fn index_url(&self) -> String {
107 format!("{}/simple/", self.server.url())
108 }
109
110 pub fn file_url(&self, filename: &str) -> String {
112 format!("{}/files/{filename}", self.server.url())
113 }
114}
115
116fn build_server_index(scenario: &Scenario) -> ServerIndex {
118 let mut packages = HashMap::new();
119 let mut files: HashMap<String, FileData> = HashMap::new();
120
121 for (package_name, package) in &scenario.packages {
122 let mut dists = Vec::new();
123
124 for (version, meta) in &package.versions {
125 if meta.wheel {
126 let tags = if meta.wheel_tags.is_empty() {
127 vec!["py3-none-any"]
128 } else {
129 meta.wheel_tags.iter().map(WheelTag::as_str).collect()
130 };
131
132 for tag in tags {
133 let (filename, bytes) = generate_wheel(
134 package_name,
135 version,
136 &meta.requires,
137 &meta.extras,
138 meta.requires_python.as_ref(),
139 tag,
140 );
141 let sha256 = sha256_hex(&bytes);
142 files.insert(filename.clone(), FileData::Bytes(bytes.into()));
143 dists.push(DistInfo {
144 filename,
145 sha256,
146 requires_python: meta.requires_python.clone(),
147 upload_time: PACKSE_UPLOAD_TIME,
148 yanked: meta.yanked,
149 });
150 }
151 }
152
153 if meta.sdist {
154 let (filename, bytes) = generate_sdist(
155 package_name,
156 version,
157 &meta.requires,
158 &meta.extras,
159 meta.requires_python.as_ref(),
160 );
161 let sha256 = sha256_hex(&bytes);
162 files.insert(filename.clone(), FileData::Bytes(bytes.into()));
163 dists.push(DistInfo {
164 filename,
165 sha256,
166 requires_python: meta.requires_python.clone(),
167 upload_time: PACKSE_UPLOAD_TIME,
168 yanked: meta.yanked,
169 });
170 }
171 }
172
173 packages.insert(package_name.clone(), PackageEntry { dists });
174 }
175
176 for artifact in vendor_artifacts() {
177 if !Path::new(artifact.filename)
178 .extension()
179 .is_some_and(|extension| extension.eq_ignore_ascii_case("whl"))
180 {
181 continue;
182 }
183
184 let wheel_filename =
185 WheelFilename::from_str(artifact.filename).expect("invalid vendor wheel filename");
186
187 files.insert(artifact.filename.to_string(), FileData::Vendor(artifact));
188 packages
189 .entry(wheel_filename.name)
190 .or_insert_with(|| PackageEntry { dists: Vec::new() })
191 .dists
192 .push(DistInfo {
193 filename: artifact.filename.to_string(),
194 sha256: artifact.sha256.to_string(),
195 requires_python: None,
196 upload_time: PACKSE_UPLOAD_TIME,
197 yanked: false,
198 });
199 }
200
201 ServerIndex { packages, files }
202}
203
204fn handle_request(req: &Request, server_uri: &str, index: &ServerIndex) -> ResponseTemplate {
205 let path = req.url.path();
206
207 if let Some(pkg) = extract_package_name(path) {
208 let Ok(package_name) = PackageName::from_str(pkg) else {
209 return ResponseTemplate::new(404);
210 };
211
212 if let Some(entry) = index.packages.get(&package_name) {
213 return build_simple_api_response(pkg, entry, server_uri);
214 }
215 return ResponseTemplate::new(404);
216 }
217
218 if let Some(filename) = path.strip_prefix("/files/") {
219 if let Some(file) = index.files.get(filename) {
220 return match file.bytes() {
221 Ok(bytes) => distribution_file_response(req, filename, &bytes),
222 Err(error) => ResponseTemplate::new(500).set_body_string(format!("{error:#}")),
223 };
224 }
225 return ResponseTemplate::new(404);
226 }
227
228 ResponseTemplate::new(404)
229}
230
231pub fn distribution_file_response(req: &Request, filename: &str, bytes: &[u8]) -> ResponseTemplate {
233 let content_type = content_type_for_filename(filename);
234 let Some(range) = req.headers.get("range") else {
235 return ResponseTemplate::new(200)
236 .insert_header("Accept-Ranges", "bytes")
237 .set_body_raw(bytes.to_vec(), content_type);
238 };
239
240 let Some((start, end)) = range
241 .to_str()
242 .ok()
243 .and_then(|range| parse_byte_range(range, bytes.len()))
244 else {
245 return ResponseTemplate::new(416)
246 .insert_header("Accept-Ranges", "bytes")
247 .insert_header("Content-Range", format!("bytes */{}", bytes.len()));
248 };
249
250 ResponseTemplate::new(206)
251 .insert_header("Accept-Ranges", "bytes")
252 .insert_header(
253 "Content-Range",
254 format!("bytes {start}-{end}/{}", bytes.len()),
255 )
256 .set_body_raw(bytes[start..=end].to_vec(), content_type)
257}
258
259pub async fn mount_mismatched_distribution(
264 server: &MockServer,
265 file_path: &str,
266 filename: &str,
267 ranged_bytes: Vec<u8>,
268 full_bytes: Vec<u8>,
269) {
270 Mock::given(method("HEAD"))
271 .and(path(file_path))
272 .respond_with(
273 ResponseTemplate::new(200)
274 .insert_header("Accept-Ranges", "bytes")
275 .set_body_bytes(ranged_bytes.clone()),
276 )
277 .mount(server)
278 .await;
279
280 let filename = filename.to_string();
281 Mock::given(method("GET"))
282 .and(path(file_path))
283 .and(header_exists("range"))
284 .respond_with(move |request: &Request| {
285 distribution_file_response(request, &filename, &ranged_bytes)
286 })
287 .with_priority(1)
288 .expect(1..)
289 .mount(server)
290 .await;
291
292 Mock::given(method("GET"))
293 .and(path(file_path))
294 .respond_with(ResponseTemplate::new(200).set_body_bytes(full_bytes))
295 .mount(server)
296 .await;
297}
298
299fn parse_byte_range(range: &str, length: usize) -> Option<(usize, usize)> {
301 let range = range.strip_prefix("bytes=")?;
302 let (start, end) = range.split_once('-')?;
303
304 if start.is_empty() {
305 let suffix = end.parse::<usize>().ok()?;
306 if suffix == 0 || length == 0 {
307 return None;
308 }
309 return Some((length.saturating_sub(suffix), length - 1));
310 }
311
312 let start = start.parse::<usize>().ok()?;
313 if start >= length {
314 return None;
315 }
316 let end = if end.is_empty() {
317 length - 1
318 } else {
319 end.parse::<usize>().ok()?.min(length - 1)
320 };
321 if start > end {
322 return None;
323 }
324 Some((start, end))
325}
326
327fn build_simple_api_response(
329 package_name: &str,
330 entry: &PackageEntry,
331 server_uri: &str,
332) -> ResponseTemplate {
333 let files: Vec<serde_json::Value> = entry
334 .dists
335 .iter()
336 .map(|dist| {
337 let url = format!("{server_uri}/files/{}", dist.filename);
338 let mut file_obj = json!({
339 "filename": dist.filename,
340 "url": url,
341 "hashes": {
342 "sha256": dist.sha256,
343 },
344 "upload-time": dist.upload_time,
345 });
346 if let Some(rp) = &dist.requires_python {
347 file_obj["requires-python"] = json!(rp);
348 }
349 if dist.yanked {
350 file_obj["yanked"] = json!(true);
351 }
352 file_obj
353 })
354 .collect();
355
356 let body = json!({
357 "meta": { "api-version": "1.1" },
358 "name": package_name,
359 "files": files,
360 });
361
362 let body_str = body.to_string();
363 ResponseTemplate::new(200)
364 .insert_header("Content-Type", "application/vnd.pypi.simple.v1+json")
365 .set_body_raw(body_str, "application/vnd.pypi.simple.v1+json")
366}
367
368fn extract_package_name(path: &str) -> Option<&str> {
370 let rest = path.strip_prefix("/simple/")?;
371 let pkg = rest.strip_suffix('/').unwrap_or(rest);
372 if pkg.is_empty() || pkg.contains('/') {
373 return None;
374 }
375 Some(pkg)
376}
377
378#[cfg(test)]
379mod tests {
380 use anyhow::Result;
381 use reqwest::StatusCode;
382 use reqwest::header::{ACCEPT_RANGES, CONTENT_RANGE, RANGE};
383 use wiremock::MockServer;
384
385 use crate::vendor::vendor_artifacts;
386
387 use super::{
388 PackseServer, Scenario, build_server_index, extract_package_name,
389 mount_mismatched_distribution,
390 };
391
392 #[test]
393 fn extract_package_name_accepts_with_or_without_trailing_slash() {
394 assert_eq!(extract_package_name("/simple/foo/"), Some("foo"));
395 assert_eq!(extract_package_name("/simple/foo"), Some("foo"));
396 }
397
398 #[test]
399 fn extract_package_name_rejects_invalid_paths() {
400 assert_eq!(extract_package_name("/simple/"), None);
401 assert_eq!(extract_package_name("/simple"), None);
402 assert_eq!(extract_package_name("/simple/foo/bar"), None);
403 }
404
405 #[test]
406 fn server_index_construction_does_not_load_vendor_artifacts() {
407 let _index = build_server_index(&Scenario::empty());
408
409 assert!(
410 vendor_artifacts()
411 .iter()
412 .all(|artifact| !artifact.is_loaded())
413 );
414 }
415
416 #[tokio::test]
417 async fn file_requests_support_byte_ranges() -> Result<()> {
418 let scenario = toml::from_str::<Scenario>(
419 r#"
420name = "range-requests"
421
422[root]
423requires = ["a"]
424
425[expected]
426satisfiable = true
427
428[packages.a.versions."1.0.0"]
429sdist = false
430"#,
431 )?;
432 let server = PackseServer::from_scenario(&scenario);
433 let url = server.file_url("a-1.0.0-py3-none-any.whl");
434 let client = reqwest::Client::new();
435
436 let response = client.get(&url).send().await?;
437 assert_eq!(response.status(), StatusCode::OK);
438 assert_eq!(
439 response.headers().get(ACCEPT_RANGES),
440 Some(&"bytes".parse()?)
441 );
442 let bytes = response.bytes().await?;
443 let length = bytes.len();
444
445 let response = client.get(&url).header(RANGE, "bytes=-8").send().await?;
446 assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
447 assert_eq!(
448 response.headers().get(CONTENT_RANGE),
449 Some(&format!("bytes {}-{}/{}", length - 8, length - 1, length).parse()?)
450 );
451 assert_eq!(response.bytes().await?, bytes[length - 8..]);
452
453 let response = client
454 .get(&url)
455 .header(RANGE, "bytes=-999999")
456 .send()
457 .await?;
458 assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
459 assert_eq!(
460 response.headers().get(CONTENT_RANGE),
461 Some(&format!("bytes 0-{}/{}", length - 1, length).parse()?)
462 );
463 assert_eq!(response.bytes().await?, bytes);
464
465 let response = client.get(&url).header(RANGE, "bytes=3-9").send().await?;
466 assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
467 assert_eq!(
468 response.headers().get(CONTENT_RANGE),
469 Some(&format!("bytes 3-9/{length}").parse()?)
470 );
471 assert_eq!(response.bytes().await?, bytes[3..=9]);
472
473 let response = client.get(&url).header(RANGE, "bytes=10-").send().await?;
474 assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
475 assert_eq!(
476 response.headers().get(CONTENT_RANGE),
477 Some(&format!("bytes 10-{}/{}", length - 1, length).parse()?)
478 );
479 assert_eq!(response.bytes().await?, bytes[10..]);
480
481 let response = client
482 .get(&url)
483 .header(RANGE, format!("bytes={length}-"))
484 .send()
485 .await?;
486 assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE);
487 assert_eq!(
488 response.headers().get(CONTENT_RANGE),
489 Some(&format!("bytes */{length}").parse()?)
490 );
491
492 Ok(())
493 }
494
495 #[tokio::test]
496 async fn mismatched_distribution_serves_ranged_and_full_bytes() -> Result<()> {
497 let server = MockServer::start().await;
498 mount_mismatched_distribution(
499 &server,
500 "/files/example.whl",
501 "example.whl",
502 b"forged".to_vec(),
503 b"authentic".to_vec(),
504 )
505 .await;
506 let url = format!("{}/files/example.whl", server.uri());
507 let client = reqwest::Client::new();
508
509 let response = client.head(&url).send().await?;
510 assert_eq!(response.status(), StatusCode::OK);
511 assert_eq!(
512 response.headers().get(ACCEPT_RANGES),
513 Some(&"bytes".parse()?)
514 );
515
516 let response = client.get(&url).header(RANGE, "bytes=1-3").send().await?;
517 assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
518 assert_eq!(response.bytes().await?, b"org".as_slice());
519
520 let response = client.get(&url).send().await?;
521 assert_eq!(response.status(), StatusCode::OK);
522 assert_eq!(response.bytes().await?, b"authentic".as_slice());
523
524 Ok(())
525 }
526}