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("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => 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,
"<script>alert("x")</script>&'"
);
// 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");
}
}
}