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) => ResponseTemplate::new(200)
219 .set_body_raw(bytes.to_vec(), content_type_for_filename(filename)),
220 Err(error) => ResponseTemplate::new(500).set_body_string(format!("{error:#}")),
221 };
222 }
223 return ResponseTemplate::new(404);
224 }
225
226 ResponseTemplate::new(404)
227}
228
229fn build_simple_api_response(
231 package_name: &str,
232 entry: &PackageEntry,
233 server_uri: &str,
234) -> ResponseTemplate {
235 let files: Vec<serde_json::Value> = entry
236 .dists
237 .iter()
238 .map(|dist| {
239 let url = format!("{server_uri}/files/{}", dist.filename);
240 let mut file_obj = json!({
241 "filename": dist.filename,
242 "url": url,
243 "hashes": {
244 "sha256": dist.sha256,
245 },
246 "upload-time": dist.upload_time,
247 });
248 if let Some(rp) = &dist.requires_python {
249 file_obj["requires-python"] = json!(rp);
250 }
251 if dist.yanked {
252 file_obj["yanked"] = json!(true);
253 }
254 file_obj
255 })
256 .collect();
257
258 let body = json!({
259 "meta": { "api-version": "1.1" },
260 "name": package_name,
261 "files": files,
262 });
263
264 let body_str = body.to_string();
265 ResponseTemplate::new(200)
266 .insert_header("Content-Type", "application/vnd.pypi.simple.v1+json")
267 .set_body_raw(body_str, "application/vnd.pypi.simple.v1+json")
268}
269
270fn extract_package_name(path: &str) -> Option<&str> {
272 let rest = path.strip_prefix("/simple/")?;
273 let pkg = rest.strip_suffix('/').unwrap_or(rest);
274 if pkg.is_empty() || pkg.contains('/') {
275 return None;
276 }
277 Some(pkg)
278}
279
280#[cfg(test)]
281mod tests {
282 use crate::vendor::vendor_artifacts;
283
284 use super::{Scenario, build_server_index, extract_package_name};
285
286 #[test]
287 fn extract_package_name_accepts_with_or_without_trailing_slash() {
288 assert_eq!(extract_package_name("/simple/foo/"), Some("foo"));
289 assert_eq!(extract_package_name("/simple/foo"), Some("foo"));
290 }
291
292 #[test]
293 fn extract_package_name_rejects_invalid_paths() {
294 assert_eq!(extract_package_name("/simple/"), None);
295 assert_eq!(extract_package_name("/simple"), None);
296 assert_eq!(extract_package_name("/simple/foo/bar"), None);
297 }
298
299 #[test]
300 fn server_index_construction_does_not_load_vendor_artifacts() {
301 let _index = build_server_index(&Scenario::empty());
302
303 assert!(
304 vendor_artifacts()
305 .iter()
306 .all(|artifact| !artifact.is_loaded())
307 );
308 }
309}