Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

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"
        );
    }
}