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::{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
31/// Information about a single distribution file (metadata for the Simple API).
32struct DistInfo {
33    filename: String,
34    sha256: String,
35    requires_python: Option<VersionSpecifiers>,
36    upload_time: &'static str,
37    yanked: bool,
38}
39
40/// All distributions for a given package name, across versions.
41struct 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
59/// The complete pre-indexed database for a server instance.
60struct ServerIndex {
61    /// Simple API: normalized package name → list of distribution metadata.
62    packages: HashMap<PackageName, PackageEntry>,
63    /// File downloads: filename → generated bytes or a lazy vendored artifact.
64    files: HashMap<String, FileData>,
65}
66
67/// A running mock PyPI server for a single packse scenario.
68///
69/// The server runs on a background thread with its own single-threaded tokio runtime.
70/// When [`PackseServer`] is dropped, the background thread and server are shut down.
71pub struct PackseServer {
72    server: HttpServer,
73}
74
75impl PackseServer {
76    /// Load a scenario from a TOML path (relative to the vendored scenarios directory)
77    /// and start a mock server for it.
78    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    /// Start a mock server with no packages (only cached build dependencies).
86    ///
87    /// Useful as a dummy index that will 404 for any non-cached package lookup.
88    pub fn empty() -> Self {
89        Self::from_scenario(&Scenario::empty())
90    }
91
92    /// Start a mock server for the given scenario.
93    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    /// The Simple API index URL (e.g., `http://127.0.0.1:PORT/simple/`).
103    pub fn index_url(&self) -> String {
104        format!("{}/simple/", self.server.url())
105    }
106
107    /// Return the URL for a generated distribution file.
108    pub fn file_url(&self, filename: &str) -> String {
109        format!("{}/files/{filename}", self.server.url())
110    }
111}
112
113/// Build the complete [`ServerIndex`] from a scenario and cached build dependencies.
114fn 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
228/// Build a response for a distribution file, including support for single byte ranges.
229fn 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
256/// Parse a single HTTP byte range and return its inclusive bounds.
257fn 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
284/// Build PEP 691 JSON response for a package.
285fn 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
325/// Extract the package name from `/simple/{package}` or `/simple/{package}/`.
326fn 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}