server/repository/znippy-go/src/lib.rs
//! Go repository backend: serves the GOPROXY protocol out of a read-only znippy
//! archive. Like the other `znippy-<eco>` backends it synthesizes the native
//! index from the files the archive already holds.
//!
//! Archive layout mirrors the on-disk module-proxy cache: `{module}/@v/{ver}.{info,mod,zip}`.
//! `fetch` rebuilds the module path from `ArtifactId` (namespace = module
//! prefix, name = last segment) and only ever returns the `.zip`.
//!
//! Gotcha: `@v/list` is built by scanning `.zip` entries alone, so a version
//! carrying only `.info`/`.mod` (no zip) is invisible to `list` even though its
//! metadata is still directly fetchable.
use std::path::PathBuf;
use std::sync::{Arc, OnceLock};
use anyhow::Result;
use traits::{ArtifactFormat, ArtifactId, RepositoryBackendTrait, ZnippyBase};
use znippy_common::ZnippyReader;
/// znippy-backed Go module proxy implementing the GOPROXY protocol.
///
/// * `GET /{repo}/{module}/@v/list` → newline-separated versions
/// * `GET /{repo}/{module}/@v/{ver}.info` → `{ "Version": …, "Time": … }`
/// * `GET /{repo}/{module}/@v/{ver}.mod` → the `go.mod`
/// * `GET /{repo}/{module}/@v/{ver}.zip` → module zip
pub struct GoRepoZnippy {
base: ZnippyBase,
/// The archive's file table, taken ONCE: `list_files()` allocates one
/// String per file per call, and the index endpoint scans it per request.
/// The backend is read-only and holds its reader for the process lifetime,
/// so the table cannot change underneath.
paths: OnceLock<Vec<String>>,
/// Each module's `@v/list`, built once (lane HOL-PERF-2): the same bytes after.
lists: traits::DocMemo,
}
impl GoRepoZnippy {
pub fn new(name: String) -> Self {
Self {
base: ZnippyBase::new(name),
paths: OnceLock::new(),
lists: traits::DocMemo::default(),
}
}
pub fn with_archive(name: String, archive_path: PathBuf) -> Result<Self> {
Ok(Self {
base: ZnippyBase::with_archive(name, archive_path)?,
paths: OnceLock::new(),
lists: traits::DocMemo::default(),
})
}
pub fn with_reader(name: String, reader: Arc<dyn ZnippyReader>) -> Self {
Self {
base: ZnippyBase::with_reader(name, reader),
paths: OnceLock::new(),
lists: traits::DocMemo::default(),
}
}
/// 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)
}
fn version_list(&self, module: &str) -> Vec<u8> {
let prefix = format!("{}/@v/", module);
let mut versions: Vec<&str> = self
.paths()
.iter()
.filter_map(|f| f.strip_prefix(&prefix)?.strip_suffix(".zip"))
.collect();
versions.sort_unstable();
versions.dedup();
versions.join("\n").into_bytes()
}
}
/// The `Content-Type` a `/@v/` document or zip is served as.
fn go_content_type(op: &str) -> &'static str {
if op.ends_with(".info") {
"application/json"
} else if op.ends_with(".mod") {
"text/plain"
} else {
"application/zip"
}
}
impl RepositoryBackendTrait for GoRepoZnippy {
traits::znippy_read_only!(base);
/// A module download in pieces (lane HOL-RSS part 2): `{module}/@v/{op}`, the archive
/// file the whole-body door reads.
fn stream_get(
&self,
suburl: &str,
piece_bytes: usize,
) -> anyhow::Result<Option<traits::StreamedGet>> {
let Some((module, op)) = self.base.strip_repo(suburl).split_once("/@v/") else {
return Ok(None);
};
if op == "list" {
return Ok(None);
}
self.base.stream_get(
&format!("{module}/@v/{op}"),
go_content_type(op),
piece_bytes,
)
}
fn format(&self) -> ArtifactFormat {
ArtifactFormat::Go
}
fn handle_http2_request(
&self,
method: &str,
suburl: &str,
body: &[u8],
) -> Result<traits::HttpReply> {
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],
) -> Result<traits::HttpBytesReply> {
use traits::znippy_base::{not_found_bytes, ok_bytes};
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);
}
// Split the module path (everything before "/@v/") from the operation.
let Some((module, op)) = self.base.strip_repo(suburl).split_once("/@v/") else {
return Ok(not_found_bytes("Not found"));
};
if op == "list" {
// Built once per module that has versions; an unknown one lists nothing, unkept.
let list = self
.lists
.get_or(module, || {
Some(self.version_list(module))
.filter(|l| !l.is_empty())
.map(traits::Bytes::from)
})
.unwrap_or_default();
return Ok(ok_bytes("text/plain", list));
}
let content_type = go_content_type(op);
Ok(match self.get_file(&format!("{module}/@v/{op}")) {
Ok(data) => ok_bytes(content_type, traits::Bytes::from(data)),
Err(_) => not_found_bytes("Not found in archive"),
})
}
fn fetch(&self, id: &ArtifactId) -> Result<Option<traits::Bytes>> {
// module path lives in namespace, e.g. namespace="github.com/foo", name="bar"
let module = match &id.namespace {
Some(ns) => format!("{}/{}", ns, id.name),
None => id.name.clone(),
};
Ok(self
.get_file(&format!("{}/@v/{}.zip", module, id.version))
.ok()
.map(traits::Bytes::from))
}
/// Module coordinates from the archive's `{module}/@v/{ver}.zip` entries
/// (`list_entries` stamps the change tokens). Anchored on `.zip` for the
/// same reason `@v/list` is.
fn list(&self, name_filter: Option<&str>, limit: usize) -> Result<Vec<traits::ArtifactEntry>> {
self.base
.list_entries(name_filter, limit, "application/zip", |path| {
let (module, file) = path.split_once("/@v/")?;
let version = file.strip_suffix(".zip")?;
if module.is_empty() || version.is_empty() {
return None;
}
// `fetch` rebuilds the module path as `{namespace}/{name}`, so the
// split here has to be its exact inverse.
let (namespace, name) = match module.rsplit_once('/') {
Some((ns, n)) => (Some(ns.to_string()), n.to_string()),
None => (None, module.to_string()),
};
Some(ArtifactId {
namespace,
name,
version: version.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 = GoRepoZnippy::new("go-test".to_string());
assert_eq!(repo.name(), "go-test");
assert!(!repo.is_writable());
#[cfg(feature = "testmatrix")]
fstatus(
"znippy-go",
"new_name_and_readonly",
repo.name() == "go-test" && !repo.is_writable(),
&format!("name={} writable={}", repo.name(), repo.is_writable()),
);
}
#[test]
fn test_format() {
let f = GoRepoZnippy::new("x".into()).format();
assert_eq!(f, ArtifactFormat::Go);
#[cfg(feature = "testmatrix")]
fstatus(
"znippy-go",
"format_is_go",
f == ArtifactFormat::Go,
&format!("format = {f:?}"),
);
}
#[test]
fn serves_injected_archive() {
// Archive layout is `{module}/@v/{ver}.zip` where module = namespace/name.
let module = "github.com/foo/bar";
let version = "v1.2.3";
let key = format!("{module}/@v/{version}.zip");
let payload: Vec<u8> = b"PK\x03\x04 fake go module zip payload".to_vec();
let reader = MemReader::new([(key.as_str(), payload.clone())]);
let repo = GoRepoZnippy::with_reader("go-test".to_string(), reader);
let id = ArtifactId {
namespace: Some("github.com/foo".to_string()),
name: "bar".to_string(),
version: version.to_string(),
};
// fetch() returns the injected bytes for the matching id.
let fetched = repo.fetch(&id).unwrap();
let fetch_ok = fetched.as_deref() == Some(payload.as_slice());
assert!(fetch_ok, "fetch should return the injected zip bytes");
#[cfg(feature = "testmatrix")]
fstatus(
"znippy-go",
"fetch_returns_injected_zip",
fetch_ok,
&format!(
"key={key} fetched_len={:?} expected_len={}",
fetched.as_ref().map(|d| d.len()),
payload.len()
),
);
// handle_http2_request() serves the same bytes with the zip Content-Type.
let url = "/go-test/github.com/foo/bar/@v/v1.2.3.zip";
let (status, headers, body) = repo.handle_http2_request("GET", url, b"").unwrap();
let ct = headers
.iter()
.find(|(k, _)| k == "Content-Type")
.map(|(_, v)| v.as_str());
let serve_ok = status == 200 && ct == Some("application/zip") && body == payload;
assert!(
serve_ok,
"serve should 200 with application/zip and the injected body"
);
#[cfg(feature = "testmatrix")]
fstatus(
"znippy-go",
"serve_returns_injected_zip",
serve_ok,
&format!(
"url={url} status={status} content_type={ct:?} body_len={} expected_len={}",
body.len(),
payload.len()
),
);
// Missing artifact: fetch() yields None.
let missing_id = ArtifactId {
namespace: Some("github.com/foo".to_string()),
name: "bar".to_string(),
version: "v9.9.9".to_string(),
};
let missing_fetch = repo.fetch(&missing_id).unwrap();
let fetch_miss_ok = missing_fetch.is_none();
assert!(
fetch_miss_ok,
"fetch of a missing version should return None"
);
#[cfg(feature = "testmatrix")]
fstatus(
"znippy-go",
"fetch_missing_returns_none",
fetch_miss_ok,
&format!("version=v9.9.9 fetched_is_none={fetch_miss_ok}"),
);
// Missing artifact: handle_http2_request() yields 404.
let miss_url = "/go-test/github.com/foo/bar/@v/v9.9.9.zip";
let (miss_status, _miss_headers, _miss_body) =
repo.handle_http2_request("GET", miss_url, b"").unwrap();
let serve_miss_ok = miss_status == 404;
assert!(serve_miss_ok, "serve of a missing version should be 404");
#[cfg(feature = "testmatrix")]
fstatus(
"znippy-go",
"serve_missing_returns_404",
serve_miss_ok,
&format!("url={miss_url} status={miss_status}"),
);
}
/// T25: the `@v/list` route returns the sorted versions newline-joined, and
/// the `.info`/`.mod` routes carry the right content types.
#[test]
fn version_list_info_and_mod_routes() {
let reader = MemReader::new([
("github.com/foo/bar/@v/v1.2.0.zip", b"zip-120".to_vec()),
("github.com/foo/bar/@v/v1.0.0.zip", b"zip-100".to_vec()),
(
"github.com/foo/bar/@v/v1.0.0.info",
br#"{"Version":"v1.0.0","Time":"2020-01-01T00:00:00Z"}"#.to_vec(),
),
(
"github.com/foo/bar/@v/v1.0.0.mod",
b"module github.com/foo/bar\n".to_vec(),
),
]);
let repo = GoRepoZnippy::with_reader("go-test".to_string(), reader);
let ct = |hs: &[(String, String)]| {
hs.iter()
.find(|(k, _)| k == "Content-Type")
.map(|(_, v)| v.clone())
};
// @v/list → sorted, newline-joined.
let (lst, _lh, lbody) = repo
.handle_http2_request("GET", "/go-test/github.com/foo/bar/@v/list", b"")
.unwrap();
let list = String::from_utf8(lbody).unwrap();
let list_ok = lst == 200 && list == "v1.0.0\nv1.2.0";
assert!(list_ok, "version list sorted: status={lst} body={list:?}");
// .info → application/json.
let (ist, ih, _ib) = repo
.handle_http2_request("GET", "/go-test/github.com/foo/bar/@v/v1.0.0.info", b"")
.unwrap();
let info_ok = ist == 200 && ct(&ih).as_deref() == Some("application/json");
assert!(
info_ok,
".info is application/json: status={ist} ct={:?}",
ct(&ih)
);
// .mod → text/plain.
let (mst, mh, _mb) = repo
.handle_http2_request("GET", "/go-test/github.com/foo/bar/@v/v1.0.0.mod", b"")
.unwrap();
let mod_ok = mst == 200 && ct(&mh).as_deref() == Some("text/plain");
assert!(mod_ok, ".mod is text/plain: status={mst} ct={:?}", ct(&mh));
#[cfg(feature = "testmatrix")]
fstatus(
"znippy-go",
"version_list_info_mod_routes",
list_ok && info_ok && mod_ok,
&format!("list_status={lst} info_status={ist} mod_status={mst}"),
);
}
#[test]
fn list_reports_module_versions_not_a_false_zero() {
let reader = MemReader::new([
(
"github.com/acme/widget/@v/v1.0.0.zip",
b"zipbytes-1".to_vec(),
),
(
"github.com/acme/widget/@v/v1.1.0.zip",
b"zipbytes-22".to_vec(),
),
// metadata only: no .zip, so not an artifact (same rule as @v/list)
("github.com/acme/widget/@v/v2.0.0.mod", b"module x".to_vec()),
("standalone/@v/v0.1.0.zip", b"z".to_vec()),
]);
let repo = GoRepoZnippy::with_reader("go-test".to_string(), reader);
let all = repo.list(None, 0).expect("list ok");
assert_eq!(all.len(), 3, "two widget zips + one single-segment module");
// fetch() must find every coordinate list() reported — the two path
// parses are inverses and a drift between them is invisible otherwise.
for e in &all {
assert!(
repo.fetch(&e.id).expect("fetch ok").is_some(),
"unfetchable: {:?}",
e.id
);
}
let widget: Vec<_> = repo.list(Some("widget"), 0).expect("list ok");
assert_eq!(widget.len(), 2);
assert_eq!(widget[0].id.namespace.as_deref(), Some("github.com/acme"));
assert_eq!(widget[0].content_type, "application/zip");
assert!(
widget.iter().any(|e| e.size_bytes == 11),
"real size, not 0"
);
assert_eq!(
repo.list(None, 1).expect("list ok").len(),
1,
"limit honoured"
);
// No reader at all is still an empty listing, not a panic.
assert!(GoRepoZnippy::new("empty".into())
.list(None, 0)
.unwrap()
.is_empty());
#[cfg(feature = "testmatrix")]
fstatus(
"znippy-go",
"list_reports_module_versions",
all.len() == 3 && widget.len() == 2,
&format!("all={} filtered={}", all.len(), widget.len()),
);
}
/// The archive file table is taken ONCE, not once per request.
///
/// Measured before the `paths` OnceLock landed: ten `@v/list` requests =
/// 10 full table reads. After: 1. On a 50 000-file archive that is 500 000
/// String allocations turned into 50 000, once. Safe because this backend
/// is read-only and holds its reader `Arc` for the process lifetime, so
/// the table cannot change under it.
#[test]
fn the_archive_file_table_is_read_once_not_per_request() {
let mem = MemReader::new([
("github.com/acme/widget/@v/v1.0.0.zip", b"z1".to_vec()),
("github.com/acme/widget/@v/v1.1.0.zip", b"z2".to_vec()),
]);
let counting = CountingReader::new(mem);
let repo = GoRepoZnippy::with_reader("go-test".to_string(), counting.clone());
assert_eq!(
counting.table_reads(),
0,
"nothing is read before the first request"
);
for _ in 0..10 {
let (status, _, body) = repo
.handle_http2_request("GET", "/go-test/github.com/acme/widget/@v/list", b"")
.expect("list request ok");
assert_eq!(status, 200);
assert_eq!(String::from_utf8_lossy(&body), "v1.0.0\nv1.1.0");
}
assert_eq!(
counting.table_reads(),
1,
"ten @v/list requests must cost ONE table read, not ten"
);
#[cfg(feature = "testmatrix")]
fstatus(
"znippy-go",
"archive_table_read_once_not_per_request",
counting.table_reads() == 1,
&format!(
"10 requests -> {} table reads (was 10)",
counting.table_reads()
),
);
}
/// The archive verbs report the real archive, and an empty backend says
/// it has none rather than handing out a zero.
#[test]
fn the_archive_verbs_report_the_real_archive() {
let reader = MemReader::new([
("github.com/acme/widget/@v/v1.0.0.zip", b"zip-1".to_vec()),
("github.com/acme/widget/@v/v1.0.0.mod", b"module x".to_vec()),
("standalone/@v/v0.1.0.zip", b"z".to_vec()),
]);
let repo = GoRepoZnippy::with_reader("go-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("github.com/")).len(),
2,
"prefix narrows"
);
let info = repo.archive().unwrap().info();
assert_eq!(info.file_count, 3);
assert_eq!(
info.total_uncompressed_bytes,
5 + 8 + 1,
"real bytes, not zero"
);
assert_eq!(info.archive_path, "go-test");
let empty = GoRepoZnippy::new("empty".into());
assert!(!empty.caps().archive);
assert!(traits::is_unsupported(&empty.archive().unwrap_err()));
}
#[test]
fn caps_match_methods() {
traits::backend::conformance::check(&GoRepoZnippy::new("r".into())).unwrap();
let loaded =
GoRepoZnippy::with_reader("r".into(), MemReader::new([("m/@v/v1.zip", b"z".to_vec())]));
traits::backend::conformance::check(&loaded).unwrap();
}
/// **`@v/list` is built once per module** (lane HOL-PERF-2, step 6): the same bytes
/// for a second request, no scan of the file table.
#[test]
fn a_modules_version_list_is_built_once() {
let repo = GoRepoZnippy::with_reader(
"g".into(),
MemReader::new([
("example.com/m/@v/v1.1.0.zip", b"z".to_vec()),
("example.com/m/@v/v1.0.0.zip", b"z".to_vec()),
(
"example.com/m/@v/v1.0.0.mod",
b"module example.com/m\n".to_vec(),
),
]),
);
let a = repo
.handle_http2_request_bytes("GET", "/g/example.com/m/@v/list", &[])
.unwrap();
let b = repo
.handle_http2_request_bytes("GET", "/g/example.com/m/@v/list", &[])
.unwrap();
assert_eq!(&a.2[..], b"v1.0.0\nv1.1.0");
assert_eq!(a.2.as_ptr(), b.2.as_ptr(), "the same bytes");
assert_eq!(
repo.handle_http2_request("GET", "/g/example.com/other/@v/list", &[])
.unwrap()
.0,
200,
"an unknown module lists nothing, as before"
);
}
}