Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

server/repository/znippy-python/src/lib.rs

//! Python (pip) repository backend: serves a PEP 503 "simple" index synthesized
//! from wheels/sdists inside a read-only znippy archive. Sibling to the other
//! `znippy-<eco>` backends; implements [`RepositoryBackendTrait`] and is wired in
//! read-only (`is_writable` false, `put` errors).
//!
//! Layout convention: files live at `packages/{normalized-name}/{filename}`, name
//! normalized per PEP 503 (lowercase, `_`/`.` → `-`). `handle_http2_request` routes
//! `/simple/`, `/simple/{name}/`, and `/packages/{name}/{filename}` after stripping
//! the repo-name prefix.
//!
//! Coord resolution has two tiers: prefer the concrete archive's typed python view
//! ([`ZnippyArchive::as_python`]) whose `(name, version)` come from index columns the
//! python plugin extracted, so wheels vs sdists resolve authoritatively; fall back to
//! parsing the dist *filename* when that view is empty (e.g. an archive built by the
//! agent's directory→znippy path, which tags `pkg_type` but writes no columns) or when
//! only a generic `ZnippyReader` is configured.
//!
//! Gotcha: the simple index reflects attacker-influenced package/file names into HTML,
//! so every interpolated value goes through `html_escape` (reflected-injection, M10).

use std::collections::{BTreeSet, HashMap};
use std::fmt;
use std::fmt::Write as _;
use std::path::PathBuf;
use std::sync::{Arc, Mutex, OnceLock};

use anyhow::Result;

use traits::znippy_base::{not_found, ok};
use traits::{ArtifactEntry, ArtifactFormat, ArtifactId, RepositoryBackendTrait, ZnippyBase};
use znippy_common::ZnippyReader;

/// JSON-string-escape `s` for safe interpolation into a hand-built PEP 691 body
/// (the index reflects attacker-influenced package/file names). Escapes the two
/// structural characters plus control chars; package/file names never legitimately
/// contain quotes or backslashes, so a hostile one is neutralised, not reflected.
fn json_escape(s: &str) -> String {
    let mut out = String::with_capacity(s.len() + 2);
    for c in s.chars() {
        match c {
            '"' => out.push_str("\\\""),
            '\\' => out.push_str("\\\\"),
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            '\t' => out.push_str("\\t"),
            c if (c as u32) < 0x20 => {
                let _ = write!(out, "\\u{:04x}", c as u32);
            }
            c => out.push(c),
        }
    }
    out
}

/// Escape a string for safe interpolation into HTML text/attribute contexts.
///
/// The PEP 503 simple index reflects attacker-influenced values (the requested
/// package `name` from the URL, and package/file names from archive contents)
/// into HTML. Without escaping these are a reflected HTML/script-injection
/// vector (M10). Escapes the five significant characters; sufficient for both
/// element text and double-quoted attribute values.
fn html_escape(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for c in s.chars() {
        match c {
            '&' => out.push_str("&amp;"),
            '<' => out.push_str("&lt;"),
            '>' => out.push_str("&gt;"),
            '"' => out.push_str("&quot;"),
            '\'' => out.push_str("&#x27;"),
            _ => out.push(c),
        }
    }
    out
}

/// znippy-backed Python package index implementing PEP 503.
///
/// Serves packages from any `ZnippyReader` backend.
/// Files stored as: `packages/{normalized-name}/{filename}`.
pub struct PipRepoZnippy {
    /// The typed archive (`base.typed()`) drives the python view
    /// ([`znippy_common::ZnippyArchive::as_python`]): `(name, version)` come from
    /// the index columns the python plugin extracted, not from filename guesses.
    base: ZnippyBase,
    /// Memoised SHA-256 (lower-hex) of each served distribution, keyed by its
    /// archive path. The simple-index detail page (PEP 503 `#sha256=` fragment /
    /// PEP 691 `hashes.sha256`) needs the digest pip/uv verify against, and znippy
    /// stores only blake3 — so it is computed from the file bytes on first touch
    /// and reused. Never on the artifact-download hot path (`/packages/...`), which
    /// is byte-identical to before.
    sha_cache: Mutex<HashMap<String, String>>,
    /// The archive's file table, taken ONCE.
    ///
    /// `ZnippyArchive::list_files()` is `file_index.keys().cloned().collect()`
    /// — one String allocation per file in the archive, every call. The PEP 503
    /// simple index scans the whole table on EVERY request (`/simple/`, and
    /// `/simple/{name}/` through `dist_files`), so a 50 000-file archive
    /// allocated 50 000 Strings to answer one index request. This backend is
    /// READ-ONLY (`put` errors, `is_writable` is false) and holds its reader
    /// `Arc` from boot for the process lifetime, so the table cannot change
    /// underneath it: taking it once adds no staleness the held handle did not
    /// already have.
    paths: OnceLock<Vec<String>>,
    /// The simple pages (root, each project's HTML and PEP 691 JSON), built once (lane
    /// HOL-PERF-2): the archive is immutable, so the same bytes after.
    pages: traits::DocMemo<(&'static str, traits::Bytes)>,
}

// Manual Debug impl because dyn ZnippyReader does not implement Debug.
impl fmt::Debug for PipRepoZnippy {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("PipRepoZnippy")
            .field("name", &self.base.name())
            .field("archive", &self.base.has_archive())
            .field(
                "sha_cached",
                &self.sha_cache.lock().map(|c| c.len()).unwrap_or(0),
            )
            .finish()
    }
}

/// Normalize a package name per PEP 503: lowercase and replace `_` / `.` with `-`.
/// Parse a python distribution filename into `(name, version)`.
///
/// - Wheel `{dist}-{version}(-{build})?-{py}-{abi}-{plat}.whl`: the first two
///   `-`-separated tokens are the distribution and version (PEP 427).
/// - sdist `{name}-{version}.tar.gz` / `.zip`: split at the last `-`.
///
/// Returns `None` for anything else (e.g. a stray `.txt`).
fn parse_dist_filename(filename: &str) -> Option<(String, String)> {
    if let Some(stem) = filename.strip_suffix(".whl") {
        let parts: Vec<&str> = stem.split('-').collect();
        if parts.len() >= 2 && !parts[0].is_empty() && !parts[1].is_empty() {
            return Some((parts[0].to_string(), parts[1].to_string()));
        }
        return None;
    }
    let stem = filename
        .strip_suffix(".tar.gz")
        .or_else(|| filename.strip_suffix(".tgz"))
        .or_else(|| filename.strip_suffix(".zip"))?;
    let (name, version) = stem.rsplit_once('-')?;
    if name.is_empty() || version.is_empty() {
        return None;
    }
    Some((name.to_string(), version.to_string()))
}

/// The `Content-Type` a distribution download is served as.
fn dist_content_type(filename: &str) -> &'static str {
    if filename.ends_with(".whl") || filename.ends_with(".zip") {
        "application/zip"
    } else if filename.ends_with(".tar.gz") {
        "application/gzip"
    } else {
        "application/octet-stream"
    }
}

fn normalize_name(s: &str) -> String {
    s.to_lowercase().replace(['_', '.'], "-")
}

impl PipRepoZnippy {
    fn from_base(base: ZnippyBase) -> Self {
        Self {
            base,
            paths: OnceLock::new(),
            sha_cache: Mutex::new(HashMap::new()),
            pages: traits::DocMemo::default(),
        }
    }

    pub fn new(name: String) -> Self {
        Self::from_base(ZnippyBase::new(name))
    }

    /// Create a repo backed by a specific znippy archive file
    pub fn with_archive(name: String, archive_path: PathBuf) -> Result<Self> {
        Ok(Self::from_base(ZnippyBase::with_archive(
            name,
            archive_path,
        )?))
    }

    /// Create a repo backed by any ZnippyReader implementation
    pub fn with_reader(name: String, reader: Arc<dyn ZnippyReader>) -> Self {
        Self::from_base(ZnippyBase::with_reader(name, reader))
    }

    /// SHA-256 (lower-hex) of the distribution at archive `path`, memoised. Returns
    /// `None` when the file cannot be read (so a link is emitted without a hash
    /// fragment rather than the request failing). This is the digest pip/uv verify
    /// the download against; see [`traits::sha256_hex`].
    fn sha256_hex_of(&self, path: &str) -> Option<String> {
        if let Ok(cache) = self.sha_cache.lock() {
            if let Some(h) = cache.get(path) {
                return Some(h.clone());
            }
        }
        // hashed a piece at a time: a wheel is never whole in memory for its digest
        let hex = self.base.sha256_hex_of(path).ok()?;
        if let Ok(mut cache) = self.sha_cache.lock() {
            cache.insert(path.to_string(), hex.clone());
        }
        Some(hex)
    }

    /// The archive's file table, filled on first use. See the `paths` field.
    fn paths(&self) -> &[String] {
        self.paths.get_or_init(|| self.base.list_files())
    }

    pub fn list_files(&self) -> Vec<String> {
        self.paths().to_vec()
    }

    pub fn get_file(&self, relative_path: &str) -> Result<Vec<u8>> {
        self.base.get_file(relative_path)
    }

    /// The distribution files for one (already-requested) package `name`, as
    /// `(filename, href, sha256_hex)` triples. `href` is the download URL served
    /// by this repo (`/{repo}/packages/{normalized}/{filename}`); `sha256_hex` is
    /// the memoised SHA-256 pip/uv verify against (`None` if unreadable). Shared by
    /// the PEP 503 HTML detail page and the PEP 691 JSON representation so the two
    /// never drift.
    fn dist_files(&self, name: &str) -> Vec<(String, String, Option<String>)> {
        let normalized = normalize_name(name);
        let prefix = format!("packages/{}/", normalized);
        let mut out = Vec::new();
        for f in self.paths() {
            let Some(filename) = f.strip_prefix(&prefix) else {
                continue;
            };
            // Skip nested directories — only direct distribution files.
            if filename.is_empty() || filename.contains('/') {
                continue;
            }
            let href = format!("/{}/{}", self.base.name(), f);
            let sha = self.sha256_hex_of(f);
            out.push((filename.to_string(), href, sha));
        }
        out
    }
}

impl RepositoryBackendTrait for PipRepoZnippy {
    traits::znippy_read_only!(base);

    /// A distribution download (`/packages/{name}/{file}`) in pieces (lane HOL-RSS): the
    /// archive file the whole-body door reads, so the door holds a piece, not the wheel.
    fn stream_get(
        &self,
        suburl: &str,
        piece_bytes: usize,
    ) -> anyhow::Result<Option<traits::StreamedGet>> {
        let remainder = self.base.strip_repo(suburl);
        let parts: Vec<&str> = remainder.split('/').filter(|s| !s.is_empty()).collect();
        let ["packages", name, filename] = parts.as_slice() else {
            return Ok(None);
        };
        let archive_path = format!("packages/{}/{}", normalize_name(name), filename);
        self.base
            .stream_get(&archive_path, dist_content_type(filename), piece_bytes)
    }

    /// The simple pages, built once and handed out as the same bytes (lane HOL-PERF-2);
    /// a project with no distribution is answered as before and not kept.
    fn handle_http2_request_bytes(
        &self,
        method: &str,
        suburl: &str,
        body: &[u8],
    ) -> anyhow::Result<traits::HttpBytesReply> {
        if method == "HEAD" {
            return self
                .handle_http2_request_bytes("GET", suburl, &[])
                .map(traits::backend::head_of_bytes);
        }
        let remainder = self.base.strip_repo(suburl);
        let parts: Vec<&str> = remainder.split('/').filter(|s| !s.is_empty()).collect();
        let key = match parts.as_slice() {
            ["simple"] => Some("root".to_string()),
            ["simple", name] => Some(format!("html/{name}")),
            ["simple", name, "json"] => Some(format!("json/{name}")),
            _ => None,
        };
        if let Some(key) = key {
            let page = self.pages.get_or(&key, || {
                if let Some(name) = parts.get(1) {
                    if self.dist_files(name).is_empty() {
                        return None;
                    }
                }
                let (status, headers, b) = self.handle_http2_request("GET", suburl, body).ok()?;
                let json = headers.iter().any(|(_, v)| v.contains("json"));
                (status == 200).then(|| {
                    (
                        if json {
                            "application/vnd.pypi.simple.v1+json"
                        } else {
                            "text/html"
                        },
                        traits::Bytes::from(b),
                    )
                })
            });
            if let Some((ctype, b)) = page {
                return Ok(traits::znippy_base::ok_bytes(ctype, b));
            }
        }
        self.handle_http2_request(method, suburl, body)
            .map(|(s, h, b)| (s, h, traits::Bytes::from(b)))
    }

    fn handle_http2_request(
        &self,
        method: &str,
        suburl: &str,
        body: &[u8],
    ) -> anyhow::Result<(u16, Vec<(String, String)>, Vec<u8>)> {
        if method == "HEAD" {
            // HOL-HEAD-1: the GET answer's status and headers, its length, no body.
            return self
                .handle_http2_request("GET", suburl, &[])
                .map(traits::backend::head_of);
        }
        let _ = body;
        log::debug!("Pip repo znippy handle_http2_request.suburl={}", suburl);

        // Strip leading repo name prefix: /{repo}/simple/... or /{repo}/packages/...
        let remainder = self.base.strip_repo(suburl);

        // Split on '/' and drop empty segments (handles trailing slashes).
        let parts: Vec<&str> = remainder.split('/').filter(|s| !s.is_empty()).collect();

        match parts.as_slice() {
            // GET /{repo}/simple/  →  list all unique package names
            ["simple"] => {
                let files = self.paths();
                let mut names: BTreeSet<String> = BTreeSet::new();
                for f in files {
                    if let Some(rest) = f.strip_prefix("packages/") {
                        if let Some(slash_pos) = rest.find('/') {
                            let pkg_name = &rest[..slash_pos];
                            if !pkg_name.is_empty() {
                                names.insert(pkg_name.to_string());
                            }
                        }
                    }
                }

                let mut links = String::new();
                for n in &names {
                    let e = html_escape(n);
                    links.push_str(&format!("<a href=\"{}/\">{}</a><br>\n", e, e));
                }
                let html = format!(
                    "<!DOCTYPE html>\n<html><head><title>Simple Index</title></head>\n<body>\n{}</body></html>",
                    links
                );
                Ok(ok("text/html", html.into_bytes()))
            }

            // GET /{repo}/simple/{name}/json  →  PEP 691 JSON project detail. Same
            // data as the HTML detail page, machine-readable. (uv/pip negotiate
            // this via an `Accept:` header, which the backend trait does not
            // surface today; this explicit `.../json` route makes the PEP 691
            // representation reachable + testable meanwhile — see design doc.)
            ["simple", name, "json"] => {
                let dists = self.dist_files(name);
                let mut files_json = String::new();
                for (i, (filename, href, sha)) in dists.iter().enumerate() {
                    if i > 0 {
                        files_json.push(',');
                    }
                    let hashes = match sha {
                        Some(h) => format!("{{\"sha256\":\"{}\"}}", json_escape(h)),
                        None => "{}".to_string(),
                    };
                    let _ = write!(
                        files_json,
                        "{{\"filename\":\"{}\",\"url\":\"{}\",\"hashes\":{}}}",
                        json_escape(filename),
                        json_escape(href),
                        hashes,
                    );
                }
                let body = format!(
                    "{{\"meta\":{{\"api-version\":\"1.0\"}},\"name\":\"{}\",\"files\":[{}]}}",
                    json_escape(&normalize_name(name)),
                    files_json,
                );
                Ok(ok("application/vnd.pypi.simple.v1+json", body.into_bytes()))
            }

            // GET /{repo}/simple/{name}/  →  list all files for the package. Each
            // link carries a `#sha256=<hex>` fragment (PEP 503) so pip/uv can
            // verify the download — the digest of the served bytes (memoised).
            ["simple", name] => {
                let mut links = String::new();
                for (filename, href, sha) in self.dist_files(name) {
                    let href_frag = match &sha {
                        Some(h) => format!("{}#sha256={}", href, h),
                        None => href,
                    };
                    links.push_str(&format!(
                        "<a href=\"{}\">{}</a><br>\n",
                        html_escape(&href_frag),
                        html_escape(&filename)
                    ));
                }

                let name_esc = html_escape(name);
                let html = format!(
                    "<!DOCTYPE html>\n<html><head><title>Links for {name}</title></head>\n<body><h1>Links for {name}</h1>\n{links}</body></html>",
                    name = name_esc,
                    links = links,
                );
                Ok(ok("text/html", html.into_bytes()))
            }

            // GET /{repo}/packages/{name}/{filename}  →  serve file from archive
            ["packages", name, filename] => {
                let normalized = normalize_name(name);
                let archive_path = format!("packages/{}/{}", normalized, filename);
                match self.get_file(&archive_path) {
                    Ok(data) => Ok(ok(dist_content_type(filename), data)),
                    Err(_) => Ok(not_found("Not found in archive")),
                }
            }

            _ => Ok(not_found("Not found")),
        }
    }

    fn format(&self) -> ArtifactFormat {
        ArtifactFormat::Pip
    }

    /// Browse: one entry per distribution file in the archive. Prefers the typed
    /// python view (authoritative `name`/`version` columns the python plugin wrote);
    /// when that view is empty — e.g. an archive packed by the agent's
    /// directory→znippy builder, which tags `pkg_type` but writes no columns — it
    /// falls back to parsing the wheel/sdist *filename*, so wheels still Browse.
    fn list(&self, name_filter: Option<&str>, limit: usize) -> anyhow::Result<Vec<ArtifactEntry>> {
        let mut out = Vec::new();
        // ONE index read for the whole listing; no artifact byte is touched.
        let tokens = self.base.tokens().open();

        // Authoritative path: typed view coords.
        if let Some(archive) = self.base.typed() {
            if let Some(python) = archive.as_python() {
                for (name, version) in python.list() {
                    if name_filter.is_some_and(|f| !name.contains(f)) {
                        continue;
                    }
                    // The package handle names its own archive row
                    // (`relative_path`), which is the key the lookup sub-index
                    // answers a change token for. Size and token come off the
                    // same handle; neither reads an artifact byte.
                    let (size_bytes, change_token) = python
                        .get(&name, &version)
                        .map(|p| {
                            let token = tokens.as_ref().and_then(|t| t.token(p.relative_path()));
                            (p.size() as i64, token)
                        })
                        .unwrap_or((0, None));
                    out.push(
                        ArtifactEntry {
                            id: ArtifactId {
                                namespace: None,
                                name,
                                version,
                            },
                            size_bytes,
                            content_type: "application/octet-stream".into(),
                            // A znippy file table publishes no mtime and no per-entry digest;
                            // answering either would mean extracting and hashing every
                            // artifact on the listing path. Unknown, never a fabricated zero.
                            // The CHANGE TOKEN below is what the index CAN answer.
                            ..ArtifactEntry::UNKNOWN_PROVENANCE
                        }
                        .with_change_token(change_token),
                    );
                    if limit != 0 && out.len() >= limit {
                        break;
                    }
                }
                if !out.is_empty() {
                    return Ok(out);
                }
            }
        }

        // Fallback: parse distribution filenames from the raw file list.
        for path in self.paths() {
            let filename = path.rsplit('/').next().unwrap_or(path);
            let Some((name, version)) = parse_dist_filename(filename) else {
                continue;
            };
            if name_filter.is_some_and(|f| !name.contains(f)) {
                continue;
            }
            let size_bytes = self
                .base
                .reader()
                .and_then(|r| r.file_size(path))
                .unwrap_or(0) as i64;
            out.push(
                ArtifactEntry {
                    id: ArtifactId {
                        namespace: None,
                        name,
                        version,
                    },
                    size_bytes,
                    content_type: "application/octet-stream".into(),
                    // A znippy file table publishes no mtime and no per-entry digest;
                    // answering either would mean extracting and hashing every
                    // artifact on the listing path. Unknown, never a fabricated zero.
                    ..ArtifactEntry::UNKNOWN_PROVENANCE
                }
                .with_change_token(tokens.as_ref().and_then(|t| t.token(path))),
            );
            if limit != 0 && out.len() >= limit {
                break;
            }
        }
        Ok(out)
    }

    fn fetch(&self, id: &ArtifactId) -> anyhow::Result<Option<traits::Bytes>> {
        // Static archive: resolve through the typed python view — `(name, version)`
        // coords are read from the index columns the python plugin extracted, so a
        // wheel or sdist resolves by its authoritative coords (kind included)
        // rather than guessing the `.tar.gz` filename or substring-matching.
        if let Some(archive) = self.base.typed() {
            if let Some(python) = archive.as_python() {
                return match python.get(&id.name, &id.version) {
                    Some(pkg) => Ok(Some(traits::Bytes::from(pkg.into_bytes()?))),
                    None => Ok(None),
                };
            }
        }

        // Fallback (generic reader): canonical filename then substring match.
        // id.namespace = None, id.name = "requests", id.version = "2.31.0"
        // Try: packages/requests/requests-2.31.0.tar.gz
        let canonical = format!("packages/{}/{}-{}.tar.gz", id.name, id.name, id.version);
        if let Ok(data) = self.get_file(&canonical) {
            return Ok(Some(traits::Bytes::from(data)));
        }

        // Fallback: list all files under packages/{name}/ and return the first
        // whose filename contains the requested version string.
        let prefix = format!("packages/{}/", normalize_name(&id.name));
        for f in self.paths() {
            if f.starts_with(&prefix) {
                if let Some(filename) = f.strip_prefix(&prefix) {
                    if filename.contains(id.version.as_str()) {
                        if let Ok(data) = self.get_file(f) {
                            return Ok(Some(traits::Bytes::from(data)));
                        }
                    }
                }
            }
        }

        Ok(None)
    }
}

#[cfg(test)]
mod tests {

    /// One file, nothing else — the smallest loaded reader.
    struct OneFile;
    impl znippy_common::ZnippyReader for OneFile {
        fn list_files(&self) -> anyhow::Result<Vec<String>> {
            Ok(vec!["a/b".into()])
        }
        fn extract_file(&self, _: &str) -> anyhow::Result<Vec<u8>> {
            Ok(b"x".to_vec())
        }
        fn contains(&self, p: &str) -> bool {
            p == "a/b"
        }
        fn file_size(&self, _: &str) -> Option<u64> {
            Some(1)
        }
    }

    /// RED drift bug: a backend with its archive loaded must SAY it has one —
    /// metering counts archive bytes only for backends that do.
    #[test]
    fn a_loaded_reader_is_an_archive() {
        let repo = PipRepoZnippy::with_reader("r".to_string(), std::sync::Arc::new(OneFile));
        assert!(
            repo.caps().archive,
            "a backend with its reader loaded must say it has an archive"
        );
        assert_eq!(repo.archive().unwrap().info().file_count, 1);
    }

    #[test]
    fn caps_and_methods_agree() {
        traits::backend::conformance::check(&PipRepoZnippy::new("e".into())).unwrap();
        traits::backend::conformance::check(&PipRepoZnippy::with_reader(
            "l".into(),
            std::sync::Arc::new(OneFile),
        ))
        .unwrap();
    }

    use super::*;
    use traits::sha256_hex;
    use traits::test_support::{CountingReader, MemReader};

    #[cfg(feature = "testmatrix")]
    use traits::test_support::fstatus;

    #[test]
    fn test_html_escape_blocks_injection() {
        // A package/file name carrying an HTML/script payload must come back
        // fully escaped so the PEP 503 simple index cannot reflect markup (M10).
        let hostile = r#"<script>alert("x")</script>&'"#;
        let escaped = html_escape(hostile);
        assert!(!escaped.contains('<'));
        assert!(!escaped.contains('>'));
        assert!(!escaped.contains('"'));
        assert!(!escaped.contains('\''));
        assert_eq!(
            escaped,
            "&lt;script&gt;alert(&quot;x&quot;)&lt;/script&gt;&amp;&#x27;"
        );
        // Benign names pass through unchanged.
        assert_eq!(
            html_escape("requests-2.31.0.tar.gz"),
            "requests-2.31.0.tar.gz"
        );
    }

    #[test]
    fn parse_dist_filename_wheels_and_sdists() {
        // wheel: first two `-` tokens are dist + version (build/py/abi/plat ignored)
        assert_eq!(
            parse_dist_filename("requests-2.31.0-py3-none-any.whl"),
            Some(("requests".into(), "2.31.0".into()))
        );
        assert_eq!(
            parse_dist_filename("python_dateutil-2.9.0.post0-py2.py3-none-any.whl"),
            Some(("python_dateutil".into(), "2.9.0.post0".into()))
        );
        // sdist: split at the last `-`
        assert_eq!(
            parse_dist_filename("requests-2.31.0.tar.gz"),
            Some(("requests".into(), "2.31.0".into()))
        );
        assert_eq!(
            parse_dist_filename("Flask-3.0.0.zip"),
            Some(("Flask".into(), "3.0.0".into()))
        );
        // non-distribution files are ignored
        assert_eq!(parse_dist_filename("index.html"), None);
        assert_eq!(parse_dist_filename("noversion.whl"), None);

        #[cfg(feature = "testmatrix")]
        fstatus(
            "znippy-python",
            "parse_dist_filename",
            parse_dist_filename("requests-2.31.0-py3-none-any.whl").is_some(),
            "wheel + sdist filenames parse to (name, version)",
        );
    }

    #[test]
    fn test_new() {
        let repo = PipRepoZnippy::new("pip-test".to_string());
        assert_eq!(repo.name(), "pip-test");
        assert!(repo.list_files().is_empty());

        #[cfg(feature = "testmatrix")]
        fstatus(
            "znippy-python",
            "new_repo_named_and_empty",
            repo.name() == "pip-test" && repo.list_files().is_empty(),
            &format!("name={} files={}", repo.name(), repo.list_files().len()),
        );
    }

    #[test]
    fn test_readonly() {
        let repo = PipRepoZnippy::new("pip-test".to_string());
        assert!(!repo.is_writable());
        let id = ArtifactId {
            namespace: None,
            name: "requests".to_string(),
            version: "2.31.0".to_string(),
        };
        let put = repo.put(&id, b"data");
        assert!(put.is_err());

        #[cfg(feature = "testmatrix")]
        fstatus(
            "znippy-python",
            "is_readonly",
            !repo.is_writable() && put.is_err(),
            &format!("writable={} put_err={}", repo.is_writable(), put.is_err()),
        );
    }

    #[test]
    fn test_format() {
        let repo = PipRepoZnippy::new("pip-test".to_string());
        assert_eq!(repo.format(), ArtifactFormat::Pip);

        #[cfg(feature = "testmatrix")]
        fstatus(
            "znippy-python",
            "format_is_pip",
            repo.format() == ArtifactFormat::Pip,
            &format!("format = {:?}", repo.format()),
        );
    }

    #[test]
    fn test_sha256_hex_known_vector() {
        // NIST/RFC-well-known: SHA-256("abc").
        assert_eq!(
            sha256_hex(b"abc"),
            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
        );
        // SHA-256("") — empty input.
        assert_eq!(
            sha256_hex(b""),
            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
        );
        // Digest is always 64 lower-hex chars.
        let h = sha256_hex(b"holger");
        assert_eq!(h.len(), 64);
        assert!(h
            .chars()
            .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()));

        #[cfg(feature = "testmatrix")]
        fstatus(
            "znippy-python",
            "sha256_hex_content_address",
            sha256_hex(b"abc")
                == "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
            "SHA-256(\"abc\") matches the known vector — the digest pip/uv verify against",
        );
    }

    #[test]
    fn test_json_escape_blocks_injection() {
        // A hostile filename cannot break out of a JSON string (PEP 691 body).
        assert_eq!(json_escape(r#"a"b\c"#), r#"a\"b\\c"#);
        assert_eq!(json_escape("line\nbreak"), "line\\nbreak");
        // A control char is \u-escaped, never emitted raw.
        assert!(json_escape("\u{0007}").starts_with("\\u0007"));
        // Benign package names pass through.
        assert_eq!(json_escape("apache-airflow"), "apache-airflow");

        #[cfg(feature = "testmatrix")]
        fstatus(
            "znippy-python",
            "json_escape_pep691",
            json_escape(r#"a"b\c"#) == r#"a\"b\\c"#,
            "quote + backslash escaped so a hostile filename can't break the PEP 691 JSON",
        );
    }

    #[test]
    fn test_normalize_name() {
        assert_eq!(normalize_name("Requests"), "requests");
        assert_eq!(normalize_name("my_package"), "my-package");
        assert_eq!(normalize_name("my.package"), "my-package");
        assert_eq!(normalize_name("My_Package.Name"), "my-package-name");

        #[cfg(feature = "testmatrix")]
        fstatus(
            "znippy-python",
            "normalize_name_pep503",
            normalize_name("Requests") == "requests"
                && normalize_name("My_Package.Name") == "my-package-name",
            &format!("My_Package.Name -> {}", normalize_name("My_Package.Name")),
        );
    }

    /// The archive file table is taken ONCE, not once per request.
    ///
    /// Measured before the `paths` OnceLock landed: ten `/simple/` requests =
    /// 10 full table reads. After: 1. This is the arm the bench harness drives
    /// (PEP 503 index-resolve), so on a 50 000-file archive it is 500 000
    /// String allocations per ten requests turned into 50 000, once.
    #[test]
    fn the_archive_file_table_is_read_once_not_per_request() {
        let reader = CountingReader::new(MemReader::new([
            (
                "packages/widget/widget-1.0.0-py3-none-any.whl",
                b"wheel".to_vec(),
            ),
            ("packages/widget/widget-1.0.0.tar.gz", b"sdist".to_vec()),
            (
                "packages/gadget/gadget-2.0.0-py3-none-any.whl",
                b"wheel2".to_vec(),
            ),
        ]));
        let repo = PipRepoZnippy::with_reader("pypi-test".to_string(), reader.clone());

        assert_eq!(
            reader.table_reads(),
            0,
            "nothing is read before the first request"
        );
        for _ in 0..10 {
            let (status, _, body) = repo
                .handle_http2_request("GET", "/pypi-test/simple/", b"")
                .expect("simple index ok");
            assert_eq!(status, 200);
            let html = String::from_utf8_lossy(&body);
            assert!(html.contains("widget") && html.contains("gadget"));
        }
        assert_eq!(
            reader.table_reads(),
            1,
            "ten /simple/ requests must cost ONE table read, not ten"
        );

        #[cfg(feature = "testmatrix")]
        fstatus(
            "znippy-python",
            "archive_table_read_once_not_per_request",
            reader.table_reads() == 1,
            &format!(
                "10 requests -> {} table reads (was 10)",
                reader.table_reads()
            ),
        );
    }

    /// **The simple pages are built once** (lane HOL-PERF-2, step 6): the root page and a
    /// project's page (HTML and JSON) are the same bytes on a second request.
    #[test]
    fn the_simple_pages_are_built_once() {
        let repo = PipRepoZnippy::with_reader(
            "p".into(),
            traits::test_support::MemReader::new([(
                "packages/demo/demo-1.0-py3-none-any.whl",
                b"PK wheel".to_vec(),
            )]),
        );
        for url in ["/p/simple/", "/p/simple/demo/", "/p/simple/demo/json"] {
            let a = repo.handle_http2_request_bytes("GET", url, &[]).unwrap();
            let b = repo.handle_http2_request_bytes("GET", url, &[]).unwrap();
            assert_eq!(a.0, 200, "{url}");
            assert_eq!(a.2.as_ptr(), b.2.as_ptr(), "{url}: the same bytes");
        }
    }
}