1use anyhow::{Result, anyhow, bail};
4use pep440_rs::Version;
5use percent_encoding::percent_decode_str;
6use reqwest::header::{ACCEPT, CONTENT_TYPE};
7use reqwest::{Client, ClientBuilder, Request};
8use scraper::{Html, Selector};
9use serde_json::{Value, json};
10use sha2::{Digest, Sha256};
11use std::collections::HashMap;
12use std::ffi::OsStr;
13use std::fs;
14use std::path::PathBuf;
15use std::sync::{
16 LazyLock,
17 atomic::{AtomicBool, Ordering},
18};
19use std::time::{Duration, SystemTime, UNIX_EPOCH};
20use tokio::runtime::{Builder, Runtime};
21use url::Url;
22
23const CACHE_TTL: f64 = 3600.0;
24const INVALID_INDEX: &str =
25 "Invalid index URL or authentication policy. Check authenticate and index credentials.";
26
27type CacheKey = (String, Vec<Index>);
28type Metadata = (Vec<Release>, Option<String>);
29
30#[derive(Clone, Debug, Eq, Hash, PartialEq)]
31pub struct Release {
32 pub version: Version,
33 pub requires_python: Option<String>,
34}
35
36#[derive(Clone, Debug, Eq, Hash, PartialEq)]
37pub struct Index {
38 pub url: String,
39 pub name: Option<String>,
40 pub explicit: bool,
41 pub default: bool,
42 pub authenticate: String,
43}
44
45impl Default for Index {
46 fn default() -> Self {
47 Self {
48 url: String::new(),
49 name: None,
50 explicit: false,
51 default: false,
52 authenticate: "auto".into(),
53 }
54 }
55}
56
57fn normalize(name: &str) -> String {
58 let mut result = String::with_capacity(name.len());
59 let mut separator = false;
60 for c in name.chars().flat_map(char::to_lowercase) {
61 if matches!(c, '-' | '_' | '.') {
62 if !separator {
63 result.push('-');
64 }
65 separator = true;
66 } else {
67 result.push(c);
68 separator = false;
69 }
70 }
71 result
72}
73
74fn printable_url(value: &str) -> bool {
75 static NONPRINTABLE: LazyLock<regex::Regex> =
77 LazyLock::new(|| regex::Regex::new(r"[\p{C}\p{Z}]").unwrap());
78 !NONPRINTABLE.is_match(value)
79}
80
81fn raw_authority(value: &str) -> Option<&str> {
82 let (_, rest) = value.split_once("://")?;
83 rest.split(['/', '?', '#'])
84 .next()
85 .filter(|part| !part.is_empty())
86}
87
88impl Index {
89 fn credentials(
90 &self,
91 parsed: &Url,
92 env: impl Fn(&str) -> Option<String>,
93 ) -> Result<(String, String)> {
94 if !matches!(self.authenticate.as_str(), "auto" | "always" | "never") {
95 bail!(INVALID_INDEX);
96 }
97 let mut username = percent_decode_str(parsed.username())
98 .decode_utf8_lossy()
99 .into_owned();
100 let mut password = percent_decode_str(parsed.password().unwrap_or(""))
101 .decode_utf8_lossy()
102 .into_owned();
103 if let Some(name) = self.name.as_ref().filter(|name| !name.is_empty()) {
104 let key: String = name
105 .to_uppercase()
106 .chars()
107 .map(|c| {
108 if c.is_ascii_uppercase() || c.is_ascii_digit() {
109 c
110 } else {
111 '_'
112 }
113 })
114 .collect();
115 let prefix = format!("UV_INDEX_{key}");
116 username = env(&format!("{prefix}_USERNAME")).unwrap_or(username);
117 password = env(&format!("{prefix}_PASSWORD")).unwrap_or(password);
118 }
119 let has_url_credentials = raw_authority(&self.url).is_some_and(|value| value.contains('@'));
121 if self.authenticate == "never"
122 && (has_url_credentials || !username.is_empty() || !password.is_empty())
123 {
124 bail!(INVALID_INDEX);
125 }
126 if self.authenticate == "always" && username.is_empty() && password.is_empty() {
127 bail!(INVALID_INDEX);
128 }
129 if (!username.is_empty() || !password.is_empty()) && parsed.scheme() != "https" {
130 bail!(INVALID_INDEX);
131 }
132 Ok((username, password))
133 }
134
135 fn request(&self, name: &str, client: &Client) -> Result<(Request, String)> {
136 if !printable_url(&self.url)
137 || self.url.contains('\\')
138 || raw_authority(&self.url).is_none()
139 {
140 bail!(INVALID_INDEX);
141 }
142 let mut parsed = Url::parse(&self.url).map_err(|_| anyhow!(INVALID_INDEX))?;
143 if !matches!(parsed.scheme(), "http" | "https")
144 || parsed.host_str().is_none()
145 || parsed.query().is_some_and(|q| !q.is_empty())
146 || parsed.fragment().is_some_and(|f| !f.is_empty())
147 || parsed.port() == Some(0)
148 {
149 bail!(INVALID_INDEX);
150 }
151 let (username, password) = self.credentials(&parsed, |key| std::env::var(key).ok())?;
152 let explicit_port = raw_authority(&self.url)
154 .and_then(|a| a.rsplit('@').next())
155 .and_then(|a| {
156 let (_, port) = a.rsplit_once(':')?;
157 if port.is_empty() || !port.bytes().all(|c| c.is_ascii_digit()) {
158 return None;
159 }
160 port.parse::<u16>().ok()
161 });
162 parsed
163 .set_username("")
164 .map_err(|_| anyhow!(INVALID_INDEX))?;
165 parsed
166 .set_password(None)
167 .map_err(|_| anyhow!(INVALID_INDEX))?;
168 parsed.set_query(None);
169 parsed.set_fragment(None);
170 let path = format!(
171 "{}/{}/",
172 parsed.path().trim_end_matches('/'),
173 normalize(name)
174 );
175 parsed.set_path(&path);
176 let link = if let Some(port) = explicit_port {
177 format!(
178 "{}://{}:{}{}",
179 parsed.scheme(),
180 parsed.host_str().unwrap(),
181 port,
182 parsed.path()
183 )
184 } else {
185 parsed.to_string()
186 };
187 let mut builder = client.get(parsed).header(
188 ACCEPT,
189 "application/vnd.pypi.simple.v1+json, text/html;q=0.9",
190 );
191 if !username.is_empty() || !password.is_empty() {
192 builder = builder.basic_auth(username, Some(password));
193 }
194 let request = builder.build().map_err(|_| anyhow!(INVALID_INDEX))?;
195 Ok((request, link))
196 }
197}
198
199fn safe_redirect(old: &Url, target: &Url) -> bool {
200 old.origin() == target.origin() && target.username().is_empty() && target.password().is_none()
201}
202
203fn client_builder() -> ClientBuilder {
204 Client::builder()
205 .timeout(Duration::from_secs(15))
206 .pool_max_idle_per_host(0)
208 .retry(reqwest::retry::never())
210 .redirect(reqwest::redirect::Policy::custom(|attempt| {
211 let target = attempt.url();
212 if attempt.previous().len() >= 10 {
213 return attempt.error("Too many index redirects.");
214 }
215 if attempt
216 .previous()
217 .first()
218 .is_none_or(|old| !safe_redirect(old, target))
219 {
220 return attempt.error("Index redirect to another origin is not allowed.");
221 }
222 attempt.follow()
223 }))
224}
225
226#[derive(Debug)]
227enum ReadError {
228 Http(u16),
229 Network,
230 Interrupted,
231}
232
233pub struct ReleaseClient {
234 no_cache: bool,
235 http: Client,
236 runtime: Option<Runtime>,
237 cache: HashMap<CacheKey, Vec<Release>>,
238 links: HashMap<CacheKey, String>,
239 notes: HashMap<String, String>,
240}
241
242impl Drop for ReleaseClient {
243 fn drop(&mut self) {
244 if let Some(runtime) = self.runtime.take() {
247 runtime.shutdown_background();
248 }
249 }
250}
251
252impl ReleaseClient {
253 pub fn new(no_cache: bool) -> Result<Self> {
254 Ok(Self {
255 no_cache,
256 http: client_builder()
257 .build()
258 .map_err(|_| anyhow!("Could not initialize metadata HTTP client."))?,
259 runtime: Some(
260 Builder::new_current_thread()
261 .enable_all()
262 .build()
263 .map_err(|_| anyhow!("Could not initialize metadata runtime."))?,
264 ),
265 cache: HashMap::new(),
266 links: HashMap::new(),
267 notes: HashMap::new(),
268 })
269 }
270
271 fn read(
272 &self,
273 request: Request,
274 interrupted: &AtomicBool,
275 ) -> std::result::Result<(Vec<u8>, String), ReadError> {
276 let lookup = async {
277 for attempt in 0..3 {
278 if interrupted.load(Ordering::Relaxed) {
279 return Err(ReadError::Interrupted);
280 }
281 let result = match self
282 .http
283 .execute(
284 request
285 .try_clone()
286 .expect("metadata GET request is cloneable"),
287 )
288 .await
289 {
290 Ok(response) => {
291 let status = response.status();
292 if !status.is_success() {
293 Err(ReadError::Http(status.as_u16()))
294 } else {
295 let content_type = response
296 .headers()
297 .get(CONTENT_TYPE)
298 .and_then(|value| value.to_str().ok())
299 .unwrap_or("")
300 .to_owned();
301 response
302 .bytes()
303 .await
304 .map(|body| (body.to_vec(), content_type))
305 .map_err(|_| ReadError::Network)
306 }
307 }
308 Err(_) => Err(ReadError::Network),
309 };
310 let retry = matches!(
311 result,
312 Err(ReadError::Network | ReadError::Http(408 | 429 | 500 | 502 | 503 | 504))
313 );
314 if !retry || attempt == 2 {
315 return result;
316 }
317 tokio::time::sleep(Duration::from_millis(250 * (attempt + 1))).await;
318 }
319 unreachable!("bounded metadata retry loop")
320 };
321 self.runtime
322 .as_ref()
323 .expect("metadata runtime is available")
324 .block_on(async {
325 tokio::select! {
326 biased;
327 _ = async {
328 while !interrupted.load(Ordering::Relaxed) {
329 tokio::time::sleep(Duration::from_millis(25)).await;
330 }
331 } => Err(ReadError::Interrupted),
332 result = lookup => result,
333 }
334 })
335 }
336
337 fn private(
338 &self,
339 name: &str,
340 indexes: &[Index],
341 interrupted: &AtomicBool,
342 ) -> Result<Option<(Vec<Release>, String)>> {
343 for index in indexes {
344 if index.explicit {
345 continue;
346 }
347 let (request, link) = index.request(name, &self.http)?;
348 let (body, content_type) = match self.read(request, interrupted) {
349 Ok(response) => response,
350 Err(ReadError::Interrupted) => return Err(crate::Interrupted.into()),
351 Err(ReadError::Http(404)) => continue,
352 Err(ReadError::Http(code)) => {
353 bail!("Index lookup failed for {name} (HTTP {code}).")
354 }
355 Err(ReadError::Network) => bail!(
356 "Index lookup failed for {name}. Check connectivity and index credentials."
357 ),
358 };
359 let releases = simple_metadata(&body, &content_type, name)
360 .map_err(|_| anyhow!("Invalid Simple index metadata for {name}."))?;
361 return Ok(Some((releases, link)));
362 }
363 Ok(None)
364 }
365
366 fn public(&mut self, name: &str) -> Result<Vec<Release>> {
367 self.public_from(
368 name,
369 &format!("https://pypi.org/pypi/{name}/json"),
370 cache_path(name),
371 )
372 }
373
374 fn public_from(
375 &mut self,
376 name: &str,
377 url: &str,
378 path: Option<PathBuf>,
379 ) -> Result<Vec<Release>> {
380 self.public_from_with_interrupt(name, url, path, &crate::INTERRUPTED)
381 }
382
383 fn public_from_with_interrupt(
384 &mut self,
385 name: &str,
386 url: &str,
387 path: Option<PathBuf>,
388 interrupted: &AtomicBool,
389 ) -> Result<Vec<Release>> {
390 if !self.no_cache
391 && let Some((releases, note)) = path.as_ref().and_then(|p| load_cache(p, now()))
392 {
393 if let Some(note) = note {
394 self.notes.insert(name.into(), note);
395 }
396 return Ok(releases);
397 }
398 let request = self
399 .http
400 .get(url)
401 .build()
402 .map_err(|_| anyhow!("Invalid public PyPI URL."))?;
403 let (body, _) = match self.read(request, interrupted) {
404 Ok(response) => response,
405 Err(ReadError::Interrupted) => return Err(crate::Interrupted.into()),
406 Err(ReadError::Http(404)) => bail!(
407 "Package {name} was not found on public PyPI (HTTP 404). Check the package name; it may be private or unpublished. Use --index or configure tool.uv.index for private packages. For uv workspace dependencies, declare workspace = true in [tool.uv.sources] and use -w to include workspace root sources and check members. No files were written."
408 ),
409 Err(ReadError::Http(code)) => bail!("PyPI lookup failed for {name} (HTTP {code})."),
410 Err(ReadError::Network) => {
411 bail!("PyPI response could not be read for {name}. No files were written.")
412 }
413 };
414 let data: Value = serde_json::from_slice(&body)
415 .map_err(|_| anyhow!("Invalid PyPI release metadata for {name}."))?;
416 let (releases, note) = public_releases(&data, name)?;
417 if let Some(note) = ¬e {
418 self.notes.insert(name.into(), note.clone());
419 }
420 if !self.no_cache
421 && let Some(path) = path
422 {
423 let rows: Vec<Value> = releases
424 .iter()
425 .map(|r| json!([r.version.to_string(), r.requires_python]))
426 .collect();
427 let payload = json!({"time": now(), "releases": rows, "project_urls": note.map(|n| json!({"Releases": n})).unwrap_or(json!({}))});
428 if path
430 .parent()
431 .is_some_and(|parent| fs::create_dir_all(parent).is_ok())
432 {
433 let _ = fs::write(path, payload.to_string());
434 }
435 }
436 Ok(releases)
437 }
438
439 pub fn get(&mut self, name: &str, indexes: &[Index]) -> Result<Vec<Release>> {
440 let name = normalize(name);
441 let key = (name.clone(), indexes.to_vec());
442 if let Some(releases) = self.cache.get(&key) {
443 return Ok(releases.clone());
444 }
445 let (releases, link) = if let Some(found) =
446 self.private(&name, indexes, &crate::INTERRUPTED)?
447 {
448 found
449 } else {
450 if indexes.iter().any(|index| index.default) {
451 bail!("Package {name} was not found on configured indexes. No files were written.");
452 }
453 (
454 self.public(&name)?,
455 format!("https://pypi.org/project/{name}/"),
456 )
457 };
458 self.links.insert(key.clone(), link);
459 self.cache.insert(key, releases.clone());
460 Ok(releases)
461 }
462
463 pub fn release_link(&self, name: &str, indexes: &[Index]) -> Option<String> {
464 self.links
465 .get(&(normalize(name), indexes.to_vec()))
466 .cloned()
467 }
468
469 pub fn project_link(&self, name: &str) -> Option<String> {
470 self.notes.get(&normalize(name)).cloned()
471 }
472}
473
474fn stable(value: &str) -> Option<Version> {
475 let version: Version = value.parse().ok()?;
476 (!version.is_pre() && !version.is_dev()).then_some(version)
477}
478
479fn distribution(filename: &str) -> Option<(String, Version)> {
480 let (name, version) = if let Some(stem) = filename.strip_suffix(".whl") {
481 let parts: Vec<&str> = stem.split('-').collect();
482 if !matches!(parts.len(), 5 | 6) {
483 return None;
484 }
485 let name = parts[0];
486 if name.is_empty()
487 || name.contains("__")
488 || !name
489 .chars()
490 .all(|c| c.is_alphanumeric() || matches!(c, '_' | '.'))
491 {
492 return None;
493 }
494 if parts.len() == 6 && !parts[2].as_bytes().first().is_some_and(u8::is_ascii_digit) {
495 return None;
496 }
497 if parts[parts.len() - 3..]
499 .iter()
500 .any(|tag| tag.split('.').any(str::is_empty))
501 {
502 return None;
503 }
504 (name, parts[1])
505 } else {
506 let stem = filename
507 .strip_suffix(".tar.gz")
508 .or_else(|| filename.strip_suffix(".zip"))?;
509 let (name, version) = stem.rsplit_once('-')?;
510 if name.is_empty() {
511 return None;
512 }
513 (name, version)
514 };
515 Some((normalize(name), stable(version)?))
516}
517
518fn python_requirement(value: Option<&Value>) -> Option<Option<String>> {
519 match value {
520 None | Some(Value::Null) => Some(None),
521 Some(Value::String(s)) => Some(Some(s.clone())),
522 _ => None,
523 }
524}
525
526fn simple_releases(files: &Value, name: &str) -> Result<Vec<Release>> {
527 let files = files
528 .as_array()
529 .filter(|files| files.iter().all(Value::is_object))
530 .ok_or_else(|| anyhow!("Invalid file metadata."))?;
531 let mut releases = Vec::new();
532 for file in files {
533 if file
535 .get("yanked")
536 .is_some_and(|yanked| yanked != &Value::Bool(false))
537 {
538 continue;
539 }
540 let Some(filename) = file.get("filename").and_then(Value::as_str) else {
541 continue;
542 };
543 let Some((package, version)) = distribution(filename) else {
544 continue;
545 };
546 if package == name
547 && let Some(requires_python) = python_requirement(file.get("requires-python"))
548 {
549 releases.push(Release {
550 version,
551 requires_python,
552 });
553 }
554 }
555 Ok(releases)
556}
557
558fn simple_metadata(body: &[u8], content_type: &str, name: &str) -> Result<Vec<Release>> {
559 if content_type.to_ascii_lowercase().contains("json") {
560 let data: Value = serde_json::from_slice(body)?;
561 simple_releases(
562 data.get("files").ok_or_else(|| anyhow!("Missing files."))?,
563 name,
564 )
565 } else {
566 let html = Html::parse_document(std::str::from_utf8(body)?);
567 static ANCHORS: LazyLock<Selector> = LazyLock::new(|| Selector::parse("a[href]").unwrap());
568 let files: Vec<Value> = html.select(&ANCHORS).filter_map(|anchor| {
569 let href = anchor.value().attr("href")?;
570 if href.is_empty() { return None; }
571 let path = href.split('#').next()?.split('?').next()?;
572 let filename = percent_decode_str(path.rsplit('/').next()?).decode_utf8_lossy();
573 Some(json!({"filename": filename, "requires-python": anchor.value().attr("data-requires-python"), "yanked": anchor.value().attr("data-yanked").is_some()}))
574 }).collect();
575 simple_releases(&Value::Array(files), name)
576 }
577}
578
579fn truthy(value: &Value) -> bool {
580 match value {
581 Value::Null => false,
582 Value::Bool(value) => *value,
583 Value::Number(value) => value.as_f64() != Some(0.0),
584 Value::String(value) => !value.is_empty(),
585 Value::Array(value) => !value.is_empty(),
586 Value::Object(value) => !value.is_empty(),
587 }
588}
589
590fn public_releases(data: &Value, name: &str) -> Result<Metadata> {
591 let entries = data
592 .get("releases")
593 .and_then(Value::as_object)
594 .ok_or_else(|| anyhow!("Invalid PyPI release metadata for {name}."))?;
595 let note = release_notes(data.get("info").and_then(|info| info.get("project_urls")));
596 let mut releases = Vec::new();
597 for (value, files) in entries {
598 let Some(version) = stable(value) else {
599 continue;
600 };
601 let files = files
602 .as_array()
603 .filter(|files| files.iter().all(Value::is_object))
604 .ok_or_else(|| anyhow!("Invalid PyPI file metadata for {name}."))?;
605 for file in files {
606 if !file.get("yanked").is_some_and(truthy)
607 && let Some(requires_python) = python_requirement(file.get("requires_python"))
608 {
609 releases.push(Release {
610 version: version.clone(),
611 requires_python,
612 });
613 }
614 }
615 }
616 Ok((releases, note))
617}
618
619fn release_notes(urls: Option<&Value>) -> Option<String> {
620 for (key, value) in urls?.as_object()? {
621 if !matches!(
622 key.to_lowercase().as_str(),
623 "changelog" | "release notes" | "releases"
624 ) {
625 continue;
626 }
627 let Some(value) = value.as_str() else {
628 continue;
629 };
630 if !printable_url(value) || value.contains('\\') {
631 continue;
632 }
633 let Ok(parsed) = Url::parse(value) else {
634 continue;
635 };
636 if matches!(parsed.scheme(), "https" | "http")
637 && parsed.host_str().is_some()
638 && parsed.username().is_empty()
639 && parsed.password().is_none()
640 && parsed.query().is_none_or(str::is_empty)
641 {
642 return Some(value.to_owned());
643 }
644 }
645 None
646}
647
648fn now() -> f64 {
649 SystemTime::now()
650 .duration_since(UNIX_EPOCH)
651 .unwrap_or_default()
652 .as_secs_f64()
653}
654
655fn cache_path_from(name: &str, xdg: Option<&OsStr>, home: Option<PathBuf>) -> Option<PathBuf> {
656 let root = xdg
657 .map(PathBuf::from)
658 .filter(|path| path.is_absolute())
659 .or_else(|| {
660 home.filter(|path| path.is_absolute())
661 .map(|home| home.join(".cache"))
662 })?;
663 let digest = format!("{:x}", Sha256::digest(normalize(name).as_bytes()));
664 Some(root.join("check-updates").join(format!("v1-{digest}.json")))
665}
666
667fn cache_path(name: &str) -> Option<PathBuf> {
668 #[allow(deprecated)]
669 let home = std::env::home_dir();
670 cache_path_from(name, std::env::var_os("XDG_CACHE_HOME").as_deref(), home)
671}
672
673fn load_cache(path: &PathBuf, now: f64) -> Option<Metadata> {
674 let data: Value = serde_json::from_slice(&fs::read(path).ok()?).ok()?;
675 let age = now - data.get("time")?.as_f64()?;
676 if !(0.0..CACHE_TTL).contains(&age) {
677 return None;
678 }
679 let mut releases = Vec::new();
680 for row in data.get("releases")?.as_array()? {
681 let row = row.as_array()?;
682 if row.len() != 2 {
683 return None;
684 }
685 let version = row[0].as_str()?.parse().ok()?;
686 let requires_python = python_requirement(Some(&row[1]))?;
687 releases.push(Release {
688 version,
689 requires_python,
690 });
691 }
692 Some((releases, release_notes(data.get("project_urls"))))
693}
694
695#[cfg(test)]
696mod tests {
697 use super::*;
698 use std::collections::VecDeque;
699 use std::io::{Read, Write};
700 use std::net::TcpListener;
701 use std::sync::{
702 Arc, Mutex,
703 atomic::{AtomicBool, Ordering},
704 };
705 use std::thread::{self, JoinHandle};
706
707 struct Server {
709 url: String,
710 requests: Arc<Mutex<Vec<String>>>,
711 stop: Arc<AtomicBool>,
712 worker: Option<JoinHandle<()>>,
713 }
714
715 impl Server {
716 fn new(responses: impl FnOnce(&str) -> Vec<String>) -> Self {
717 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
718 listener.set_nonblocking(true).unwrap();
719 let url = format!("http://{}", listener.local_addr().unwrap());
720 let mut responses = VecDeque::from(responses(&url));
721 let requests = Arc::new(Mutex::new(Vec::new()));
722 let stop = Arc::new(AtomicBool::new(false));
723 let seen = requests.clone();
724 let stopped = stop.clone();
725 let worker = thread::spawn(move || {
726 while !stopped.load(Ordering::SeqCst) {
727 let (mut stream, _) = match listener.accept() {
728 Ok(stream) => stream,
729 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
730 thread::sleep(Duration::from_millis(5));
731 continue;
732 }
733 Err(error) => panic!("test accept: {error}"),
734 };
735 stream.set_nonblocking(false).unwrap();
737 stream
738 .set_read_timeout(Some(Duration::from_secs(2)))
739 .unwrap();
740 let mut bytes = Vec::new();
741 let mut buffer = [0; 1024];
742 while !bytes.windows(4).any(|w| w == b"\r\n\r\n") {
743 let n = stream.read(&mut buffer).unwrap_or(0);
744 if n == 0 {
745 break;
746 }
747 bytes.extend_from_slice(&buffer[..n]);
748 }
749 if bytes.is_empty() {
750 continue;
751 }
752 seen.lock()
753 .unwrap()
754 .push(String::from_utf8_lossy(&bytes).into_owned());
755 let response = responses
756 .pop_front()
757 .unwrap_or_else(|| response(500, "text/plain", "unexpected request"));
758 let _ = stream.write_all(response.as_bytes());
759 let _ = stream.shutdown(std::net::Shutdown::Write);
762 while stream.read(&mut buffer).unwrap_or(0) != 0 {}
763 }
764 });
765 Self {
766 url,
767 requests,
768 stop,
769 worker: Some(worker),
770 }
771 }
772
773 fn count(&self) -> usize {
774 self.requests.lock().unwrap().len()
775 }
776 }
777
778 impl Drop for Server {
779 fn drop(&mut self) {
780 self.stop.store(true, Ordering::SeqCst);
781 self.worker.take().unwrap().join().unwrap();
782 }
783 }
784
785 fn response(code: u16, content_type: &str, body: &str) -> String {
786 format!(
787 "HTTP/1.1 {code} Test\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
788 body.len()
789 )
790 }
791
792 fn client(no_cache: bool) -> ReleaseClient {
793 let mut client = ReleaseClient::new(no_cache).unwrap();
794 client.http = client_builder().no_proxy().build().unwrap();
795 client
796 }
797
798 fn index(url: &str) -> Index {
799 Index {
800 url: url.into(),
801 default: true,
802 ..Index::default()
803 }
804 }
805 fn release(version: &str, python: Option<&str>) -> Release {
806 Release {
807 version: version.parse().unwrap(),
808 requires_python: python.map(str::to_owned),
809 }
810 }
811
812 #[test]
813 fn client_shutdown_does_not_wait_for_a_blocking_resolver() {
814 let client = client(true);
815 let (started, ready) = std::sync::mpsc::channel();
816 let (release, wait) = std::sync::mpsc::channel();
817 let worker = client.runtime.as_ref().unwrap().spawn_blocking(move || {
818 started.send(()).unwrap();
819 let _ = wait.recv_timeout(Duration::from_secs(2));
820 });
821 ready.recv_timeout(Duration::from_secs(2)).unwrap();
822 let start = std::time::Instant::now();
823 drop(client);
824 assert!(start.elapsed() < Duration::from_secs(1));
825 release.send(()).unwrap();
826 drop(worker);
828 }
829
830 #[test]
831 fn cancellation_before_dispatch_preserves_interrupted_error() {
832 let server = Server::new(|_| vec![response(503, "text/plain", "private body")]);
833 let fallback = Server::new(|_| vec![response(200, "application/json", r#"{"files": []}"#)]);
834 let interrupted = AtomicBool::new(true);
835 let mut client = client(false);
836 let temp = tempfile::tempdir().unwrap();
837 let cache = temp.path().join("cache.json");
838 let private_error = client
839 .private(
840 "demo",
841 &[index(&server.url), index(&fallback.url)],
842 &interrupted,
843 )
844 .unwrap_err();
845 assert!(private_error.downcast_ref::<crate::Interrupted>().is_some());
846 let public_error = client
847 .public_from_with_interrupt("demo", &server.url, Some(cache.clone()), &interrupted)
848 .unwrap_err();
849 assert!(public_error.downcast_ref::<crate::Interrupted>().is_some());
850 assert_eq!(server.count(), 0);
851 assert_eq!(fallback.count(), 0);
852 assert!(!cache.exists());
853 }
854
855 #[test]
856 fn cancellation_during_backoff_stops_retries_and_index_fallback() {
857 for public in [false, true] {
858 let server = Server::new(|_| vec![response(503, "text/plain", "private body")]);
859 let fallback =
860 Server::new(|_| vec![response(200, "application/json", r#"{"files": []}"#)]);
861 let interrupted = Arc::new(AtomicBool::new(false));
862 let flag = interrupted.clone();
863 let requests = server.requests.clone();
864 let trigger = thread::spawn(move || {
865 let started = std::time::Instant::now();
866 while requests.lock().unwrap().is_empty() {
867 assert!(
868 started.elapsed() < Duration::from_secs(2),
869 "request did not start"
870 );
871 thread::sleep(Duration::from_millis(5));
872 }
873 thread::sleep(Duration::from_millis(40));
874 flag.store(true, Ordering::Relaxed);
875 });
876 let temp = tempfile::tempdir().unwrap();
877 let cache = temp.path().join("cache.json");
878 let mut client = client(false);
879 let started = std::time::Instant::now();
880 let error = if public {
881 client
882 .public_from_with_interrupt(
883 "demo",
884 &server.url,
885 Some(cache.clone()),
886 &interrupted,
887 )
888 .unwrap_err()
889 } else {
890 client
891 .private(
892 "demo",
893 &[index(&server.url), index(&fallback.url)],
894 &interrupted,
895 )
896 .unwrap_err()
897 };
898 trigger.join().unwrap();
899 assert!(started.elapsed() < Duration::from_secs(2));
900 assert!(error.downcast_ref::<crate::Interrupted>().is_some());
901 assert_eq!(error.to_string(), "Interrupted.");
902 assert_eq!(server.count(), 1);
903 assert_eq!(fallback.count(), 0);
904 assert!(!cache.exists());
905 }
906 }
907
908 #[test]
909 fn simple_json_and_html_contracts() {
910 let files = json!([
911 {"filename": "demo-1.0.tar.gz", "requires-python": ">=3.10"},
912 {"filename": "demo-1.9-py3-none-any.whl", "requires-python": ">=3.12"},
913 {"filename": "demo-2.0.zip", "yanked": true},
914 {"filename": "demo-2.1.tar.gz", "yanked": ""},
915 {"filename": "demo-3.0rc1.tar.gz"}, {"filename": "demo-4.0.dev1.tar.gz"},
916 {"filename": "other-5.0.tar.gz"}, {"filename": "junk"}
917 ]);
918 let expected = vec![release("1", Some(">=3.10")), release("1.9", Some(">=3.12"))];
919 assert_eq!(simple_releases(&files, "demo").unwrap(), expected);
920 let html = br#"<a href="../demo-1.0.tar.gz#hash" data-requires-python=">=3.10">a</a>
921 <a href="demo-1%2E9-py3-none-any.whl" data-requires-python=">=3.12">b</a>
922 <a href="demo-2.zip" data-yanked="">c</a><a href="other-7.zip">d</a>
923 <a href="demo-4.dev1.tar.gz">e</a><a href="">f</a>"#;
924 assert_eq!(
925 simple_metadata(html, "text/html", "demo").unwrap(),
926 expected
927 );
928 let server = Server::new(|_| {
929 vec![response(
930 200,
931 "APPLICATION/JSON",
932 &json!({"files": files}).to_string(),
933 )]
934 });
935 assert_eq!(
936 client(false).get("Demo", &[index(&server.url)]).unwrap(),
937 expected
938 );
939 }
940
941 #[test]
942 fn metadata_validation_and_file_filtering() {
943 for payload in [
944 "[]",
945 r#"{"files":null}"#,
946 r#"{"files":["bad"]}"#,
947 "not JSON",
948 ] {
949 assert!(simple_metadata(payload.as_bytes(), "application/json", "demo").is_err());
950 }
951 let files = json!([
952 {"filename": "demo-1.zip", "yanked": null}, {"filename": "demo-2.zip", "yanked": 0},
953 {"filename": "demo-3.zip", "requires-python": 12}, {"filename": 17},
954 {"filename": "demo-4.0+local-py3-none-any.whl"},
955 {"filename": "demo-5.post1.tar.gz"}, {"filename": "demo-6-1build-py3-none-any.whl"}
956 ]);
957 assert_eq!(
958 simple_releases(&files, "demo").unwrap(),
959 vec![
960 release("4+local", None),
961 release("5.post1", None),
962 release("6", None)
963 ]
964 );
965 for filename in [
966 "demo.whl",
967 "demo-x.tar.gz",
968 "demo-1.exe",
969 "demo-1-abc-py3-none-any.whl",
970 "de__mo-1-py3-none-any.whl",
971 "demo-1--none-any.whl",
972 ] {
973 assert!(distribution(filename).is_none(), "{filename}");
974 }
975 assert_eq!(distribution("Demo_Pkg-1.zip").unwrap().0, "demo-pkg");
976 }
977
978 #[test]
979 fn public_metadata_contracts() {
980 let data = json!({"releases": {
981 "1": [{"requires_python": ">=3.10"}, {"yanked": ""}, {"yanked": true}, {"requires_python": 3}],
982 "2rc1": null, "3.dev1": [{}], "bad": null, "4": [], "5.post1": [{"yanked": 0}]
983 }, "info": {"project_urls": {"Changelog": "https://example.org/releases"}}});
984 let (found, note) = public_releases(&data, "demo").unwrap();
985 assert_eq!(
986 found,
987 vec![
988 release("1", Some(">=3.10")),
989 release("1", None),
990 release("5.post1", None)
991 ]
992 );
993 assert_eq!(note.as_deref(), Some("https://example.org/releases"));
994 for data in [
995 json!([]),
996 json!({"releases": []}),
997 json!({"releases": {"1": null}}),
998 json!({"releases": {"1": [4]}}),
999 ] {
1000 assert!(
1001 public_releases(&data, "demo")
1002 .unwrap_err()
1003 .to_string()
1004 .starts_with("Invalid PyPI")
1005 );
1006 }
1007 }
1008
1009 #[test]
1010 fn first_match_empty_result_and_memory_cache() {
1011 let first = Server::new(|_| vec![response(200, "application/json", r#"{"files":[]}"#)]);
1012 let second = Server::new(|_| vec![]);
1013 let indexes = [index(&first.url), index(&second.url)];
1014 let mut client = client(true);
1015 assert_eq!(client.get("Demo_Pkg", &indexes).unwrap(), vec![]);
1016 assert_eq!(client.get("demo-pkg", &indexes).unwrap(), vec![]);
1017 assert_eq!(first.count(), 1);
1018 assert_eq!(second.count(), 0);
1019 assert_eq!(
1020 client.release_link("DEMO.pkg", &indexes),
1021 Some(format!("{}/demo-pkg/", first.url))
1022 );
1023 assert_eq!(client.project_link("demo-pkg"), None);
1024 assert_eq!(client.release_link("demo-pkg", &[]), None);
1025 }
1026
1027 #[test]
1028 fn not_found_only_moves_to_next_index() {
1029 let first = Server::new(|_| vec![response(404, "text/plain", "missing")]);
1030 let second = Server::new(|_| {
1031 vec![response(
1032 200,
1033 "application/json",
1034 r#"{"files":[{"filename":"demo-2.zip"}]}"#,
1035 )]
1036 });
1037 let mut client = client(true);
1038 let indexes = [index(&first.url), index(&second.url)];
1039 assert_eq!(
1040 client.get("demo", &indexes).unwrap(),
1041 vec![release("2", None)]
1042 );
1043 assert_eq!(first.count(), 1);
1044 assert_eq!(second.count(), 1);
1045 let explicit = Index {
1046 explicit: true,
1047 ..index("not even a URL")
1048 };
1049 let error = client.get("other", &[explicit]).unwrap_err();
1050 assert!(
1051 error
1052 .to_string()
1053 .contains("not found on configured indexes")
1054 );
1055 }
1056
1057 #[test]
1058 fn all_404_and_explicit_default_block_public() {
1059 let first = Server::new(|_| vec![response(404, "text/plain", "missing")]);
1060 let indexes = [
1061 index(&first.url),
1062 Index {
1063 explicit: true,
1064 ..index("https://unused.invalid")
1065 },
1066 ];
1067 assert!(
1068 client(true)
1069 .get("demo", &indexes)
1070 .unwrap_err()
1071 .to_string()
1072 .contains("configured indexes")
1073 );
1074 assert_eq!(first.count(), 1);
1075 }
1076
1077 #[test]
1078 fn cache_key_includes_index_configuration() {
1079 let first = Server::new(|_| {
1080 vec![response(
1081 200,
1082 "application/json",
1083 r#"{"files":[{"filename":"demo-1.zip"}]}"#,
1084 )]
1085 });
1086 let second = Server::new(|_| {
1087 vec![response(
1088 200,
1089 "application/json",
1090 r#"{"files":[{"filename":"demo-2.zip"}]}"#,
1091 )]
1092 });
1093 let mut client = client(true);
1094 assert_eq!(
1095 client.get("demo", &[index(&first.url)]).unwrap(),
1096 vec![release("1", None)]
1097 );
1098 assert_eq!(
1099 client.get("demo", &[index(&second.url)]).unwrap(),
1100 vec![release("2", None)]
1101 );
1102 }
1103
1104 #[test]
1105 fn unsafe_index_urls_fail_without_request_and_without_secrets() {
1106 let client = client(true);
1107 for url in [
1108 "http://user:secret@example.org/simple",
1109 "file:///tmp/simple",
1110 "https:example.org/simple",
1111 "https:////example.org/simple",
1112 "https://example.org/simple?token=secret",
1113 "https://example.org/simple#secret",
1114 "https://example.org/\x1b[31m",
1115 "https://example.org/white space",
1116 "https://example.org/\u{200b}",
1117 "https://user:secret@example.org:0/simple",
1118 "https://[::1]:0/simple",
1119 "https://example.org:65536",
1120 "https://example.org\\@other.org",
1121 ] {
1122 let error = index(url).request("demo", &client.http).unwrap_err();
1123 assert_eq!(format!("{error:?}"), INVALID_INDEX, "{url}");
1124 }
1125 }
1126
1127 #[test]
1128 fn credential_decoding_policy_and_environment_overrides() {
1129 let client = client(true);
1130 let mut source = index("https://user%40example:secret%3Avalue@example.org/simple");
1131 let (request, link) = source.request("Demo_Pkg", &client.http).unwrap();
1132 assert_eq!(
1133 request.url().as_str(),
1134 "https://example.org/simple/demo-pkg/"
1135 );
1136 assert_eq!(link, "https://example.org/simple/demo-pkg/");
1137 assert_eq!(
1138 request.headers()["authorization"],
1139 "Basic dXNlckBleGFtcGxlOnNlY3JldDp2YWx1ZQ=="
1140 );
1141 for name in ["internal.proxy", "internal-proxy", "internal.proxy-cache_2"] {
1142 source.name = Some(name.into());
1143 let expected = if name.ends_with('2') {
1144 "UV_INDEX_INTERNAL_PROXY_CACHE_2"
1145 } else {
1146 "UV_INDEX_INTERNAL_PROXY"
1147 };
1148 let parsed = Url::parse(&source.url).unwrap();
1149 let credentials = source
1150 .credentials(&parsed, |key| {
1151 if key == format!("{expected}_USERNAME") {
1152 Some("env-user".into())
1153 } else if key == format!("{expected}_PASSWORD") {
1154 Some("env-password".into())
1155 } else {
1156 panic!("unexpected credential key: {key}");
1157 }
1158 })
1159 .unwrap();
1160 assert_eq!(credentials, ("env-user".into(), "env-password".into()));
1161 }
1162 for policy in ["never", "invalid"] {
1163 source.authenticate = policy.into();
1164 assert!(source.request("demo", &client.http).is_err());
1165 }
1166 let mut source = index("https://example.org/simple");
1167 source.authenticate = "always".into();
1168 assert!(source.request("demo", &client.http).is_err());
1169 source.name = Some("required".into());
1170 assert_eq!(
1171 source
1172 .credentials(&Url::parse(&source.url).unwrap(), |_| Some("env".into()))
1173 .unwrap(),
1174 ("env".into(), "env".into())
1175 );
1176 source.authenticate = "never".into();
1177 assert!(
1178 source
1179 .credentials(&Url::parse(&source.url).unwrap(), |_| Some("secret".into()))
1180 .is_err()
1181 );
1182 assert!(
1183 !source
1184 .request("demo", &client.http)
1185 .unwrap()
1186 .0
1187 .headers()
1188 .contains_key("authorization")
1189 );
1190 source.url = "https://@example.org/simple".into();
1191 assert!(source.request("demo", &client.http).is_err());
1192 assert_eq!(Index::default().authenticate, "auto");
1193 }
1194
1195 #[test]
1196 fn display_links_preserve_ports_and_ipv6_without_credentials() {
1197 let client = client(true);
1198 for host in ["packages.example", "[::1]"] {
1199 for port in [1, 443, 8443, 65535] {
1200 let url = format!("https://user:secret@{host}:{port}/simple");
1201 let (request, link) = index(&url).request("demo", &client.http).unwrap();
1202 assert_eq!(link, format!("https://{host}:{port}/simple/demo/"));
1203 assert!(!request.url().as_str().contains("secret"));
1204 }
1205 }
1206 }
1207
1208 #[test]
1209 fn private_http_and_metadata_failures_are_sanitized() {
1210 for code in [401, 403, 429, 500] {
1211 let count = if matches!(code, 429 | 500) { 3 } else { 1 };
1212 let server = Server::new(|_| {
1213 (0..count)
1214 .map(|_| response(code, "text/plain", "response-secret"))
1215 .collect()
1216 });
1217 let unused = Server::new(|_| vec![]);
1218 let error = client(true)
1219 .get("demo", &[index(&server.url), index(&unused.url)])
1220 .unwrap_err();
1221 assert_eq!(
1222 format!("{error:?}"),
1223 format!("Index lookup failed for demo (HTTP {code}).")
1224 );
1225 assert_eq!(server.count(), count);
1226 assert_eq!(unused.count(), 0);
1227 }
1228 let server = Server::new(|_| {
1229 vec![response(
1230 200,
1231 "application/json",
1232 r#"{"secret":"response-secret"}"#,
1233 )]
1234 });
1235 let error = client(true).get("demo", &[index(&server.url)]).unwrap_err();
1236 assert_eq!(
1237 format!("{error:?}"),
1238 "Invalid Simple index metadata for demo."
1239 );
1240 }
1241
1242 #[test]
1243 fn transient_status_and_truncated_body_retry() {
1244 for code in [408, 429, 500, 502, 503, 504] {
1245 let server = Server::new(|_| {
1246 vec![
1247 response(code, "text/plain", "retry"),
1248 response(code, "text/plain", "retry"),
1249 response(200, "application/json", r#"{"files":[]}"#),
1250 ]
1251 });
1252 let found = client(true)
1253 .get("demo", &[index(&server.url)])
1254 .unwrap_or_else(|error| {
1255 panic!(
1256 "HTTP {code}: {error:?}; requests: {:?}",
1257 server.requests.lock().unwrap()
1258 );
1259 });
1260 assert_eq!(found, vec![]);
1261 assert_eq!(server.count(), 3);
1262 }
1263 let truncated =
1264 "HTTP/1.1 200 OK\r\nContent-Length: 999\r\nConnection: close\r\n\r\nresponse-secret";
1265 let server = Server::new(|_| {
1266 vec![
1267 truncated.into(),
1268 truncated.into(),
1269 response(200, "application/json", r#"{"files":[]}"#),
1270 ]
1271 });
1272 assert!(
1273 client(true)
1274 .get("demo", &[index(&server.url)])
1275 .unwrap()
1276 .is_empty()
1277 );
1278 assert_eq!(server.count(), 3);
1279 let server = Server::new(|_| vec![truncated.into(); 3]);
1280 let error = client(true).get("demo", &[index(&server.url)]).unwrap_err();
1281 assert_eq!(
1282 format!("{error:?}"),
1283 "Index lookup failed for demo. Check connectivity and index credentials."
1284 );
1285 assert_eq!(server.count(), 3);
1286 }
1287
1288 #[test]
1289 fn same_origin_redirect_follows_but_cross_origin_never_connects() {
1290 let server = Server::new(|base| {
1291 vec![
1292 format!(
1293 "HTTP/1.1 302 Found\r\nLocation: {base}/new/\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
1294 ),
1295 response(200, "application/json", r#"{"files":[]}"#),
1296 ]
1297 });
1298 let mut client = client(true);
1299 assert!(
1300 client
1301 .get("demo", &[index(&server.url)])
1302 .unwrap()
1303 .is_empty()
1304 );
1305 assert_eq!(server.count(), 2);
1306 assert!(server.requests.lock().unwrap()[1].starts_with("GET /new/ HTTP/1.1"));
1307 assert_eq!(
1309 client.release_link("demo", &[index(&server.url)]),
1310 Some(format!("{}/demo/", server.url))
1311 );
1312 let target = Server::new(|_| vec![]);
1313 let server = Server::new(|_| {
1314 vec![
1315 format!(
1316 "HTTP/1.1 302 Found\r\nLocation: {}/secret\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
1317 target.url
1318 );
1319 3
1320 ]
1321 });
1322 assert!(client.get("other", &[index(&server.url)]).is_err());
1323 assert_eq!(server.count(), 3);
1324 assert_eq!(target.count(), 0);
1325 }
1326
1327 #[test]
1328 fn redirect_policy_rejects_downgrades_and_credentials_and_keeps_same_origin_auth() {
1329 let old = Url::parse("https://example.org/simple/").unwrap();
1330 for destination in [
1331 "https://other.org/simple/",
1332 "http://example.org/simple/",
1333 "https://example.org:8443/simple/",
1334 "https://user:secret@example.org/simple/",
1335 ] {
1336 assert!(!safe_redirect(&old, &Url::parse(destination).unwrap()));
1337 }
1338 assert!(safe_redirect(
1339 &old,
1340 &Url::parse("https://example.org/new/").unwrap()
1341 ));
1342 let server = Server::new(|base| {
1343 vec![
1344 format!(
1345 "HTTP/1.1 302 Found\r\nLocation: {base}/new/\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
1346 ),
1347 response(200, "text/plain", "done"),
1348 ]
1349 });
1350 let client = client(true);
1351 let request = client
1353 .http
1354 .get(&server.url)
1355 .basic_auth("user", Some("secret"))
1356 .build()
1357 .unwrap();
1358 assert_eq!(
1359 client.read(request, &AtomicBool::new(false)).unwrap().0,
1360 b"done"
1361 );
1362 assert_eq!(server.count(), 2);
1363 for request in server.requests.lock().unwrap().iter() {
1364 assert!(
1365 request
1366 .to_ascii_lowercase()
1367 .contains("authorization: basic dxnlcjpzzwnyzxq=")
1368 );
1369 }
1370 }
1371
1372 #[test]
1373 fn request_timeout_retries_are_bounded() {
1374 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1376 let mut client = client(true);
1377 client.http = client_builder()
1378 .no_proxy()
1379 .timeout(Duration::from_millis(30))
1380 .build()
1381 .unwrap();
1382 let url = format!("http://{}", listener.local_addr().unwrap());
1383 let start = std::time::Instant::now();
1384 let error = client.get("demo", &[index(&url)]).unwrap_err();
1385 assert_eq!(
1386 format!("{error:?}"),
1387 "Index lookup failed for demo. Check connectivity and index credentials."
1388 );
1389 assert!(start.elapsed() >= Duration::from_millis(750));
1390 listener.set_nonblocking(true).unwrap();
1391 let mut attempts = 0;
1392 while listener.accept().is_ok() {
1393 attempts += 1;
1394 }
1395 assert_eq!(attempts, 3);
1396 }
1397
1398 #[test]
1399 fn safe_release_note_links_only() {
1400 for value in [
1401 "https://user:secret@example.org/notes",
1402 "javascript:alert(1)",
1403 "https://example.org/notes?token=secret",
1404 "https://example.org/\x1b[31m",
1405 "https://example.org/\u{200b}",
1406 "https://example.org/a b",
1407 "https://example.org\\@other.org",
1408 ] {
1409 assert_eq!(
1410 release_notes(Some(&json!({"Changelog": value}))),
1411 None,
1412 "{value}"
1413 );
1414 }
1415 assert_eq!(
1416 release_notes(Some(
1417 &json!({"Homepage": "https://example.org", "RELEASE NOTES": "https://example.org/notes#v1"})
1418 )),
1419 Some("https://example.org/notes#v1".into())
1420 );
1421 assert_eq!(
1422 release_notes(Some(
1423 &json!({"Changelog": 42, "Releases": "http://example.org"})
1424 )),
1425 Some("http://example.org".into())
1426 );
1427 assert_eq!(release_notes(Some(&json!([]))), None);
1428 }
1429
1430 #[test]
1431 fn cache_path_is_absolute_normalized_and_hashed() {
1432 let temp = tempfile::tempdir().unwrap();
1433 let home = temp.path().join("home");
1434 for setting in [None, Some(""), Some("relative-cache"), Some(".")] {
1435 let path =
1436 cache_path_from("Demo_Pkg", setting.map(OsStr::new), Some(home.clone())).unwrap();
1437 assert_eq!(path.parent().unwrap(), home.join(".cache/check-updates"));
1438 assert_eq!(
1439 Some(path.clone()),
1440 cache_path_from("demo-pkg", setting.map(OsStr::new), Some(home.clone()))
1441 );
1442 assert!(path.is_absolute());
1443 assert!(
1444 path.file_name()
1445 .unwrap()
1446 .to_str()
1447 .unwrap()
1448 .starts_with("v1-")
1449 );
1450 assert_eq!(path.file_name().unwrap().len(), 72);
1451 }
1452 let configured = temp.path().join("configured");
1453 assert_eq!(
1454 cache_path_from("demo", Some(configured.as_os_str()), None)
1455 .unwrap()
1456 .parent()
1457 .unwrap(),
1458 configured.join("check-updates")
1459 );
1460 assert!(cache_path_from("demo", Some(OsStr::new("relative")), None).is_none());
1461 }
1462
1463 #[test]
1464 fn cache_validation_ttl_future_and_corruption() {
1465 let dir = tempfile::tempdir().unwrap();
1466 let path = dir.path().join("cache.json");
1467 let data = json!({"time": 10000, "releases": [["2", ">=3.10"]], "project_urls": {"Releases": "https://example.org/notes"}});
1468 fs::write(&path, data.to_string()).unwrap();
1469 assert_eq!(
1470 load_cache(&path, 10000.0).unwrap().0,
1471 vec![release("2", Some(">=3.10"))]
1472 );
1473 assert!(load_cache(&path, 13599.0).is_some());
1474 assert!(load_cache(&path, 13600.0).is_none());
1475 assert!(load_cache(&path, 9999.0).is_none());
1476 for data in [
1477 json!([]),
1478 json!({"time":10000, "releases":[["bad", null]]}),
1479 json!({"time":10000, "releases":[["1", false]]}),
1480 json!({"time":10000, "releases":[["1"]]}),
1481 json!({"time":"10000", "releases":[]}),
1482 ] {
1483 fs::write(&path, data.to_string()).unwrap();
1484 assert!(load_cache(&path, 10000.0).is_none());
1485 }
1486 fs::write(&path, "broken").unwrap();
1487 assert!(load_cache(&path, 10000.0).is_none());
1488 }
1489
1490 #[test]
1491 fn public_disk_cache_hit_expiry_corruption_bypass_and_write_failure() {
1492 let dir = tempfile::tempdir().unwrap();
1493 let path = dir.path().join("cache/demo.json");
1494 let data = r#"{"releases":{"2":[{"requires_python":">=3.10"}]},"info":{"project_urls":{"Changelog":"https://example.org/notes"}}}"#;
1495 let server = Server::new(|_| vec![response(200, "application/json", data); 5]);
1496 let expected = vec![release("2", Some(">=3.10"))];
1497 assert_eq!(
1498 client(false)
1499 .public_from("demo", &server.url, Some(path.clone()))
1500 .unwrap(),
1501 expected
1502 );
1503 let mut cached = client(false);
1504 assert_eq!(
1505 cached
1506 .public_from("demo", &server.url, Some(path.clone()))
1507 .unwrap(),
1508 expected
1509 );
1510 assert_eq!(
1511 cached.project_link("Demo"),
1512 Some("https://example.org/notes".into())
1513 );
1514 assert_eq!(server.count(), 1);
1515 let mut payload: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
1516 payload["time"] = json!(now() - CACHE_TTL - 1.0);
1517 fs::write(&path, payload.to_string()).unwrap();
1518 assert_eq!(
1519 client(false)
1520 .public_from("demo", &server.url, Some(path.clone()))
1521 .unwrap(),
1522 expected
1523 );
1524 assert_eq!(server.count(), 2);
1525 fs::write(&path, "broken").unwrap();
1526 assert_eq!(
1527 client(false)
1528 .public_from("demo", &server.url, Some(path.clone()))
1529 .unwrap(),
1530 expected
1531 );
1532 assert_eq!(server.count(), 3);
1533 let before = fs::read(&path).unwrap();
1534 assert_eq!(
1535 client(true)
1536 .public_from("demo", &server.url, Some(path.clone()))
1537 .unwrap(),
1538 expected
1539 );
1540 assert_eq!(server.count(), 4);
1541 assert_eq!(fs::read(&path).unwrap(), before);
1542 let impossible = path.join("child.json");
1543 assert_eq!(
1544 client(false)
1545 .public_from("demo", &server.url, Some(impossible))
1546 .unwrap(),
1547 expected
1548 );
1549 assert_eq!(server.count(), 5);
1550 }
1551
1552 #[test]
1553 fn public_errors_are_actionable_and_sanitized() {
1554 let server = Server::new(|_| vec![response(404, "text/plain", "response-secret")]);
1555 let error = client(true)
1556 .public_from("demo", &server.url, None)
1557 .unwrap_err();
1558 let text = format!("{error:?}");
1559 assert!(text.contains("private or unpublished"));
1560 assert!(!text.contains("response-secret"));
1561 let truncated =
1562 "HTTP/1.1 200 OK\r\nContent-Length: 999\r\nConnection: close\r\n\r\nresponse-secret";
1563 let server = Server::new(|_| vec![truncated.into(); 3]);
1564 let error = client(true)
1565 .public_from("demo", &server.url, None)
1566 .unwrap_err();
1567 assert_eq!(
1568 format!("{error:?}"),
1569 "PyPI response could not be read for demo. No files were written."
1570 );
1571 assert_eq!(server.count(), 3);
1572 }
1573}