server/repository/znippy-composer/src/lib.rs
//! Composer (PHP / Packagist) repository backend served from a read-only znippy
//! archive — the Composer sibling to `znippy-{npm,maven,rpm,...}`, one crate per
//! ecosystem, all implementing `RepositoryBackendTrait`.
//!
//! **Finished 2026-09-20 (?23).** Until then this backend was, alone among its
//! siblings, pure passthrough: the archive had to already carry Composer v2
//! metadata verbatim at `p2/{vendor}/{package}.json`. `packages.json` was
//! correct and pointed a client at `metadata-url` → `/{repo}/p2/%package%.json`,
//! and from a bare set of dist zips there was NOTHING at that URL — so every
//! `composer install` 404ed on p2 and failed. It now synthesizes that metadata
//! from the packages present, the way deb builds `Release` and rpm builds
//! `repomd`: a Composer dist is a **zip**, the `composer.json` inside it is the
//! package definition, and `p2.rs` turns those into p2 documents.
//!
//! **Passthrough still wins.** When the archive really does carry
//! `p2/{vendor}/{package}.json`, that document is served verbatim, exactly as
//! before: a curated mirror of real Packagist metadata knows more than any
//! synthesis can reconstruct. Synthesis is the FALLBACK.
//!
//! `handle_http2_request` strips the repo name and routes three shapes —
//! `packages.json` (the root), `p2/…` metadata (archive first, then
//! synthesized, `~dev` included), and `dist/…` zips straight out of the
//! archive. `fetch()` is the in-process dist path (namespace = vendor);
//! `put()` always errors, read-only.
//!
//! Gotcha: the emitted `packages.json` `metadata-url` is `/{name}/p2/%package%.json`,
//! and `%package%` is Composer's own literal placeholder the client substitutes —
//! it is emitted through `serde_json`, never interpolated, so it survives.
//!
//! Cost: the catalog — one read of every dist zip for its `composer.json` — is
//! built ONCE, lazily, on the first p2 request that has to synthesize, held in
//! a `OnceLock` like deb's `Release`. The fan-out is `znippy_zoomies::gatling`
//! (ROOT LAW #0 — never rayon). The `dist/…` and passthrough-`p2/…` arms never
//! touch it.
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::{Arc, OnceLock};
use anyhow::Result;
use traits::znippy_base::{not_found, ok};
use traits::{ArtifactFormat, ArtifactId, RepositoryBackendTrait, ZnippyBase};
use znippy_common::ZnippyReader;
pub mod p2;
use p2::ComposerPackage;
/// A dist's `composer.json`, when it has one: its member name and bytes.
type Manifest = Option<(String, Vec<u8>)>;
/// The catalog: `vendor/package` → its versions.
type Catalog = BTreeMap<String, Vec<ComposerPackage>>;
/// The public base URL the synthesized `dist.url` is built from.
///
/// Composer resolves `dist.url` exactly as written, so it must be the
/// externally-reachable holger base (set from `ron_base_url`) — a relative or
/// loopback URL sends every remote client to the wrong host. Defaults to the
/// dev loopback when unconfigured, the same default `znippy-nuget` uses.
const DEFAULT_BASE_URL: &str = "https://127.0.0.1:8443";
/// znippy-backed Composer (PHP / Packagist) repository implementing the
/// Composer v2 metadata protocol.
///
/// * `GET /{repo}/packages.json` → repository root (points at p2)
/// * `GET /{repo}/p2/{vendor}/{package}.json` → per-package version metadata
/// * `GET /{repo}/dist/{vendor}/{package}/{ref}.zip` → distributable zip
///
/// Metadata is stored in the archive as `p2/{vendor}/{package}.json`, dists as
/// `dist/{vendor}/{package}/{ref}.zip`. When the former is absent it is
/// synthesized from the latter; see [`p2`].
pub struct ComposerRepoZnippy {
base: ZnippyBase,
/// The archive's file table, taken ONCE — the same reasoning as
/// `znippy-nuget`'s: `ZnippyArchive::list_files()` allocates one String per
/// file in the archive on every call, and the root and p2 arms scan it per
/// request. This backend is read-only and holds its reader for the process
/// lifetime, so the table cannot change underneath.
paths: OnceLock<Vec<String>>,
/// Public base URL the synthesized `dist.url` points at. See
/// [`DEFAULT_BASE_URL`].
base_url: String,
/// **The `composer.json` of every dist in the archive, taken ONCE.**
///
/// A p2 document is an answer ABOUT the dists, so building one means
/// opening every dist zip. Per request that would make `composer install`
/// — which asks for one p2 document per required package — an O(archive)
/// read each time. Held for the process lifetime, like deb's `Release`
/// `OnceLock` and conda's repodata cache.
catalog: OnceLock<Catalog>,
}
impl ComposerRepoZnippy {
pub fn new(name: String) -> Self {
Self {
base: ZnippyBase::new(name),
paths: OnceLock::new(),
base_url: DEFAULT_BASE_URL.to_string(),
catalog: OnceLock::new(),
}
}
pub fn with_archive(name: String, archive_path: PathBuf) -> Result<Self> {
Ok(Self {
base: ZnippyBase::with_archive(name, &archive_path)?,
paths: OnceLock::new(),
base_url: DEFAULT_BASE_URL.to_string(),
catalog: OnceLock::new(),
})
}
pub fn with_reader(name: String, reader: Arc<dyn ZnippyReader>) -> Self {
Self {
base: ZnippyBase::with_reader(name, reader),
paths: OnceLock::new(),
base_url: DEFAULT_BASE_URL.to_string(),
catalog: OnceLock::new(),
}
}
/// Override the public base URL the synthesized `dist.url` is built from.
/// An empty string resets to the default loopback; a trailing slash is
/// normalized. Same seam and same wording as `znippy-nuget`'s.
pub fn with_base_url(mut self, url: String) -> Self {
let trimmed = url.trim_end_matches('/');
self.base_url = if trimmed.is_empty() {
DEFAULT_BASE_URL.to_string()
} else {
trimmed.to_string()
};
self
}
/// The archive's file table, filled on first use. See the `paths` field.
fn paths(&self) -> &[String] {
self.paths.get_or_init(|| match self.base.reader() {
Some(r) => r.list_files().unwrap_or_default(),
None => Vec::new(),
})
}
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)
}
/// Every `vendor/package` this repository holds, from the archive PATHS
/// alone — no dist is opened. Both layouts count: a package the archive
/// carries p2 metadata for, and a package it only carries dists for.
fn available_packages(&self) -> Vec<String> {
let mut out: std::collections::BTreeSet<String> = Default::default();
for path in self.paths() {
let mut seg = path.split('/');
match (seg.next(), seg.next(), seg.next()) {
(Some("dist"), Some(vendor), Some(pkg))
if !vendor.is_empty() && !pkg.is_empty() =>
{
out.insert(format!("{vendor}/{pkg}"));
}
(Some("p2"), Some(vendor), Some(file)) if !vendor.is_empty() => {
if let Some(pkg) = file.strip_suffix(".json") {
let pkg = pkg.strip_suffix("~dev").unwrap_or(pkg);
if !pkg.is_empty() {
out.insert(format!("{vendor}/{pkg}"));
}
}
}
_ => {}
}
}
out.into_iter().collect()
}
/// The `dist/{vendor}/{package}/{reference}.zip` coordinates.
fn dist_coords(&self) -> Vec<DistCoord> {
let mut out = Vec::new();
for path in self.paths() {
let Some(rest) = path.strip_prefix("dist/") else {
continue;
};
let mut seg = rest.split('/');
let (Some(vendor), Some(pkg), Some(file), None) =
(seg.next(), seg.next(), seg.next(), seg.next())
else {
continue;
};
let Some(reference) = file.strip_suffix(".zip") else {
continue;
};
if vendor.is_empty() || pkg.is_empty() || reference.is_empty() {
continue;
}
out.push(DistCoord {
path: path.clone(),
vendor: vendor.to_string(),
package: pkg.to_string(),
reference: reference.to_string(),
});
}
out
}
/// Open every dist zip and read its `composer.json`. Called ONCE; see the
/// `catalog` field.
///
/// A dist that is not a readable zip, or that carries no `composer.json`,
/// is **skipped and logged** — never fatal. One hostile dist must not make
/// the whole repository unresolvable, which is the same ruling
/// `znippy-helm` makes about one bomb chart in an `index.yaml` build. Its
/// bytes are still downloadable at their exact `dist/…` URL.
fn build_catalog(&self) -> Catalog {
let Some(reader) = self.base.reader() else {
return Catalog::new();
};
let coords = self.dist_coords();
if coords.is_empty() {
return Catalog::new();
}
// ROOT LAW #0: gatling, never rayon. One unit per dist, weighted by the
// archive's own stored size so the big dists are dealt first. At most
// CATALOG_WORKERS at once (lane HOL-RSS-3): each holds a zip's directory, its
// `composer.json` and one piece of the dist being hashed, never the dist.
let parsed: Vec<Option<ComposerPackage>> =
znippy_zoomies::gatling_forkjoin::gatling_map_balanced(
&coords,
catalog_workers(),
1,
|c: &DistCoord| reader.file_size(&c.path).unwrap_or(0),
|_i, c: &DistCoord| {
let (manifest, shasum) = match self.read_dist(reader.as_ref(), &c.path) {
Ok((Some((_name, b)), shasum)) => match p2::parse_manifest(&b) {
Some(m) => (m, shasum),
None => {
log::warn!("composer catalog: bad composer.json in {}", c.path);
return None;
}
},
Ok((None, _)) => {
log::warn!("composer catalog: no composer.json in {}", c.path);
return None;
}
Err(e) => {
log::warn!("composer catalog: refused {} : {e}", c.path);
return None;
}
};
Some(p2::package_from_dist(
&c.vendor,
&c.package,
&c.reference,
manifest,
shasum,
))
},
);
p2::group(parsed.into_iter().flatten().collect())
}
/// The `composer.json` of the dist zip at `rel` and the dist's SHA-1 (lane
/// HOL-RSS-3). The manifest is read where it lies in the archive — the zip's end, its
/// central directory and the one member — and the SHA-1 a piece at a time: the dist
/// is never held whole. Composer checks a download against `dist.shasum` with sha1,
/// so the digest covers every byte. A backend over a plain reader reads the dist
/// whole, once, for both.
fn read_dist(&self, reader: &dyn ZnippyReader, rel: &str) -> Result<(Manifest, String)> {
if let Some(mut f) = self.base.file_at(rel) {
let mut src =
zip_entry::ReadAt::new(f.len(), |off, buf: &mut [u8]| f.read_at(off, buf));
let manifest = zip_entry::find_member_at(
&mut src,
zip_entry::MAX_MEMBER_DECOMPRESSED_BYTES,
p2::is_dist_manifest,
)?;
if manifest.is_none() {
return Ok((None, String::new()));
}
use sha1::{Digest, Sha1};
let mut h = Sha1::new();
self.base.for_each_piece(rel, |p| h.update(p))?;
return Ok((manifest, hex(&h.finalize())));
}
let bytes = reader.extract_file(rel)?;
let manifest = zip_entry::find_member(&bytes, p2::is_dist_manifest)?;
Ok((manifest, sha1_hex(&bytes)))
}
/// The catalog, filled on first use.
fn catalog(&self) -> &Catalog {
self.catalog.get_or_init(|| self.build_catalog())
}
}
/// One dist as the archive path spells it.
struct DistCoord {
path: String,
vendor: String,
package: String,
reference: String,
}
/// SHA-1 of a dist, hex. See [`p2::ComposerPackage::shasum`] for why it is
/// SHA-1 and not the house SHA-256.
fn sha1_hex(bytes: &[u8]) -> String {
use sha1::{Digest, Sha1};
let mut h = Sha1::new();
h.update(bytes);
hex(&h.finalize())
}
/// Lowercase hex of a digest.
fn hex(digest: &[u8]) -> String {
let mut s = String::with_capacity(digest.len() * 2);
for b in digest {
use std::fmt::Write as _;
let _ = write!(s, "{b:02x}");
}
s
}
/// How many dists the catalog reads at once (lane HOL-RSS-3): bounded, not one per
/// core. Each worker holds one zip's central directory, one `composer.json` and one
/// piece of the dist it hashes (and, out of a compressed chunk, that chunk), so the
/// build's memory no longer grows with the core count or the dist size.
const CATALOG_WORKERS: usize = 4;
fn catalog_workers() -> usize {
std::thread::available_parallelism()
.map_or(1, |n| n.get())
.min(CATALOG_WORKERS)
}
impl RepositoryBackendTrait for ComposerRepoZnippy {
traits::znippy_read_only!(base);
/// A dist download in pieces (lane HOL-RSS part 2): `dist/{vendor}/{package}/{file}`, the
/// archive file the whole-body door reads.
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 ["dist", vendor, pkg, file] = parts.as_slice() else {
return Ok(None);
};
self.base.stream_get(
&format!("dist/{vendor}/{pkg}/{file}"),
"application/zip",
piece_bytes,
)
}
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!(
"composer repo znippy handle_http2_request.suburl={}",
suburl
);
let remainder = self.base.strip_repo(suburl);
let parts: Vec<&str> = remainder.split('/').filter(|s| !s.is_empty()).collect();
match parts.as_slice() {
["packages.json"] => Ok(ok(
"application/json",
p2::packages_json(self.base.name(), &self.available_packages()),
)),
// /p2/{vendor}/{package}.json — and `{package}~dev.json`, which
// Composer asks for beside every stable one.
["p2", vendor, pkg_json] => {
// 1. Passthrough: a curated archive's own metadata always wins.
// PROBED with `contains`, not with a speculative
// `extract_file`: on a synthesizing archive the passthrough
// key is absent on every single p2 request, and an
// extract-and-discard would decompress nothing but still
// cost an archive lookup and an Err allocation per request.
let archive_path = format!("p2/{}/{}", vendor, pkg_json);
let curated = self
.base
.reader()
.is_some_and(|r| r.contains(&archive_path));
if curated {
if let Ok(data) = self.get_file(&archive_path) {
return Ok(ok("application/json", data));
}
}
// 2. Synthesis, from the dists present. This is the arm that
// was missing: without it every `composer install` 404s.
let Some(stem) = pkg_json.strip_suffix(".json") else {
return Ok(not_found("Package metadata not found"));
};
let (pkg, dev) = match stem.strip_suffix("~dev") {
Some(p) => (p, true),
None => (stem, false),
};
let name = format!("{vendor}/{pkg}");
match self.catalog().get(&name) {
Some(versions) => Ok(ok(
"application/json",
p2::p2_document(&self.base_url, self.base.name(), &name, versions, dev),
)),
None => Ok(not_found("Package metadata not found")),
}
}
// /dist/{vendor}/{package}/{ref}.zip
["dist", vendor, pkg, file] => {
let archive_path = format!("dist/{}/{}/{}", vendor, pkg, file);
match self.get_file(&archive_path) {
Ok(data) => Ok(ok("application/zip", data)),
Err(_) => Ok(not_found("Dist not found in archive")),
}
}
_ => Ok(not_found("Not found")),
}
}
fn format(&self) -> ArtifactFormat {
ArtifactFormat::Composer
}
fn fetch(&self, id: &ArtifactId) -> anyhow::Result<Option<traits::Bytes>> {
// namespace = vendor, name = package
let vendor = id.namespace.as_deref().unwrap_or("");
let path = format!("dist/{}/{}/{}.zip", vendor, id.name, id.version);
match self.get_file(&path) {
Ok(data) => Ok(Some(traits::Bytes::from(data))),
Err(_) => Ok(None),
}
}
/// Package coordinates from the archive's `dist/{vendor}/{package}/{ref}.zip`
/// entries.
///
/// Was absent, so `list()` fell through to the trait default and reported an
/// empty Vec — which `/metrics` and the front page cannot tell apart from a
/// repository that really holds nothing. The p2 metadata entries are
/// deliberately skipped: they describe artifacts, they are not artifacts.
fn list(
&self,
name_filter: Option<&str>,
limit: usize,
) -> anyhow::Result<Vec<traits::ArtifactEntry>> {
let Some(reader) = self.base.reader() else {
return Ok(Vec::new());
};
// ONE index read for the whole listing; every token after it is memory.
let tokens = self.base.tokens().open();
traits::list_archive_entries(
reader.as_ref(),
tokens.as_ref(),
name_filter,
limit,
"application/zip",
|path| {
let rest = path.strip_prefix("dist/")?;
let mut seg = rest.split('/');
let vendor = seg.next()?;
let pkg = seg.next()?;
let file = seg.next()?;
if seg.next().is_some() {
return None;
}
let reference = file.strip_suffix(".zip")?;
if vendor.is_empty() || pkg.is_empty() || reference.is_empty() {
return None;
}
Some(ArtifactId {
namespace: Some(vendor.to_string()),
name: pkg.to_string(),
version: reference.to_string(),
})
},
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use traits::test_support::{CountingReader, MemReader};
#[cfg(feature = "testmatrix")]
use traits::test_support::fstatus;
#[test]
fn test_new() {
let repo = ComposerRepoZnippy::new("composer-test".to_string());
assert_eq!(repo.name(), "composer-test");
assert!(!repo.is_writable());
#[cfg(feature = "testmatrix")]
fstatus(
"znippy-composer",
"new_name_and_readonly",
repo.name() == "composer-test" && !repo.is_writable(),
&format!("name={} writable={}", repo.name(), repo.is_writable()),
);
}
#[test]
fn test_format() {
let f = ComposerRepoZnippy::new("x".into()).format();
assert_eq!(f, ArtifactFormat::Composer);
#[cfg(feature = "testmatrix")]
fstatus(
"znippy-composer",
"format_is_composer",
f == ArtifactFormat::Composer,
&format!("format = {f:?}"),
);
}
#[test]
fn serves_injected_archive() {
// Known dist artifact: a (fake) Composer dist zip, stored at the exact
// key fetch()/get_file() build → dist/{vendor}/{package}/{ref}.zip.
let zip_bytes: Vec<u8> = b"PK\x03\x04injected-composer-dist-zip".to_vec();
// Per-package metadata, served verbatim from p2/{vendor}/{package}.json.
let p2_json: Vec<u8> = br#"{"packages":{"acme/widget":[]}}"#.to_vec();
let reader = MemReader::new([
("dist/acme/widget/1.0.0.zip", zip_bytes.clone()),
("p2/acme/widget.json", p2_json.clone()),
]);
let repo = ComposerRepoZnippy::with_reader("composer-test".to_string(), reader);
// --- fetch(): hit returns the exact injected zip bytes ---
let id = ArtifactId {
namespace: Some("acme".to_string()),
name: "widget".to_string(),
version: "1.0.0".to_string(),
};
let fetched = repo.fetch(&id).expect("fetch ok");
assert_eq!(fetched.as_deref(), Some(zip_bytes.as_slice()));
// --- fetch(): miss returns None ---
let miss_id = ArtifactId {
namespace: Some("acme".to_string()),
name: "widget".to_string(),
version: "9.9.9".to_string(),
};
let fetched_miss = repo.fetch(&miss_id).expect("fetch ok");
assert_eq!(fetched_miss, None);
// --- handle_http2_request: dist hit → 200 + application/zip + bytes ---
let (dist_status, dist_headers, dist_body) = repo
.handle_http2_request("GET", "/composer-test/dist/acme/widget/1.0.0.zip", b"")
.expect("dist request ok");
let dist_ct = dist_headers
.iter()
.find(|(k, _)| k == "Content-Type")
.map(|(_, v)| v.as_str());
assert_eq!(dist_status, 200);
assert_eq!(dist_ct, Some("application/zip"));
assert_eq!(dist_body, zip_bytes);
// --- handle_http2_request: dist miss → 404 ---
let (dist_miss_status, _, _) = repo
.handle_http2_request("GET", "/composer-test/dist/acme/widget/9.9.9.zip", b"")
.expect("dist miss request ok");
assert_eq!(dist_miss_status, 404);
// --- handle_http2_request: p2 metadata hit → 200 + JSON + injected name ---
let (p2_status, p2_headers, p2_body) = repo
.handle_http2_request("GET", "/composer-test/p2/acme/widget.json", b"")
.expect("p2 request ok");
let p2_ct = p2_headers
.iter()
.find(|(k, _)| k == "Content-Type")
.map(|(_, v)| v.as_str());
assert_eq!(p2_status, 200);
assert_eq!(p2_ct, Some("application/json"));
assert_eq!(p2_body, p2_json);
assert!(String::from_utf8_lossy(&p2_body).contains("acme/widget"));
// --- handle_http2_request: packages.json root → 200 + JSON ---
let (root_status, root_headers, root_body) = repo
.handle_http2_request("GET", "/composer-test/packages.json", b"")
.expect("packages.json request ok");
let root_ct = root_headers
.iter()
.find(|(k, _)| k == "Content-Type")
.map(|(_, v)| v.as_str());
let root_str = String::from_utf8_lossy(&root_body);
assert_eq!(root_status, 200);
assert_eq!(root_ct, Some("application/json"));
assert!(root_str.contains("/composer-test/p2/%package%.json"));
#[cfg(feature = "testmatrix")]
{
fstatus(
"znippy-composer",
"fetch_serves_injected_dist",
fetched.as_deref() == Some(zip_bytes.as_slice()) && fetched_miss.is_none(),
&format!(
"fetch hit_len={:?} miss={:?}",
fetched.as_ref().map(|b| b.len()),
fetched_miss
),
);
fstatus(
"znippy-composer",
"http_dist_route_serves_zip",
dist_status == 200
&& dist_ct == Some("application/zip")
&& dist_body == zip_bytes
&& dist_miss_status == 404,
&format!(
"hit={} ct={:?} body_match={} miss={}",
dist_status,
dist_ct,
dist_body == zip_bytes,
dist_miss_status
),
);
fstatus(
"znippy-composer",
"http_p2_route_serves_metadata",
p2_status == 200
&& p2_ct == Some("application/json")
&& String::from_utf8_lossy(&p2_body).contains("acme/widget"),
&format!("status={} ct={:?}", p2_status, p2_ct),
);
fstatus(
"znippy-composer",
"http_packages_json_root",
root_status == 200
&& root_ct == Some("application/json")
&& root_str.contains("/composer-test/p2/%package%.json"),
&format!("status={} ct={:?}", root_status, root_ct),
);
}
}
#[test]
fn list_reports_dists_not_a_false_zero() {
let reader = MemReader::new([
("dist/acme/widget/1.0.0.zip", b"PK-one".to_vec()),
("dist/acme/widget/2.0.0.zip", b"PK-two-longer".to_vec()),
("dist/other/thing/1.0.0.zip", b"PK".to_vec()),
// metadata describes artifacts; it is not one
("p2/acme/widget.json", br#"{"packages":{}}"#.to_vec()),
]);
let repo = ComposerRepoZnippy::with_reader("composer-test".to_string(), reader);
let all = repo.list(None, 0).expect("list ok");
assert_eq!(all.len(), 3, "three dists, p2 metadata excluded");
for e in &all {
assert!(
repo.fetch(&e.id).expect("fetch ok").is_some(),
"unfetchable: {:?}",
e.id
);
}
let widget = repo.list(Some("widget"), 0).expect("list ok");
assert_eq!(widget.len(), 2);
assert_eq!(widget[0].id.namespace.as_deref(), Some("acme"));
assert_eq!(widget[0].content_type, "application/zip");
assert!(widget.iter().any(|e| e.size_bytes == 6), "real size, not 0");
assert_eq!(
repo.list(None, 2).expect("list ok").len(),
2,
"limit honoured"
);
assert!(ComposerRepoZnippy::new("empty".into())
.list(None, 0)
.unwrap()
.is_empty());
#[cfg(feature = "testmatrix")]
fstatus(
"znippy-composer",
"list_reports_dists",
all.len() == 3 && widget.len() == 2,
&format!("all={} filtered={}", all.len(), widget.len()),
);
}
/// The Archive tab was told there was nothing to browse on a repository
/// that was serving dists: the archive verbs
/// were left at the trait default. The same false zero
/// as `list()`, on three more verbs.
#[test]
fn the_archive_verbs_report_the_real_archive() {
let reader = MemReader::new([
("dist/acme/widget/1.0.0.zip", b"PK-one".to_vec()),
("dist/acme/widget/2.0.0.zip", b"PK-two".to_vec()),
("p2/acme/widget.json", br#"{"packages":{}}"#.to_vec()),
]);
let repo = ComposerRepoZnippy::with_reader("composer-test".to_string(), reader);
assert!(repo.caps().archive, "a loaded reader IS an archive");
assert_eq!(repo.archive().unwrap().paths(None).len(), 3);
assert_eq!(
repo.archive().unwrap().paths(Some("dist/")).len(),
2,
"prefix narrows"
);
let info = repo.archive().unwrap().info();
assert_eq!(info.file_count, 3);
assert_eq!(
info.total_uncompressed_bytes,
6 + 6 + 15,
"real bytes, not zero"
);
assert_eq!(info.archive_path, "composer-test");
let empty = ComposerRepoZnippy::new("empty".into());
assert!(!empty.caps().archive);
assert!(traits::is_unsupported(&empty.archive().unwrap_err()));
}
// ── ?23: the metadata this backend had no synthesiser for ────────────────
/// Build a real Composer dist: a zip whose root holds the package's own
/// `composer.json`, exactly as `composer archive` lays one out.
fn dist(name: &str, requires: &str) -> Vec<u8> {
let manifest = format!(
r#"{{"name":"{name}","description":"a package","type":"library",
"license":["MIT"],"require":{{{requires}}},
"autoload":{{"psr-4":{{"Acme\\Widget\\":"src/"}}}}}}"#
);
zip_entry::fixture::zip(&[
("composer.json", manifest.as_bytes()),
("src/Widget.php", b"<?php class Widget {}".as_slice()),
])
}
fn json_of(repo: &ComposerRepoZnippy, url: &str) -> (u16, serde_json::Value) {
let (status, _h, body) = repo.handle_http2_request("GET", url, b"").expect("request");
let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default();
(status, v)
}
/// ★ **?23, the composer half.** Before: the archive had to already carry
/// `p2/{vendor}/{package}.json`, and from a bare set of dist zips every
/// `composer install` 404ed on p2 — the repository served bytes nobody
/// could find. After: the p2 document is synthesized from the
/// `composer.json` inside each dist.
///
/// This walks the exact sequence `composer install` walks.
#[test]
fn a_composer_install_now_resolves_from_bare_dists() {
let reader = MemReader::new([
(
"dist/acme/widget/1.0.0.zip",
dist("acme/widget", r#""php":">=8.1""#),
),
(
"dist/acme/widget/1.2.0.zip",
dist("acme/widget", r#""php":">=8.1","acme/other":"^2.0""#),
),
("dist/acme/widget/main.zip", dist("acme/widget", "")),
("dist/acme/other/2.1.0.zip", dist("acme/other", "")),
]);
let repo = ComposerRepoZnippy::with_reader("composer".into(), reader)
.with_base_url("https://holger.example.com/".into());
// 1. The root. `metadata-url` is unchanged — it was always right — and
// the catalog is now advertised beside it.
let (status, root) = json_of(&repo, "/composer/packages.json");
assert_eq!(status, 200);
assert_eq!(root["metadata-url"], "/composer/p2/%package%.json");
let avail: Vec<&str> = root["available-packages"]
.as_array()
.expect("catalog")
.iter()
.map(|v| v.as_str().unwrap_or(""))
.collect();
assert_eq!(avail, vec!["acme/other", "acme/widget"]);
// 2. Follow it. THE request that used to 404.
let (status, meta) = json_of(&repo, "/composer/p2/acme/widget.json");
assert_eq!(status, 200, "a bare dist archive must answer p2, not 404");
let items = meta["packages"]["acme/widget"]
.as_array()
.expect("versions");
assert_eq!(
items.len(),
2,
"two stable versions; the branch is not one of them"
);
assert_eq!(items[0]["version"], "1.2.0", "newest first");
assert_eq!(items[0]["version_normalized"], "1.2.0.0");
// 3. The requirements — the whole reason a client reads this document,
// and what a passthrough archive without p2 could never supply.
assert_eq!(items[0]["require"]["acme/other"], "^2.0");
assert_eq!(items[0]["autoload"]["psr-4"]["Acme\\Widget\\"], "src/");
// 4. The download. `dist.url` must be absolute and must resolve
// against this backend's own dist arm.
let url = items[0]["dist"]["url"].as_str().expect("dist url");
assert_eq!(
url,
"https://holger.example.com/composer/dist/acme/widget/1.2.0.zip"
);
let suburl = url.trim_start_matches("https://holger.example.com");
let (dstatus, dheaders, dbody) = repo
.handle_http2_request("GET", suburl, b"")
.expect("download");
assert_eq!(
dstatus, 200,
"the URL the p2 document advertised really serves bytes"
);
assert!(dheaders
.iter()
.any(|(k, v)| k == "Content-Type" && v == "application/zip"));
assert!(dbody.starts_with(b"PK"));
// 5. …and the shasum in the document is the one Composer will check
// the downloaded bytes against. sha1, because that is what
// Composer's own verifier uses.
let shasum = items[0]["dist"]["shasum"].as_str().expect("shasum");
assert_eq!(shasum.len(), 40, "sha1 hex, not sha256: {shasum}");
assert_eq!(
shasum,
sha1_hex(&dbody),
"the digest is OF the bytes that are served"
);
// 6. The `~dev` document Composer asks for beside every stable one.
let (status, dev) = json_of(&repo, "/composer/p2/acme/widget~dev.json");
assert_eq!(status, 200);
assert_eq!(dev["packages"]["acme/widget"][0]["version"], "dev-main");
// A package that is not here is a 404, not an empty document a client
// would read as "exists, has no versions".
assert_eq!(json_of(&repo, "/composer/p2/acme/ghost.json").0, 404);
#[cfg(feature = "testmatrix")]
fstatus(
"znippy-composer",
"p2_synthesized_from_bare_dists",
status == 200,
"p2 built from composer.json inside each dist: require, version_normalized, \
absolute dist url that downloads, sha1 shasum over the served bytes, ~dev split",
);
}
/// A curated archive that packed REAL Packagist metadata keeps serving it
/// verbatim. Synthesis is the fallback, never a replacement: the upstream
/// document carries `time`, `source` and upstream digests no synthesis can
/// reconstruct.
#[test]
fn curated_p2_metadata_still_wins_over_synthesis() {
let curated = br#"{"packages":{"acme/widget":[{"version":"0.0.1","curated":true}]}}"#;
let reader = MemReader::new([
("dist/acme/widget/1.0.0.zip", dist("acme/widget", "")),
("p2/acme/widget.json", curated.to_vec()),
]);
let repo = ComposerRepoZnippy::with_reader("composer".into(), reader);
let (status, doc) = json_of(&repo, "/composer/p2/acme/widget.json");
assert_eq!(status, 200);
assert_eq!(doc["packages"]["acme/widget"][0]["curated"], true);
assert_eq!(
doc["packages"]["acme/widget"][0]["version"], "0.0.1",
"the archive's own document is served verbatim, not the synthesized 1.0.0"
);
}
/// ★ **The memo, counted.** A p2 document is an answer ABOUT the dists, so
/// building one opens every dist zip. `composer install` asks for one p2
/// document per required package, so per request that is O(archive) every
/// time. Measured: four dists, twelve p2 requests. Without the memo that is
/// 48 member reads; with it, 4 — and the dist arm pays 1 for its own bytes.
#[test]
fn the_composer_json_catalog_is_built_once_not_per_request() {
let mem = MemReader::new([
("dist/acme/widget/1.0.0.zip", dist("acme/widget", "")),
("dist/acme/widget/1.2.0.zip", dist("acme/widget", "")),
("dist/acme/other/1.0.0.zip", dist("acme/other", "")),
("dist/acme/other/2.0.0.zip", dist("acme/other", "")),
]);
let counting = CountingReader::new(mem);
let repo = ComposerRepoZnippy::with_reader("composer".into(), counting.clone());
assert_eq!(
counting.member_reads(),
0,
"nothing is opened before the first request"
);
// The root is built from PATHS alone: it must not pay for the catalog.
assert_eq!(json_of(&repo, "/composer/packages.json").0, 200);
assert_eq!(
counting.member_reads(),
0,
"packages.json lists the catalog from the file table, opening no dist"
);
for _ in 0..6 {
assert_eq!(json_of(&repo, "/composer/p2/acme/widget.json").0, 200);
assert_eq!(json_of(&repo, "/composer/p2/acme/other.json").0, 200);
}
assert_eq!(
counting.member_reads(),
4,
"twelve p2 requests over a 4-dist archive must cost FOUR member reads \
(one per dist, once), not 48"
);
#[cfg(feature = "testmatrix")]
fstatus(
"znippy-composer",
"composer_json_catalog_built_once",
counting.member_reads() == 4,
"12 p2 requests over 4 dists -> 4 member reads (was 48); packages.json pays 0",
);
}
/// One hostile dist must not make the whole repository unresolvable, and
/// its bytes must stay downloadable.
#[test]
fn a_dist_that_is_not_a_readable_zip_is_skipped_not_fatal() {
let reader = MemReader::new([
("dist/acme/widget/1.0.0.zip", dist("acme/widget", "")),
(
"dist/acme/junk/1.0.0.zip",
b"PK\x03\x04 not really a zip".to_vec(),
),
// A zip with no composer.json at all.
(
"dist/acme/empty/1.0.0.zip",
zip_entry::fixture::zip(&[("src/x.php", b"<?php".as_slice())]),
),
]);
let repo = ComposerRepoZnippy::with_reader("composer".into(), reader);
assert_eq!(json_of(&repo, "/composer/p2/acme/widget.json").0, 200);
assert_eq!(json_of(&repo, "/composer/p2/acme/junk.json").0, 404);
assert_eq!(json_of(&repo, "/composer/p2/acme/empty.json").0, 404);
let (s, _h, b) = repo
.handle_http2_request("GET", "/composer/dist/acme/junk/1.0.0.zip", b"")
.expect("request");
assert_eq!(
s, 200,
"a manifest holger cannot parse does not hide the bytes"
);
assert_eq!(b, b"PK\x03\x04 not really a zip".to_vec());
}
/// 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(&ComposerRepoZnippy::new("r".into())).unwrap();
traits::backend::conformance::check(&ComposerRepoZnippy::with_reader(
"r".into(),
std::sync::Arc::new(One),
))
.unwrap();
}
}