Skip to main content

uv_test/packse/
server.rs

1//! A per-test wiremock server that serves packse scenario packages.
2//!
3//! Each [`PackseServer`] reads a single scenario TOML file and serves:
4//! - PEP 691 Simple API at `/simple/{package}/`
5//! - Distribution downloads at `/files/{filename}`
6//!
7//! Cached build dependencies are exposed through the same `/simple/*` and
8//! `/files/*` routes as scenario packages.
9
10use 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
34/// Information about a single distribution file (metadata for the Simple API).
35struct DistInfo {
36    filename: String,
37    sha256: String,
38    requires_python: Option<VersionSpecifiers>,
39    upload_time: &'static str,
40    yanked: bool,
41}
42
43/// All distributions for a given package name, across versions.
44struct 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
62/// The complete pre-indexed database for a server instance.
63struct ServerIndex {
64    /// Simple API: normalized package name → list of distribution metadata.
65    packages: HashMap<PackageName, PackageEntry>,
66    /// File downloads: filename → generated bytes or a lazy vendored artifact.
67    files: HashMap<String, FileData>,
68}
69
70/// A running mock PyPI server for a single packse scenario.
71///
72/// The server runs on a background thread with its own single-threaded tokio runtime.
73/// When [`PackseServer`] is dropped, the background thread and server are shut down.
74pub struct PackseServer {
75    server: HttpServer,
76}
77
78impl PackseServer {
79    /// Load a scenario from a TOML path (relative to the vendored scenarios directory)
80    /// and start a mock server for it.
81    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    /// Start a mock server with no packages (only cached build dependencies).
89    ///
90    /// Useful as a dummy index that will 404 for any non-cached package lookup.
91    pub fn empty() -> Self {
92        Self::from_scenario(&Scenario::empty())
93    }
94
95    /// Start a mock server for the given scenario.
96    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    /// The Simple API index URL (e.g., `http://127.0.0.1:PORT/simple/`).
106    pub fn index_url(&self) -> String {
107        format!("{}/simple/", self.server.url())
108    }
109
110    /// Return the URL for a generated distribution file.
111    pub fn file_url(&self, filename: &str) -> String {
112        format!("{}/files/{filename}", self.server.url())
113    }
114}
115
116/// Build the complete [`ServerIndex`] from a scenario and cached build dependencies.
117fn 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
231/// Build a response for a distribution file, including support for single byte ranges.
232pub 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
259/// Mount a distribution that serves different bytes to range and full-file requests.
260///
261/// `HEAD` responses advertise range support. Ranged `GET` requests receive the ranged bytes,
262/// while full `GET` requests receive the full-file bytes instead.
263pub 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
299/// Parse a single HTTP byte range and return its inclusive bounds.
300fn 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
327/// Build PEP 691 JSON response for a package.
328fn 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
368/// Extract the package name from `/simple/{package}` or `/simple/{package}/`.
369fn 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}