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
108fn build_server_index(scenario: &Scenario) -> ServerIndex {
110 let mut packages = HashMap::new();
111 let mut files: HashMap<String, FileData> = HashMap::new();
112
113 for (package_name, package) in &scenario.packages {
114 let mut dists = Vec::new();
115
116 for (version, meta) in &package.versions {
117 if meta.wheel {
118 let tags = if meta.wheel_tags.is_empty() {
119 vec!["py3-none-any"]
120 } else {
121 meta.wheel_tags.iter().map(WheelTag::as_str).collect()
122 };
123
124 for tag in tags {
125 let (filename, bytes) = generate_wheel(
126 package_name,
127 version,
128 &meta.requires,
129 &meta.extras,
130 meta.requires_python.as_ref(),
131 tag,
132 );
133 let sha256 = sha256_hex(&bytes);
134 files.insert(filename.clone(), FileData::Bytes(bytes.into()));
135 dists.push(DistInfo {
136 filename,
137 sha256,
138 requires_python: meta.requires_python.clone(),
139 upload_time: PACKSE_UPLOAD_TIME,
140 yanked: meta.yanked,
141 });
142 }
143 }
144
145 if meta.sdist {
146 let (filename, bytes) = generate_sdist(
147 package_name,
148 version,
149 &meta.requires,
150 &meta.extras,
151 meta.requires_python.as_ref(),
152 );
153 let sha256 = sha256_hex(&bytes);
154 files.insert(filename.clone(), FileData::Bytes(bytes.into()));
155 dists.push(DistInfo {
156 filename,
157 sha256,
158 requires_python: meta.requires_python.clone(),
159 upload_time: PACKSE_UPLOAD_TIME,
160 yanked: meta.yanked,
161 });
162 }
163 }
164
165 packages.insert(package_name.clone(), PackageEntry { dists });
166 }
167
168 for artifact in vendor_artifacts() {
169 if !Path::new(artifact.filename)
170 .extension()
171 .is_some_and(|extension| extension.eq_ignore_ascii_case("whl"))
172 {
173 continue;
174 }
175
176 let wheel_filename =
177 WheelFilename::from_str(artifact.filename).expect("invalid vendor wheel filename");
178
179 files.insert(artifact.filename.to_string(), FileData::Vendor(artifact));
180 packages
181 .entry(wheel_filename.name)
182 .or_insert_with(|| PackageEntry { dists: Vec::new() })
183 .dists
184 .push(DistInfo {
185 filename: artifact.filename.to_string(),
186 sha256: artifact.sha256.to_string(),
187 requires_python: None,
188 upload_time: PACKSE_UPLOAD_TIME,
189 yanked: false,
190 });
191 }
192
193 ServerIndex { packages, files }
194}
195
196fn handle_request(req: &Request, server_uri: &str, index: &ServerIndex) -> ResponseTemplate {
197 let path = req.url.path();
198
199 if let Some(pkg) = extract_package_name(path) {
200 let Ok(package_name) = PackageName::from_str(pkg) else {
201 return ResponseTemplate::new(404);
202 };
203
204 if let Some(entry) = index.packages.get(&package_name) {
205 return build_simple_api_response(pkg, entry, server_uri);
206 }
207 return ResponseTemplate::new(404);
208 }
209
210 if let Some(filename) = path.strip_prefix("/files/") {
211 if let Some(file) = index.files.get(filename) {
212 return match file.bytes() {
213 Ok(bytes) => ResponseTemplate::new(200)
214 .set_body_raw(bytes.to_vec(), content_type_for_filename(filename)),
215 Err(error) => ResponseTemplate::new(500).set_body_string(format!("{error:#}")),
216 };
217 }
218 return ResponseTemplate::new(404);
219 }
220
221 ResponseTemplate::new(404)
222}
223
224fn build_simple_api_response(
226 package_name: &str,
227 entry: &PackageEntry,
228 server_uri: &str,
229) -> ResponseTemplate {
230 let files: Vec<serde_json::Value> = entry
231 .dists
232 .iter()
233 .map(|dist| {
234 let url = format!("{server_uri}/files/{}", dist.filename);
235 let mut file_obj = json!({
236 "filename": dist.filename,
237 "url": url,
238 "hashes": {
239 "sha256": dist.sha256,
240 },
241 "upload-time": dist.upload_time,
242 });
243 if let Some(rp) = &dist.requires_python {
244 file_obj["requires-python"] = json!(rp);
245 }
246 if dist.yanked {
247 file_obj["yanked"] = json!(true);
248 }
249 file_obj
250 })
251 .collect();
252
253 let body = json!({
254 "meta": { "api-version": "1.1" },
255 "name": package_name,
256 "files": files,
257 });
258
259 let body_str = body.to_string();
260 ResponseTemplate::new(200)
261 .insert_header("Content-Type", "application/vnd.pypi.simple.v1+json")
262 .set_body_raw(body_str, "application/vnd.pypi.simple.v1+json")
263}
264
265fn extract_package_name(path: &str) -> Option<&str> {
267 let rest = path.strip_prefix("/simple/")?;
268 let pkg = rest.strip_suffix('/').unwrap_or(rest);
269 if pkg.is_empty() || pkg.contains('/') {
270 return None;
271 }
272 Some(pkg)
273}
274
275#[cfg(test)]
276mod tests {
277 use crate::vendor::vendor_artifacts;
278
279 use super::{Scenario, build_server_index, extract_package_name};
280
281 #[test]
282 fn extract_package_name_accepts_with_or_without_trailing_slash() {
283 assert_eq!(extract_package_name("/simple/foo/"), Some("foo"));
284 assert_eq!(extract_package_name("/simple/foo"), Some("foo"));
285 }
286
287 #[test]
288 fn extract_package_name_rejects_invalid_paths() {
289 assert_eq!(extract_package_name("/simple/"), None);
290 assert_eq!(extract_package_name("/simple"), None);
291 assert_eq!(extract_package_name("/simple/foo/bar"), None);
292 }
293
294 #[test]
295 fn server_index_construction_does_not_load_vendor_artifacts() {
296 let _index = build_server_index(&Scenario::empty());
297
298 assert!(
299 vendor_artifacts()
300 .iter()
301 .all(|artifact| !artifact.is_loaded())
302 );
303 }
304}