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