Skip to main content

check_updates/
indexes.rs

1//! Release metadata from public PyPI and first-match Simple indexes.
2
3use 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    // Python str.isprintable excludes Unicode separators and format characters.
76    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        // Check the raw authority as Url removes an empty userinfo component.
120        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        // Keep an explicitly supplied default port in the display link, as Python does.
153        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        // Match urllib's one-connection-per-request behavior.
207        .pool_max_idle_per_host(0)
208        // Retrying here, not inside reqwest, bounds the total number of attempts.
209        .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        // A cancelled DNS lookup can still have a blocking resolver task.
245        // Do not wait for that task when the CLI needs to exit.
246        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) = &note {
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            // Cache writes are disposable; failures must not fail a lookup.
429            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        // Empty wheel tag components cannot identify a distribution.
498        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        // PEP 691 yanked is false or a reason string. Even an empty reason is yanked.
534        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    // Local TCP fixtures keep HTTP, redirect, and truncated-body tests offline.
708    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                    // Accepted sockets can inherit O_NONBLOCK on macOS.
736                    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                    // Send FIN after the response, then drain the peer before closing.
760                    // An immediate close can send RST and discard the response on macOS.
761                    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        // The blocking task can finish after the runtime has stopped.
827        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="&gt;=3.10">a</a>
921            <a href="demo-1%2E9-py3-none-any.whl" data-requires-python="&gt;=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        // The release link is the initial index URL, not the redirect destination.
1308        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        // Test header retention separately; Index itself forbids credentials over HTTP.
1352        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        // The kernel accepts connections, but no server sends response headers.
1375        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}