use std::collections::{BTreeSet, HashMap};
use std::fmt;
use std::fmt::Write as _;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use anyhow::{anyhow, Result};
use traits::{ArtifactEntry, ArtifactFormat, ArtifactId, RepositoryBackendTrait};
use znippy_common::{ZnippyArchive, ZnippyReader};
fn sha256_hex(bytes: &[u8]) -> String {
nornir_hash::sha256_hex(bytes)
}
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
}
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
}
pub struct PipRepoZnippy {
pub name: String,
reader: Option<Arc<dyn ZnippyReader>>,
archive: Option<Arc<ZnippyArchive>>,
sha_cache: Mutex<HashMap<String, String>>,
}
impl fmt::Debug for PipRepoZnippy {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("PipRepoZnippy")
.field("name", &self.name)
.field("reader", &self.reader.as_ref().map(|_| "<dyn ZnippyReader>"))
.field("archive", &self.archive.as_ref().map(|_| "<ZnippyArchive>"))
.field("sha_cached", &self.sha_cache.lock().map(|c| c.len()).unwrap_or(0))
.finish()
}
}
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()))
}
fn normalize_name(s: &str) -> String {
s.to_lowercase().replace(['_', '.'], "-")
}
impl PipRepoZnippy {
pub fn new(name: String) -> Self {
Self { name, reader: None, archive: None, sha_cache: Mutex::new(HashMap::new()) }
}
pub fn with_archive(name: String, archive_path: PathBuf) -> Result<Self> {
let archive = Arc::new(ZnippyArchive::open(&archive_path)?);
Ok(Self {
name,
reader: Some(Arc::clone(&archive) as Arc<dyn ZnippyReader>),
archive: Some(archive),
sha_cache: Mutex::new(HashMap::new()),
})
}
pub fn with_reader(name: String, reader: Arc<dyn ZnippyReader>) -> Self {
Self {
name,
reader: Some(reader),
archive: None,
sha_cache: Mutex::new(HashMap::new()),
}
}
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());
}
}
let bytes = self.get_file(path).ok()?;
let hex = sha256_hex(&bytes);
if let Ok(mut cache) = self.sha_cache.lock() {
cache.insert(path.to_string(), hex.clone());
}
Some(hex)
}
pub fn list_files(&self) -> Vec<String> {
match &self.reader {
Some(r) => r.list_files().unwrap_or_default(),
None => vec![],
}
}
pub fn get_file(&self, relative_path: &str) -> Result<Vec<u8>> {
let reader = self.reader.as_ref()
.ok_or_else(|| anyhow!("No reader configured"))?;
reader.extract_file(relative_path)
}
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.list_files() {
let Some(filename) = f.strip_prefix(&prefix) else { continue };
if filename.is_empty() || filename.contains('/') {
continue;
}
let href = format!("/{}/{}", self.name, f);
let sha = self.sha256_hex_of(&f);
out.push((filename.to_string(), href, sha));
}
out
}
}
impl RepositoryBackendTrait for PipRepoZnippy {
fn name(&self) -> &str {
&self.name
}
fn handle_http2_request(
&self,
method: &str,
suburl: &str,
body: &[u8],
) -> anyhow::Result<(u16, Vec<(String, String)>, Vec<u8>)> {
let _ = (method, body);
log::debug!("Pip repo znippy handle_http2_request.suburl={}", suburl);
let path = suburl.trim_start_matches('/');
let remainder = match path.strip_prefix(&self.name) {
Some(r) => r.trim_start_matches('/'),
None => path,
};
let parts: Vec<&str> = remainder.split('/').filter(|s| !s.is_empty()).collect();
match parts.as_slice() {
["simple"] => {
let files = self.list_files();
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((
200,
vec![("Content-Type".into(), "text/html".into())],
html.into_bytes(),
))
}
["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((
200,
vec![(
"Content-Type".into(),
"application/vnd.pypi.simple.v1+json".into(),
)],
body.into_bytes(),
))
}
["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((
200,
vec![("Content-Type".into(), "text/html".into())],
html.into_bytes(),
))
}
["packages", name, filename] => {
let normalized = normalize_name(name);
let archive_path = format!("packages/{}/{}", normalized, filename);
match self.get_file(&archive_path) {
Ok(data) => {
let content_type = if filename.ends_with(".whl") {
"application/zip"
} else if filename.ends_with(".tar.gz") {
"application/gzip"
} else if filename.ends_with(".zip") {
"application/zip"
} else {
"application/octet-stream"
};
Ok((
200,
vec![("Content-Type".into(), content_type.into())],
data,
))
}
Err(_) => Ok((404, Vec::new(), b"Not found in archive".to_vec())),
}
}
_ => Ok((404, Vec::new(), b"Not found".to_vec())),
}
}
fn format(&self) -> ArtifactFormat {
ArtifactFormat::Pip
}
fn is_writable(&self) -> bool {
false
}
fn list(&self, name_filter: Option<&str>, limit: usize) -> anyhow::Result<Vec<ArtifactEntry>> {
let mut out = Vec::new();
if let Some(archive) = &self.archive {
if let Some(python) = archive.as_python() {
for (name, version) in python.list() {
if name_filter.is_some_and(|f| !name.contains(f)) {
continue;
}
out.push(ArtifactEntry {
id: ArtifactId { namespace: None, name, version },
size_bytes: 0,
content_type: "application/octet-stream".into(),
});
if limit != 0 && out.len() >= limit {
break;
}
}
if !out.is_empty() {
return Ok(out);
}
}
}
for path in self.list_files() {
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
.reader
.as_ref()
.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(),
});
if limit != 0 && out.len() >= limit {
break;
}
}
Ok(out)
}
fn archive_files(&self, prefix: Option<&str>) -> anyhow::Result<Vec<String>> {
Ok(self
.list_files()
.into_iter()
.filter(|p| prefix.is_none_or(|pre| p.starts_with(pre)))
.collect())
}
fn fetch(&self, id: &ArtifactId) -> anyhow::Result<Option<Vec<u8>>> {
if let Some(archive) = &self.archive {
if let Some(python) = archive.as_python() {
return match python.get(&id.name, &id.version) {
Some(pkg) => Ok(Some(pkg.into_bytes()?)),
None => Ok(None),
};
}
}
let canonical = format!("packages/{}/{}-{}.tar.gz", id.name, id.name, id.version);
if let Ok(data) = self.get_file(&canonical) {
return Ok(Some(data));
}
let prefix = format!("packages/{}/", normalize_name(&id.name));
for f in self.list_files() {
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(data));
}
}
}
}
}
Ok(None)
}
fn put(&self, _id: &ArtifactId, _data: &[u8]) -> anyhow::Result<()> {
Err(anyhow!("Pip znippy repository is read-only"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "testmatrix")]
fn fstatus(component: &str, check: &str, ok: bool, detail: &str) {
nornir_testmatrix::functional_status(component, check, ok, detail);
}
#[test]
fn test_html_escape_blocks_injection() {
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>&'"
);
assert_eq!(html_escape("requests-2.31.0.tar.gz"), "requests-2.31.0.tar.gz");
}
#[test]
fn parse_dist_filename_wheels_and_sdists() {
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()))
);
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()))
);
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() {
assert_eq!(
sha256_hex(b"abc"),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
assert_eq!(
sha256_hex(b""),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
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() {
assert_eq!(json_escape(r#"a"b\c"#), r#"a\"b\\c"#);
assert_eq!(json_escape("line\nbreak"), "line\\nbreak");
assert!(json_escape("\u{0007}").starts_with("\\u0007"));
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")),
);
}
}