server/repository/znippy-deb/src/lib.rs
//! Debian (APT) repository backend over a read-only znippy archive — the deb
//! sibling of znippy-rpm/conda/go, implementing `RepositoryBackendTrait`.
//!
//! APT fetches the index (`dists/{dist}/Release` + per-component/arch
//! `Packages`) then the `pool/*.deb` bodies. Two tiers answer a request, in
//! order: authoritative path-passthrough (`get_file`) serves package bytes and
//! any mirrored upstream metadata packed into the archive verbatim; when that
//! misses, `synthesize_index` builds the native `Packages`/`Release` from the
//! pool `.deb`s — each stanza's `Filename`/`Size`/`SHA256` computed over the
//! real served bytes so an `[trusted=yes]` apt client can verify. Control fields
//! come from the typed deb view (`as_deb`) when present; otherwise holger parses
//! them NATIVELY out of the served `.deb` bytes — an `ar` + `control.tar.*` read
//! of the real Debian `control` stanza (`control` module) — and only when THAT
//! misses falls back to the `{name}_{ver}_{arch}.deb` filename (a 3-way `_` split,
//! exact since name and version never contain `_`).
//!
//! Gotcha: synthesized indexes are cached because the archive is immutable —
//! without it every `Release` request re-hashes the whole pool, a DoS-
//! amplification vector (same guard the conda plugin documents for
//! `repodata.json`). The cache is keyed on the **derived, bounded identity of the
//! work**, never on the request path: one `Packages` per `(component, arch)` the
//! pool actually contains, and one dist-independent `Release` skeleton. Keying on
//! the path remainder was itself the vector — `dists/<anything>/Release` missed
//! the cache, so an attacker re-ran the whole decompress+SHA-256+control-parse
//! pool walk by varying one path segment. Index coordinates outside
//! [`DebRepoZnippy::pool_dimensions`] 404 before any work. An empty `Packages`
//! synthesizes to `None` → 404, never an empty index.
mod control;
/// The parser of untrusted package bytes, public for holger's fuzz targets (`fuzz/`).
pub use control::control_from_deb;
/// The test builders (feature `fixtures`): the fuzz seeds are made with them.
#[cfg(feature = "fixtures")]
pub use control::tests_support as fixtures;
use anyhow::Result;
use std::collections::{BTreeSet, HashMap};
use std::path::PathBuf;
use std::sync::{Arc, Mutex, OnceLock};
use traits::znippy_base::{not_found_bytes, ok_bytes};
use traits::{ArtifactFormat, ArtifactId, RepositoryBackendTrait, ZnippyBase};
use znippy_common::ZnippyReader;
/// znippy-backed Debian (APT) repository.
///
/// APT first fetches the index (`Release` + per-component/arch `Packages`),
/// then the packages out of `pool/`:
/// * `GET /{repo}/dists/{dist}/Release`
/// * `GET /{repo}/dists/{dist}/{component}/binary-{arch}/Packages`
/// * `GET /{repo}/pool/{component}/…/{pkg}_{ver}_{arch}.deb`
///
/// **Native protocol (mirrors the conda/go plugins):** the package files are
/// served straight from the archive, and the **index is synthesized from the
/// `.deb` files present** when the archive doesn't already carry it. Each
/// `Packages` stanza is built from the pool filename (`{name}_{ver}_{arch}.deb`)
/// plus the archive byte length and a real **SHA256** of the served bytes, and
/// the `Release` references each synthesized `Packages` by SHA256 + size — so an
/// `apt` client (`[trusted=yes]`) can resolve and verify downloads. If the
/// archive already contains authoritative upstream metadata (a mirrored repo),
/// that is served verbatim (path-passthrough wins); synthesis is the fallback.
///
/// Synthesized indexes are cached (the archive is immutable) so each request
/// doesn't re-hash the whole pool — the same DoS-amplification guard the conda
/// plugin documents for `repodata.json`. The cache is keyed on the DERIVED
/// identity of the work (`(component, arch)` for a `Packages`, nothing at all for
/// the dist-independent `Release` skeleton), so a caller cannot miss it by
/// renaming a path segment.
pub struct DebRepoZnippy {
/// The typed view (`as_deb`) is the source of the authoritative `control`
/// stanza (Depends/Maintainer/Description) the filename can't carry.
base: ZnippyBase,
/// The `(components, arches)` the pool actually contains — derived once (the
/// archive is immutable) and the ONLY index coordinates that can be asked for.
/// It is what bounds `packages_cache` below.
dims_cache: OnceLock<(BTreeSet<String>, BTreeSet<String>)>,
/// Synthesized `Packages`, keyed by the derived `(component, arch)`. Only ever
/// populated for a pair present in [`DebRepoZnippy::pool_dimensions`], so the
/// map can never grow past the archive's own dimensions.
packages_cache: Mutex<HashMap<(String, String), Arc<PackagesIndex>>>,
/// The dist-INDEPENDENT part of `Release`: the `SHA256:` block over every
/// synthesized `Packages` plus the component/architecture lists. `dists/{dist}`
/// only names the suite, so this is computed once and the (attacker-chosen)
/// dist is formatted into it — no cache keyed on the suite name, and no pool
/// walk per request.
release_cache: OnceLock<ReleaseParts>,
/// The rows of the pool's `.deb`s (lane HOL-PERF-2), when the backend is a chain view:
/// the control stanza, the size and the sha256 the scan worker read at intake.
rows: Option<Arc<dyn holger_index_rows::RowSource>>,
}
/// One synthesized `Packages` index plus the SHA-256 the `Release` references it
/// by — hashed once, when the index is built.
struct PackagesIndex {
/// Rendered once, handed out as the same bytes (lane HOL-PERF-2).
body: traits::Bytes,
/// `Packages.gz`, written once beside it (lane HOL-PERF-2), and its SHA-256.
gz: traits::Bytes,
gz_sha256: String,
sha256: String,
}
/// The dist-independent body of a synthesized `Release`.
struct ReleaseParts {
/// `Date:` (RFC 2822, UTC): when this body was built, which is when the set it indexes
/// was read. apt warns on a Release without one (`Invalid 'Date' entry`), and a signed
/// Release (HOL-SIGNED-APT-DNF) is checked against it (`not valid yet`).
date: String,
/// ` {sha} {size} {component}/binary-{arch}/Packages` lines, in a deterministic order.
sha_lines: String,
components: String,
architectures: String,
}
impl DebRepoZnippy {
pub fn new(name: String) -> Self {
Self::over(ZnippyBase::new(name))
}
fn over(base: ZnippyBase) -> Self {
Self {
base,
dims_cache: OnceLock::new(),
packages_cache: Mutex::new(HashMap::new()),
release_cache: OnceLock::new(),
rows: None,
}
}
/// Backed by a specific znippy archive file. Holds the open archive so the
/// typed deb view is built once and reused.
pub fn with_archive(name: String, archive_path: PathBuf) -> Result<Self> {
Ok(Self::over(ZnippyBase::with_archive(name, archive_path)?))
}
pub fn with_reader(name: String, reader: Arc<dyn ZnippyReader>) -> Self {
Self::over(ZnippyBase::with_reader(name, reader))
}
/// Over `reader`, with the rows of its files (lane HOL-PERF-2): the index is rendered
/// from the rows and reads no package.
pub fn with_reader_and_rows(
name: String,
reader: Arc<dyn ZnippyReader>,
rows: Arc<dyn holger_index_rows::RowSource>,
) -> Self {
Self {
rows: Some(rows),
..Self::with_reader(name, reader)
}
}
pub fn list_files(&self) -> Vec<String> {
self.base.list_files()
}
pub fn get_file(&self, relative_path: &str) -> Result<Vec<u8>> {
self.base.get_file(relative_path)
}
/// Authoritative `(name, version, arch) -> control` from the typed deb view
/// (the control tarball the znippy `deb_native` plugin parsed at ingest), keyed
/// by NEVRA. Empty when the archive carries no deb sub-index (a mirrored repo,
/// a non-deb placeholder, or `.deb`s whose control codec isn't supported yet) —
/// the synthesis then falls back to filename-only stanzas, exactly as before.
fn control_stanzas(&self) -> HashMap<(String, String, String), String> {
let mut map = HashMap::new();
if let Some(archive) = self.base.typed() {
if let Some(view) = archive.as_deb() {
for (name, version, arch, control) in view.list() {
if let Some(control) = control {
map.insert((name, version, arch), control);
}
}
}
}
map
}
/// Parse a pool `.deb`/`.udeb` filename into `(name, version, arch)`.
/// Debian pool filenames are `{name}_{version}_{arch}.deb`; neither the
/// package name nor the version contains `_`, so a 3-way split is exact.
fn parse_deb_filename(fname: &str) -> Option<(String, String, String)> {
let stem = fname
.strip_suffix(".deb")
.or_else(|| fname.strip_suffix(".udeb"))?;
let mut it = stem.splitn(3, '_');
let name = it.next()?;
let version = it.next()?;
let arch = it.next()?;
if name.is_empty() || version.is_empty() || arch.is_empty() {
return None;
}
Some((name.to_string(), version.to_string(), arch.to_string()))
}
/// The `.deb` files under `pool/{component}/`, with their full archive paths.
fn pool_debs(&self, component: &str) -> Vec<String> {
let prefix = format!("pool/{component}/");
let mut files: Vec<String> = self
.list_files()
.into_iter()
.filter(|f| f.starts_with(&prefix) && (f.ends_with(".deb") || f.ends_with(".udeb")))
.collect();
files.sort();
files
}
/// The `(components, arches)` present in `pool/` — the dimensions of the
/// synthesized index, and the ONLY coordinates a request may name. Derived
/// once (the archive is immutable) and memoized: it is consulted on every
/// index request, and it is what keeps the `Packages` cache bounded.
fn pool_dimensions(&self) -> &(BTreeSet<String>, BTreeSet<String>) {
self.dims_cache.get_or_init(|| self.build_pool_dimensions())
}
fn build_pool_dimensions(&self) -> (BTreeSet<String>, BTreeSet<String>) {
let mut components = BTreeSet::new();
let mut arches = BTreeSet::new();
for f in self.list_files() {
if !(f.ends_with(".deb") || f.ends_with(".udeb")) {
continue;
}
// pool/{component}/...
let mut segs = f.split('/');
if segs.next() != Some("pool") {
continue;
}
if let Some(component) = segs.next() {
components.insert(component.to_string());
}
if let Some(fname) = f.rsplit('/').next() {
if let Some((_, _, arch)) = Self::parse_deb_filename(fname) {
arches.insert(arch);
}
}
}
(components, arches)
}
/// Build the `Packages` index for `(component, arch)` from the pool `.deb`s.
/// A `.deb` with architecture `all` is included under every requested arch.
fn build_packages(&self, component: &str, arch: &str) -> Vec<u8> {
let controls = self.control_stanzas();
let mut out = String::new();
for f in self.pool_debs(component) {
let Some(fname) = f.rsplit('/').next() else {
continue;
};
let Some((name, version, deb_arch)) = Self::parse_deb_filename(fname) else {
continue;
};
if deb_arch != arch && deb_arch != "all" {
continue;
}
let typed = controls
.get(&(name.clone(), version.clone(), deb_arch.clone()))
.cloned();
// Authoritative control stanza (real Depends/Maintainer/Description),
// in precedence order:
// 1. the typed deb view (the znippy `deb_native` plugin's sub-index),
// 2. the file's index row (a chain view, lane HOL-PERF-2: the stanza, size
// and sha256 the scan worker read at intake; the .deb is not read), or
// for an archive without rows a NATIVE `ar`+`control.tar.*` parse of the
// served `.deb` bytes (mirrored/plugin-less archives), then
// 3. the filename-only fields as a last resort.
// The repo-level Filename/Size/SHA256 are always appended by holger.
let row = self.rows.as_ref().and_then(|r| r.row(&f));
let (control, size, sha) = match (&self.rows, row) {
(Some(_), Some(r)) => {
let from_row = match &r.row {
holger_index_rows::IndexRow::Deb { control } => Some(control.clone()),
_ => None,
};
(typed.or(from_row), r.size, r.sha256.clone())
}
// A view's file with no row: the bytes for its size and hash, never parsed.
(Some(_), None) => {
let Ok(bytes) = self.get_file(&f) else {
continue;
};
(typed, bytes.len() as u64, hex_sha256(&bytes))
}
(None, _) => {
let Ok(bytes) = self.get_file(&f) else {
continue;
};
let control = typed.or_else(|| control::control_from_deb(&bytes));
(control, bytes.len() as u64, hex_sha256(&bytes))
}
};
match control {
Some(control) => match clean_control_stanza(&control, &name, &version, &deb_arch) {
Some(stanza) => out.push_str(&stanza),
None => {
log::warn!(
"deb: {f} is not listed: its control names another package, version \
or architecture than its pool coordinate"
);
continue;
}
},
None => {
out.push_str(&format!("Package: {name}\n"));
out.push_str(&format!("Version: {version}\n"));
out.push_str(&format!("Architecture: {deb_arch}\n"));
}
}
out.push_str(&format!("Filename: {f}\n"));
out.push_str(&format!("Size: {size}\n"));
out.push_str(&format!("SHA256: {sha}\n"));
out.push('\n');
}
out.into_bytes()
}
/// The synthesized `Packages` for a `(component, arch)` the pool contains,
/// built at most once. The key is DERIVED from the pool, never taken from the
/// request path, so the map is bounded by the archive's own dimensions and a
/// caller cannot force a rebuild by renaming a path segment.
fn packages(&self, component: &str, arch: &str) -> Arc<PackagesIndex> {
let key = (component.to_string(), arch.to_string());
if let Ok(cache) = self.packages_cache.lock() {
if let Some(hit) = cache.get(&key) {
return Arc::clone(hit);
}
}
let body = self.build_packages(component, arch);
let gz = traits::Bytes::from(gzip(&body));
let built = Arc::new(PackagesIndex {
sha256: hex_sha256(&body),
gz_sha256: hex_sha256(&gz),
gz,
body: traits::Bytes::from(body),
});
if let Ok(mut cache) = self.packages_cache.lock() {
cache.insert(key, Arc::clone(&built));
}
built
}
/// The dist-independent `Release` body, built at most once: the `SHA256:`
/// block over every synthesized `Packages` plus the component/architecture
/// lists. This is the whole pool walk; `dists/{dist}` only names the suite.
fn release_parts(&self) -> &ReleaseParts {
self.release_cache
.get_or_init(|| self.build_release_parts())
}
fn build_release_parts(&self) -> ReleaseParts {
let (components, arches) = self.pool_dimensions();
let mut sha_lines = String::new();
// Deterministic order: (component, arch) ascending.
for component in components {
for arch in arches {
let pkgs = self.packages(component, arch);
if pkgs.body.is_empty() {
continue;
}
sha_lines.push_str(&format!(
" {} {} {component}/binary-{arch}/Packages\n",
pkgs.sha256,
pkgs.body.len()
));
sha_lines.push_str(&format!(
" {} {} {component}/binary-{arch}/Packages.gz\n",
pkgs.gz_sha256,
pkgs.gz.len()
));
}
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
ReleaseParts {
date: rfc2822_utc(now),
sha_lines,
components: components.iter().cloned().collect::<Vec<_>>().join(" "),
architectures: arches.iter().cloned().collect::<Vec<_>>().join(" "),
}
}
/// Render the dist `Release` index, referencing every synthesized `Packages`
/// by SHA256 + size (the section `apt` verifies downloaded indexes against).
/// Pure formatting over [`Self::release_parts`] — the suite name is the only
/// per-request input, so a varied `dists/<suite>` costs a `format!`, not a
/// fresh decompress + hash of every package in the pool.
fn build_release(&self, dist: &str) -> Vec<u8> {
let parts = self.release_parts();
let mut out = String::new();
out.push_str("Origin: Holger\n");
out.push_str("Label: Holger\n");
out.push_str(&format!("Suite: {dist}\n"));
out.push_str(&format!("Codename: {dist}\n"));
out.push_str(&format!("Date: {}\n", parts.date));
out.push_str(&format!("Architectures: {}\n", parts.architectures));
out.push_str(&format!("Components: {}\n", parts.components));
out.push_str("Description: Holger synthesized APT repository\n");
out.push_str("SHA256:\n");
out.push_str(&parts.sha_lines);
out.into_bytes()
}
/// Synthesize the index for `remainder` if it names a known APT index file.
/// `None` for non-index paths (caller then 404s). An empty `Packages`
/// (component/arch with no packages) is reported as `None` → 404.
fn synthesize_index(&self, remainder: &str) -> Option<(traits::Bytes, &'static str)> {
let parts: Vec<&str> = remainder.split('/').filter(|s| !s.is_empty()).collect();
match parts.as_slice() {
// dists/{dist}/Release — the suite name is decoration on a body that
// is otherwise identical whatever `dists/<suite>` asks for it.
["dists", dist, "Release"] => {
Some((traits::Bytes::from(self.build_release(dist)), "text/plain"))
}
// dists/{dist}/{component}/binary-{arch}/Packages. The identity of the
// work is (component, arch) — the suite is irrelevant to the body. A
// coordinate the pool does not carry 404s BEFORE any work, which is
// also what keeps the cache bounded to the archive's dimensions.
["dists", _dist, component, binary_arch, file @ ("Packages" | "Packages.gz")] => {
let arch = binary_arch.strip_prefix("binary-")?;
let (components, arches) = self.pool_dimensions();
if !components.contains(*component) || !arches.contains(arch) {
return None;
}
let index = self.packages(component, arch);
if index.body.is_empty() {
return None;
}
// The same bytes every request (lane HOL-PERF-2): a refcount, no copy.
if *file == "Packages.gz" {
return Some((index.gz.clone(), "application/gzip"));
}
Some((index.body.clone(), "text/plain"))
}
_ => None,
}
}
}
/// `secs` since the epoch as an RFC 2822 date in UTC (`Sun, 01 Nov 2026 08:39:20 UTC`), the
/// form apt reads in `Release`.
fn rfc2822_utc(secs: u64) -> String {
const DAYS: [&str; 7] = ["Thu", "Fri", "Sat", "Sun", "Mon", "Tue", "Wed"];
const MONTHS: [&str; 12] = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
let days = (secs / 86_400) as i64;
let rem = secs % 86_400;
// H. Hinnant's civil_from_days.
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + i64::from(m <= 2);
format!(
"{}, {d:02} {} {y} {:02}:{:02}:{:02} UTC",
DAYS[(days % 7) as usize],
MONTHS[(m - 1) as usize],
rem / 3600,
rem % 3600 / 60,
rem % 60
)
}
/// `data`, gzip-compressed (the header's time is zero, so the bytes are the content's).
fn gzip(data: &[u8]) -> Vec<u8> {
use std::io::Write as _;
let mut e = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
e.write_all(data).expect("an in-memory write");
e.finish().expect("an in-memory finish")
}
fn hex_sha256(data: &[u8]) -> String {
// REUSE-law: the ONE shared content-hash primitive (edda `nornir_hash`),
// consumed rather than re-rolling `Sha256::new(); hex(...)` locally. apt
// verifies these against the served bytes, so the digest must be byte-identical.
nornir_hash::sha256_hex(data)
}
/// Normalize a `.deb` `control` stanza into APT `Packages` field lines: take the
/// single stanza (stop at the first blank line — control files carry exactly one),
/// and drop any repo-level `Filename`/`Size`/checksum fields (which holger computes
/// and appends itself; a control file shouldn't carry them, but the archive is
/// attacker-influenced, so this is defence-in-depth against field injection).
/// Multi-line continuation lines (e.g. `Description`) are preserved. Always ends
/// with a trailing newline so the caller can append its own fields.
/// **The control stanza `Packages` lists for one pool `.deb`** (SEC-REVIEW 2026-11-01 K1):
/// `None` when its `Package`, `Version` (less an epoch, which no file name carries) or
/// `Architecture` disagrees with the pool coordinate `name_version_arch.deb`, so the box never
/// signs a listing that names another package. Holger's own fields (Filename, Size, the
/// digests) are left out, a line that is not `Field: value` or a continuation is dropped, a
/// repeated field is dropped, and a missing coordinate field is taken from the file name.
fn clean_control_stanza(control: &str, name: &str, version: &str, arch: &str) -> Option<String> {
let mut out = String::new();
let mut seen = std::collections::BTreeSet::new();
let mut keep_continuation = false;
let (mut has_pkg, mut has_ver, mut has_arch) = (false, false, false);
for line in control.lines() {
if line.trim().is_empty() {
break;
}
if line.starts_with(' ') || line.starts_with('\t') {
if keep_continuation {
out.push_str(line);
out.push('\n');
}
continue;
}
keep_continuation = false;
let Some((key, value)) = line.split_once(':') else {
continue;
};
if key.is_empty()
|| key.starts_with('-')
|| key.starts_with('#')
|| !key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
{
continue;
}
let lower = key.to_ascii_lowercase();
if !seen.insert(lower.clone()) {
continue;
}
if ["filename", "size", "md5sum", "sha1", "sha256", "sha512"].contains(&lower.as_str()) {
continue;
}
let value = value.trim();
match lower.as_str() {
"package" if value != name => return None,
"architecture" if value != arch => return None,
"version" => {
let bare = match value.split_once(':') {
Some((epoch, rest)) if epoch.bytes().all(|b| b.is_ascii_digit()) => rest,
_ => value,
};
if bare != version && value != version {
return None;
}
has_ver = true;
}
"package" => has_pkg = true,
"architecture" => has_arch = true,
_ => {}
}
out.push_str(line);
out.push('\n');
keep_continuation = true;
}
let mut head = String::new();
if !has_pkg {
head.push_str(&format!("Package: {name}\n"));
}
if !has_ver {
head.push_str(&format!("Version: {version}\n"));
}
if !has_arch {
head.push_str(&format!("Architecture: {arch}\n"));
}
Some(head + &out)
}
fn content_type_for(path: &str) -> &'static str {
if path.ends_with(".deb") || path.ends_with(".udeb") {
"application/vnd.debian.binary-package"
} else if path.ends_with(".gz") {
"application/gzip"
} else {
"text/plain"
}
}
impl RepositoryBackendTrait for DebRepoZnippy {
traits::znippy_read_only!(base);
/// A package download in pieces (lane HOL-RSS): the path-passthrough the whole-body
/// door answers first, so the door holds a piece, not the package.
fn stream_get(
&self,
suburl: &str,
piece_bytes: usize,
) -> anyhow::Result<Option<traits::StreamedGet>> {
let remainder = self.base.strip_repo(suburl);
if remainder.is_empty() {
return Ok(None);
}
self.base
.stream_get(remainder, content_type_for(remainder), piece_bytes)
}
fn handle_http2_request(
&self,
method: &str,
suburl: &str,
body: &[u8],
) -> anyhow::Result<(u16, Vec<(String, String)>, Vec<u8>)> {
self.handle_http2_request_bytes(method, suburl, body)
.map(|(s, h, b)| (s, h, b.to_vec()))
}
fn handle_http2_request_bytes(
&self,
method: &str,
suburl: &str,
body: &[u8],
) -> anyhow::Result<traits::HttpBytesReply> {
if method == "HEAD" {
// HOL-HEAD-1: the GET answer's status and headers, its length, no body.
return self
.handle_http2_request_bytes("GET", suburl, &[])
.map(traits::backend::head_of_bytes);
}
let _ = body;
log::debug!("deb repo znippy handle_http2_request.suburl={}", suburl);
let remainder = self.base.strip_repo(suburl);
if remainder.is_empty() {
return Ok(not_found_bytes("Not found"));
}
// 1) Authoritative path-passthrough: packages, and any mirrored upstream
// metadata (a real Release/Packages packed into the archive wins).
if let Ok(data) = self.get_file(remainder) {
return Ok(ok_bytes(
content_type_for(remainder),
traits::Bytes::from(data),
));
}
// 2) Native synthesis: build the APT index from the pool `.deb`s, once.
if let Some((data, ctype)) = self.synthesize_index(remainder) {
return Ok(ok_bytes(ctype, data));
}
Ok(not_found_bytes("Not found in archive"))
}
fn format(&self) -> ArtifactFormat {
ArtifactFormat::Deb
}
fn fetch(&self, id: &ArtifactId) -> anyhow::Result<Option<traits::Bytes>> {
// Match on the PARSED filename, exact name + version — never a string
// prefix (which made `hello 2.10` collide with `hello_2.100`), and
// include `.udeb` (the prior `.deb`-only test skipped udebs entirely).
// When the id carries an arch in `namespace`, require it too.
let mut files: Vec<String> = self
.list_files()
.into_iter()
.filter(|f| f.ends_with(".deb") || f.ends_with(".udeb"))
.collect();
files.sort();
for f in files {
let Some(fname) = f.rsplit('/').next() else {
continue;
};
let Some((name, version, arch)) = Self::parse_deb_filename(fname) else {
continue;
};
if name != id.name || version != id.version {
continue;
}
if let Some(want_arch) = &id.namespace {
if &arch != want_arch {
continue;
}
}
if let Ok(data) = self.get_file(&f) {
return Ok(Some(traits::Bytes::from(data)));
}
}
Ok(None)
}
/// List packages parsed from the pool `.deb` filenames — `(name, version)`
/// coords with the arch riding in `namespace`. Empty when no reader.
fn list(
&self,
name_filter: Option<&str>,
limit: usize,
) -> anyhow::Result<Vec<traits::ArtifactEntry>> {
// Sort by full path for stable output across components.
let mut files = self.list_files();
// ONE index read for the whole listing; every token after it is memory,
// and no chunk of any artifact is touched.
let tokens = self.base.tokens().open();
files.sort();
let mut out = Vec::new();
for f in files {
if !(f.ends_with(".deb") || f.ends_with(".udeb")) {
continue;
}
let Some(fname) = f.rsplit('/').next() else {
continue;
};
let Some((name, version, arch)) = Self::parse_deb_filename(fname) else {
continue;
};
if let Some(flt) = name_filter {
if !name.contains(flt) {
continue;
}
}
let size_bytes = self
.base
.reader()
.and_then(|r| r.file_size(&f))
.unwrap_or(0) as i64;
out.push(
traits::ArtifactEntry {
id: ArtifactId {
namespace: Some(arch),
name,
version,
},
size_bytes,
content_type: "application/vnd.debian.binary-package".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 the one
// provenance answer the index can give for free.
..traits::ArtifactEntry::UNKNOWN_PROVENANCE
}
.with_change_token(tokens.as_ref().and_then(|t| t.token(&f))),
);
if limit != 0 && out.len() >= limit {
break;
}
}
Ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use traits::test_support::{CountingReader, MemReader};
#[cfg(feature = "testmatrix")]
use traits::test_support::fstatus;
/// The synthesized-index cache must be keyed on the DERIVED identity of the
/// work, not on the request path. Keyed by the path remainder, an
/// unauthenticated client re-ran the whole pool walk (decompress + SHA-256 +
/// control-parse of every `.deb`) just by varying `dists/<suite>` — a small
/// request buying arbitrarily much server work. Counted through the reader:
/// after the first index request, no amount of renaming may cause one further
/// extraction.
#[test]
fn the_release_date_is_rfc2822_utc() {
assert_eq!(rfc2822_utc(0), "Thu, 01 Jan 1970 00:00:00 UTC");
assert_eq!(
rfc2822_utc(951_782_400 + 3_723),
"Tue, 29 Feb 2000 01:02:03 UTC"
);
assert_eq!(rfc2822_utc(1_793_522_360), "Sun, 01 Nov 2026 08:39:20 UTC");
}
#[test]
fn index_work_is_not_repeatable_by_renaming_the_path() {
let deb = super::control::tests_support::sample_deb();
let reader = CountingReader::new(MemReader::new([
("pool/main/h/hello/hello_2.10-2_amd64.deb", deb.clone()),
("pool/main/h/hello/hello_2.11_amd64.deb", deb.clone()),
("pool/main/c/curl/curl_8.5.0_amd64.deb", deb.clone()),
]));
let repo = DebRepoZnippy::with_reader("debrepo".into(), Arc::clone(&reader) as Arc<_>);
// First Release: the pool walk actually happens.
let (st, _h, body) = repo
.handle_http2_request("GET", "/debrepo/dists/stable/Release", b"")
.expect("ok");
let first = String::from_utf8(body).expect("utf8");
assert_eq!(st, 200);
assert!(first.contains("Suite: stable\n"), "{first}");
let after_first = reader.hits();
assert!(
after_first >= 3,
"the first Release must read every pool .deb, got {after_first}"
);
// Now the attack: same Release, a fresh suite name every time.
for suite in ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"] {
let (s, _h, b) = repo
.handle_http2_request("GET", &format!("/debrepo/dists/{suite}/Release"), b"")
.expect("ok");
let rendered = String::from_utf8(b).expect("utf8");
assert_eq!(s, 200);
// Still correct: the suite is reflected…
assert!(
rendered.contains(&format!("Suite: {suite}\n")),
"{rendered}"
);
// …over an identical, already-computed body.
assert_eq!(
rendered.replace(&format!("Suite: {suite}\nCodename: {suite}\n"), ""),
first.replace("Suite: stable\nCodename: stable\n", ""),
"only the suite may differ between Release renderings"
);
}
let after_renames = reader.hits();
assert_eq!(
after_renames, after_first,
"renaming dists/<suite> must not buy a single further pool read \
({after_first} -> {after_renames})"
);
// Same for Packages: the suite segment is not part of the work's identity.
for suite in ["stable", "k", "l", "m"] {
let (s, _h, b) = repo
.handle_http2_request(
"GET",
&format!("/debrepo/dists/{suite}/main/binary-amd64/Packages"),
b"",
)
.expect("ok");
assert_eq!(s, 200);
assert!(String::from_utf8_lossy(&b).contains("Package: hello\n"));
}
let after_packages = reader.hits();
assert_eq!(
after_packages, after_first,
"the Packages body is identified by (component, arch), not by the suite"
);
// A coordinate the pool does not carry 404s without doing any work — which
// is what keeps the cache bounded to the archive's own dimensions.
for url in [
"/debrepo/dists/stable/nosuchcomponent/binary-amd64/Packages",
"/debrepo/dists/stable/main/binary-nosucharch/Packages",
] {
let (s, _h, _b) = repo.handle_http2_request("GET", url, b"").expect("ok");
assert_eq!(s, 404, "unknown index coordinate must 404: {url}");
}
let after_unknown = reader.hits();
assert_eq!(
after_unknown, after_first,
"an unknown coordinate must cost nothing"
);
assert!(
repo.packages_cache.lock().unwrap().len() <= 1,
"only the one real (component, arch) may be cached, got {:?}",
repo.packages_cache
.lock()
.unwrap()
.keys()
.collect::<Vec<_>>()
);
#[cfg(feature = "testmatrix")]
fstatus(
"znippy-deb",
"index_cache_keyed_on_derived_identity",
after_unknown == after_first,
&format!(
"{after_first} pool reads for the first index; 14 renamed index requests + 2 unknown coords cost 0 more"
),
);
}
#[test]
fn test_new() {
let repo = DebRepoZnippy::new("deb-test".to_string());
assert_eq!(repo.name(), "deb-test");
assert!(!repo.is_writable());
#[cfg(feature = "testmatrix")]
fstatus(
"znippy-deb",
"new_name_and_readonly",
repo.name() == "deb-test" && !repo.is_writable(),
&format!("name={} writable={}", repo.name(), repo.is_writable()),
);
}
/// **Capability row** (CLAUDE.md prod-vs-prototype: Deb control parse). The
/// native `Packages`/`Release` index is synthesized with real SHA256, and the
/// per-package control fields now come from a real `ar`+`control.tar.*` parse of
/// the served `.deb` bytes (the `control` module) when the typed deb view is
/// absent — filename-derivation is only the last-resort fallback. Proven by
/// [`native_control_parse_enriches_packages`]; emitted `ok=true`.
#[test]
fn deb_control_parse_capability() {
// A bare `.deb` (no typed view) must surface a real control field.
let deb = super::control::tests_support::sample_deb();
let parsed = super::control::control_from_deb(&deb)
.map(|c| c.contains("Depends:"))
.unwrap_or(false);
assert!(
parsed,
"native ar+control.tar parse must extract the control stanza"
);
#[cfg(feature = "testmatrix")]
fstatus(
"znippy-deb",
"binary_control_parse",
parsed,
"Packages control fields parsed from ar+control.tar.{,gz,xz,zst}; real SHA256",
);
}
#[test]
fn serves_injected_archive() {
// Real APT pool layout: pool/{component}/{prefix}/{pkg}/{pkg}_{ver}_{arch}.deb
const KEY: &str = "pool/main/h/hello/hello_2.10-2_amd64.deb";
let deb_bytes: Vec<u8> = b"!<arch>\nDEBIAN-FAKE-PAYLOAD".to_vec();
let reader = MemReader::new([(KEY, deb_bytes.clone())]);
let repo = DebRepoZnippy::with_reader("debrepo".to_string(), reader);
// fetch() scans pool/ for a .deb whose filename starts `name_version`.
let id = ArtifactId {
namespace: None,
name: "hello".into(),
version: "2.10-2".into(),
};
let fetched = repo.fetch(&id).expect("fetch ok");
assert_eq!(fetched.as_deref(), Some(deb_bytes.as_slice()));
// fetch() miss → None.
let miss_id = ArtifactId {
namespace: None,
name: "absent".into(),
version: "9.9".into(),
};
let fetched_miss = repo.fetch(&miss_id).expect("fetch ok");
assert!(fetched_miss.is_none());
// HTTP/2 path-passthrough hit: /{repo}/{key} → 200 + deb content type + body.
let url = format!("/debrepo/{KEY}");
let (status, headers, body) = repo
.handle_http2_request("GET", &url, b"")
.expect("handle ok");
assert_eq!(status, 200);
assert_eq!(body, deb_bytes);
let ctype = headers
.iter()
.find(|(k, _)| k == "Content-Type")
.map(|(_, v)| v.as_str());
assert_eq!(ctype, Some("application/vnd.debian.binary-package"));
// HTTP/2 miss → 404.
let (miss_status, _miss_headers, _miss_body) = repo
.handle_http2_request("GET", "/debrepo/pool/main/h/hello/nope_0_amd64.deb", b"")
.expect("handle ok");
assert_eq!(miss_status, 404);
#[cfg(feature = "testmatrix")]
{
fstatus(
"znippy-deb",
"fetch_serves_injected_deb",
fetched.as_deref() == Some(deb_bytes.as_slice()) && fetched_miss.is_none(),
&format!(
"key={KEY} fetched_len={:?} miss={:?}",
fetched.map(|d| d.len()),
fetched_miss
),
);
fstatus(
"znippy-deb",
"http2_serves_injected_deb",
status == 200
&& body == deb_bytes
&& ctype == Some("application/vnd.debian.binary-package")
&& miss_status == 404,
&format!("url={url} status={status} ctype={ctype:?} miss_status={miss_status}"),
);
}
}
/// Native protocol: the `Packages` index and the dist `Release` are
/// synthesized from the pool `.deb`s (with real sizes + SHA256), and the
/// `Release` references the synthesized `Packages` by hash — the index path
/// an `apt` client resolves before downloading.
#[test]
fn synthesizes_apt_index_from_pool() {
const KEY: &str = "pool/main/h/hello/hello_2.10-2_amd64.deb";
let deb_bytes: Vec<u8> = b"!<arch>\nDEBIAN-FAKE-PAYLOAD".to_vec();
let reader = MemReader::new([(KEY, deb_bytes.clone())]);
let repo = DebRepoZnippy::with_reader("debrepo".to_string(), reader);
// Packages index: 200 + text/plain, carrying the package's control fields
// (built from the filename) + the real size + SHA256 of the .deb bytes.
let pkgs_url = "/debrepo/dists/stable/main/binary-amd64/Packages";
let (st, headers, body) = repo.handle_http2_request("GET", pkgs_url, b"").expect("ok");
let pkgs = String::from_utf8(body.clone()).expect("utf8");
let ctype = headers
.iter()
.find(|(k, _)| k == "Content-Type")
.map(|(_, v)| v.as_str());
let expected_sha = hex_sha256(&deb_bytes);
let packages_ok = st == 200
&& ctype == Some("text/plain")
&& pkgs.contains("Package: hello\n")
&& pkgs.contains("Version: 2.10-2\n")
&& pkgs.contains("Architecture: amd64\n")
&& pkgs.contains(&format!("Filename: {KEY}\n"))
&& pkgs.contains(&format!("Size: {}\n", deb_bytes.len()))
&& pkgs.contains(&format!("SHA256: {expected_sha}\n"));
assert!(packages_ok, "Packages index malformed:\n{pkgs}");
// Release: 200, lists the component/arch and references the synthesized
// Packages by its SHA256 — the same hash a fresh build of Packages yields.
let rel_url = "/debrepo/dists/stable/Release";
let (rst, _rh, rbody) = repo.handle_http2_request("GET", rel_url, b"").expect("ok");
let release = String::from_utf8(rbody).expect("utf8");
let pkgs_sha = hex_sha256(&body);
let release_ok = rst == 200
&& release.contains("Suite: stable\n")
&& release.contains("Components: main\n")
&& release.contains("Architectures: amd64\n")
&& release.contains(&format!(
" {pkgs_sha} {} main/binary-amd64/Packages\n",
body.len()
));
assert!(release_ok, "Release malformed:\n{release}");
// An arch with no packages → 404 (no empty index served).
let (miss_st, _h, _b) = repo
.handle_http2_request(
"GET",
"/debrepo/dists/stable/main/binary-arm64/Packages",
b"",
)
.expect("ok");
assert_eq!(miss_st, 404);
#[cfg(feature = "testmatrix")]
{
fstatus(
"znippy-deb",
"synthesizes_packages_index",
packages_ok,
&format!("url={pkgs_url} status={st} sha={expected_sha}"),
);
fstatus(
"znippy-deb",
"synthesizes_release_index",
release_ok && miss_st == 404,
&format!(
"url={rel_url} status={rst} pkgs_sha={pkgs_sha} empty_arch_status={miss_st}"
),
);
}
}
/// The native `ar`+`control.tar.*` parse ENRICHES the synthesized `Packages`
/// index: served a bare `.deb` (no typed deb view, so `control_stanzas()` is
/// empty), holger still surfaces the real `Depends`/`Maintainer`/`Description`
/// from the package's own control tarball — not just the filename-derived
/// name/version/arch. This is the capability the maturity row now asserts green.
/// **The signed `Packages` lists a `.deb` only as its approved coordinate** (SEC-REVIEW
/// 2026-11-01 K1). The stanza came from the package's own control, so an approved
/// `hello_1.0_amd64.deb` whose control says `Package: libc6`, `Version: 99:1` was listed
/// (and box-signed) as libc6 99, and `apt upgrade` installed it; a control line with no
/// colon broke the index for every client. A stanza whose Package, Version or
/// Architecture disagrees with the pool coordinate is not listed; a malformed line or a
/// repeated field is dropped.
#[test]
fn a_control_that_names_another_package_is_not_listed() {
for control in [
"Package: libc6\nVersion: 99:1\nArchitecture: amd64\n",
"Package: hello\nVersion: 99:1\nArchitecture: amd64\n",
"Package: hello\nVersion: 1.0\nArchitecture: arm64\n",
] {
assert_eq!(
clean_control_stanza(control, "hello", "1.0", "amd64"),
None,
"{control}"
);
}
let got = clean_control_stanza(
"Package: hello\nVersion: 2:1.0\nArchitecture: amd64\nno colon here\nDepends: libc6\n continued\nDepends: evil\nSHA256: x\n",
"hello",
"1.0",
"amd64",
)
.expect("listed: the epoch is not in a file name");
assert_eq!(
got,
"Package: hello\nVersion: 2:1.0\nArchitecture: amd64\nDepends: libc6\n continued\n"
);
let got = clean_control_stanza("Maintainer: m\n", "hello", "1.0", "all").unwrap();
assert!(
got.starts_with("Package: hello\nVersion: 1.0\nArchitecture: all\n"),
"{got}"
);
}
#[test]
fn native_control_parse_enriches_packages() {
const KEY: &str = "pool/main/h/hello/hello_2.10-2_amd64.deb";
let deb = super::control::tests_support::sample_deb();
let reader = MemReader::new([(KEY, deb.clone())]);
// `with_reader` ⇒ no typed archive view ⇒ control_stanzas() is empty, so the
// ONLY way these fields appear is the native ar+control.tar parse.
let repo = DebRepoZnippy::with_reader("debrepo".into(), reader);
assert!(
repo.control_stanzas().is_empty(),
"no typed view in with_reader mode"
);
let url = "/debrepo/dists/stable/main/binary-amd64/Packages";
let (st, _h, body) = repo.handle_http2_request("GET", url, b"").expect("ok");
let pkgs = String::from_utf8(body).expect("utf8");
assert_eq!(st, 200);
// Filename-derived coords still present…
assert!(pkgs.contains("Package: hello\n"), "index:\n{pkgs}");
assert!(pkgs.contains("Version: 2.10-2\n"), "index:\n{pkgs}");
// …plus the NATIVELY-parsed authoritative control fields.
assert!(
pkgs.contains("Maintainer: Santiago <santiago@debian.org>\n"),
"index:\n{pkgs}"
);
assert!(
pkgs.contains("Depends: libc6 (>= 2.14)\n"),
"index:\n{pkgs}"
);
assert!(
pkgs.contains("Description: friendly GNU greeter\n"),
"index:\n{pkgs}"
);
// holger's own repo-level fields are appended over the top.
assert!(
pkgs.contains(&format!("Filename: {KEY}\n")),
"index:\n{pkgs}"
);
assert!(
pkgs.contains(&format!("Size: {}\n", deb.len())),
"index:\n{pkgs}"
);
#[cfg(feature = "testmatrix")]
fstatus(
"znippy-deb",
"native_control_parse_enriches_packages",
pkgs.contains("Depends: libc6 (>= 2.14)\n") && pkgs.contains("Maintainer: Santiago"),
"ar+control.tar.gz parse surfaced Depends/Maintainer/Description with no typed view",
);
}
#[test]
fn test_format() {
let f = DebRepoZnippy::new("x".into()).format();
assert_eq!(f, ArtifactFormat::Deb);
#[cfg(feature = "testmatrix")]
fstatus(
"znippy-deb",
"format_is_deb",
f == ArtifactFormat::Deb,
&format!("format = {f:?}"),
);
}
/// T20: fetch must not collide on a version prefix (`2.10` vs `2.100`), must
/// honour an arch hint, and must be able to return a `.udeb` (the prior
/// `.deb`-only test skipped udebs entirely).
#[test]
fn fetch_no_version_collision_udeb_and_arch() {
let b2102 = b"hello-2.10-2-body".to_vec();
let b2100 = b"hello-2.100-body-different".to_vec();
let bi386 = b"hello-2.10-2-i386-body".to_vec();
let budeb = b"hello-udeb-installer-body".to_vec();
let reader = MemReader::new([
("pool/main/h/hello/hello_2.10-2_amd64.deb", b2102.clone()),
("pool/main/h/hello/hello_2.100_amd64.deb", b2100.clone()),
("pool/main/h/hello/hello_2.10-2_i386.deb", bi386.clone()),
(
"pool/main/h/hello-udeb/hello-udeb_2.10-2_amd64.udeb",
budeb.clone(),
),
]);
let repo = DebRepoZnippy::with_reader("debrepo".into(), reader);
// A bare "2.10" matches NO exact version (the debs are 2.10-2 / 2.100).
let id_bare = ArtifactId {
namespace: None,
name: "hello".into(),
version: "2.10".into(),
};
assert!(
repo.fetch(&id_bare).expect("ok").is_none(),
"2.10 must not match 2.10-2 or 2.100"
);
// Exact "2.10-2" returns a 2.10-2 build, never the 2.100 body.
let id_2102 = ArtifactId {
namespace: None,
name: "hello".into(),
version: "2.10-2".into(),
};
let got = repo.fetch(&id_2102).expect("ok").expect("2.10-2 found");
assert_ne!(
got, b2100,
"fetch(hello,2.10-2) must NOT return the 2.100 body"
);
assert!(got == b2102 || got == bi386, "returned a real 2.10-2 build");
// Arch hint pins the build.
let id_i386 = ArtifactId {
namespace: Some("i386".into()),
name: "hello".into(),
version: "2.10-2".into(),
};
assert_eq!(
repo.fetch(&id_i386).expect("ok").as_deref(),
Some(bi386.as_slice())
);
// A .udeb is fetchable.
let id_udeb = ArtifactId {
namespace: None,
name: "hello-udeb".into(),
version: "2.10-2".into(),
};
assert_eq!(
repo.fetch(&id_udeb).expect("ok").as_deref(),
Some(budeb.as_slice())
);
let ok = repo.fetch(&id_bare).expect("ok").is_none()
&& got != b2100
&& repo.fetch(&id_udeb).expect("ok").as_deref() == Some(budeb.as_slice());
assert!(ok);
#[cfg(feature = "testmatrix")]
fstatus(
"znippy-deb",
"fetch_no_version_collision_udeb",
ok,
"2.10!=2.10-2, 2.10-2!=2.100, arch-pinned, udeb fetchable",
);
}
/// Caps and methods agree, with and without an archive loaded.
#[test]
fn caps_match_methods() {
struct One;
impl znippy_common::ZnippyReader for One {
fn list_files(&self) -> anyhow::Result<Vec<String>> {
Ok(vec!["x".into()])
}
fn extract_file(&self, _: &str) -> anyhow::Result<Vec<u8>> {
Ok(Vec::new())
}
fn contains(&self, p: &str) -> bool {
p == "x"
}
fn file_size(&self, _: &str) -> Option<u64> {
Some(0)
}
}
traits::backend::conformance::check(&DebRepoZnippy::new("r".into())).unwrap();
traits::backend::conformance::check(&DebRepoZnippy::with_reader(
"r".into(),
std::sync::Arc::new(One),
))
.unwrap();
}
/// **A chain view's `Packages` is rendered from the rows** (lane HOL-PERF-2): byte for
/// byte the index the in-process parse gives, and not one `.deb` is read for it.
#[test]
fn with_rows_the_packages_index_reads_no_deb_and_is_the_parsed_one() {
use traits::test_support::{CountingReader, MemReader};
let deb = super::control::tests_support::sample_deb();
let path = "pool/main/h/hello/hello_2.10-2_amd64.deb";
let url = "/d/dists/stable/main/binary-amd64/Packages";
let parsed = DebRepoZnippy::with_reader("d".into(), MemReader::new([(path, deb.clone())]));
let want = parsed.handle_http2_request("GET", url, &[]).unwrap();
assert_eq!(want.0, 200);
assert!(String::from_utf8_lossy(&want.2).contains("Depends: libc6 (>= 2.14)"));
let counting = CountingReader::new(MemReader::new([(path, deb.clone())]));
let rows = holger_index_rows::MapRows::of("deb", &[(path, &deb)]);
let viewed = DebRepoZnippy::with_reader_and_rows("d".into(), counting.clone(), rows);
let got = viewed.handle_http2_request("GET", url, &[]).unwrap();
assert_eq!(
String::from_utf8_lossy(&got.2),
String::from_utf8_lossy(&want.2),
"byte for byte"
);
// The same Release but its `Date:`, which is when each backend rendered it.
let undated = |r: &DebRepoZnippy| {
let (_, _, b) = r
.handle_http2_request("GET", "/d/dists/stable/Release", &[])
.unwrap();
String::from_utf8_lossy(&b)
.lines()
.filter(|l| !l.starts_with("Date: "))
.collect::<Vec<_>>()
.join("\n")
};
assert_eq!(undated(&viewed), undated(&parsed));
assert_eq!(counting.hits(), 0, "no .deb is read to index it");
}
/// **Rendered once, handed out as the same bytes** (lane HOL-PERF-2, X5): a second
/// request for `Packages` gets the very allocation the first got, never a copy.
#[test]
fn the_packages_index_is_one_allocation_handed_out_again() {
use traits::test_support::MemReader;
let deb = super::control::tests_support::sample_deb();
let repo = DebRepoZnippy::with_reader(
"d".into(),
MemReader::new([("pool/main/h/hello/hello_2.10-2_amd64.deb", deb)]),
);
let url = "/d/dists/stable/main/binary-amd64/Packages";
let a = repo.handle_http2_request_bytes("GET", url, &[]).unwrap();
let b = repo.handle_http2_request_bytes("GET", url, &[]).unwrap();
assert_eq!((a.0, !a.2.is_empty()), (200, true));
assert_eq!(a.2.as_ptr(), b.2.as_ptr(), "the same bytes, not a copy");
let h = repo.handle_http2_request_bytes("HEAD", url, &[]).unwrap();
assert!(
h.2.is_empty()
&& h.1
.iter()
.any(|(k, v)| k == "Content-Length" && *v == a.2.len().to_string()),
"{:?}",
h.1
);
}
/// **`Packages.gz` is written once and listed in `Release`** (lane HOL-PERF-2, step
/// 6): apt moves about a tenth of the bytes; the plain `Packages` stays.
#[test]
fn packages_gz_is_written_once_and_listed_in_release() {
use std::io::Read as _;
use traits::test_support::MemReader;
let deb = super::control::tests_support::sample_deb();
let repo = DebRepoZnippy::with_reader(
"d".into(),
MemReader::new([("pool/main/h/hello/hello_2.10-2_amd64.deb", deb)]),
);
let plain = repo
.handle_http2_request_bytes("GET", "/d/dists/stable/main/binary-amd64/Packages", &[])
.unwrap()
.2;
let gz = repo
.handle_http2_request_bytes("GET", "/d/dists/stable/main/binary-amd64/Packages.gz", &[])
.unwrap();
assert_eq!(gz.0, 200);
let mut out = Vec::new();
flate2::read::GzDecoder::new(&gz.2[..])
.read_to_end(&mut out)
.unwrap();
assert_eq!(out, &plain[..], "Packages.gz is Packages");
let again = repo
.handle_http2_request_bytes("GET", "/d/dists/stable/main/binary-amd64/Packages.gz", &[])
.unwrap();
assert_eq!(gz.2.as_ptr(), again.2.as_ptr(), "written once");
let release = String::from_utf8(
repo.handle_http2_request("GET", "/d/dists/stable/Release", &[])
.unwrap()
.2,
)
.unwrap();
assert!(
release.contains(&format!(
" {} {} main/binary-amd64/Packages.gz\n",
hex_sha256(&gz.2),
gz.2.len()
)),
"{release}"
);
}
}