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-maven/tests/plugin_read_view.rs

//! Holger maven backend serve test, through a REAL compressed archive built with
//! the maven plugin (so the GAV columns are present). Proves the backend resolves
//! a classifier / `-SNAPSHOT` GAV via the typed view — the exact cases the old
//! filename-regex (`rsplit_once('-')`, `{a}-{v}.jar` path build) mis-split.

use std::fs;
use std::path::Path;

use holger_znippy_maven_repository::MavenRepoZnippy;
use traits::{ArtifactId, RepositoryBackendTrait};
use znippy_common::plugin::PluginRegistry;
use znippy_compress::compress_dir;
use znippy_plugin_maven::NativeMavenPlugin;

// Minimal stored-entry ZIP/JAR carrying a pom.xml at the maven path.
fn crc32(data: &[u8]) -> u32 {
    let mut crc = 0xFFFF_FFFFu32;
    for &b in data {
        crc ^= b as u32;
        for _ in 0..8 {
            crc = if crc & 1 != 0 {
                (crc >> 1) ^ 0xEDB8_8320
            } else {
                crc >> 1
            };
        }
    }
    !crc
}

fn build_zip_stored(name: &str, data: &[u8]) -> Vec<u8> {
    let nb = name.as_bytes();
    let crc = crc32(data);
    let size = data.len() as u32;
    let mut out = Vec::new();
    let local = out.len() as u32;
    out.extend_from_slice(b"PK\x03\x04");
    out.extend_from_slice(&20u16.to_le_bytes());
    for _ in 0..3 {
        out.extend_from_slice(&0u16.to_le_bytes());
    }
    out.extend_from_slice(&0u16.to_le_bytes());
    out.extend_from_slice(&crc.to_le_bytes());
    out.extend_from_slice(&size.to_le_bytes());
    out.extend_from_slice(&size.to_le_bytes());
    out.extend_from_slice(&(nb.len() as u16).to_le_bytes());
    out.extend_from_slice(&0u16.to_le_bytes());
    out.extend_from_slice(nb);
    out.extend_from_slice(data);
    let cd = out.len() as u32;
    out.extend_from_slice(b"PK\x01\x02");
    out.extend_from_slice(&20u16.to_le_bytes());
    out.extend_from_slice(&20u16.to_le_bytes());
    for _ in 0..4 {
        out.extend_from_slice(&0u16.to_le_bytes());
    }
    out.extend_from_slice(&crc.to_le_bytes());
    out.extend_from_slice(&size.to_le_bytes());
    out.extend_from_slice(&size.to_le_bytes());
    out.extend_from_slice(&(nb.len() as u16).to_le_bytes());
    for _ in 0..3 {
        out.extend_from_slice(&0u16.to_le_bytes());
    }
    out.extend_from_slice(&0u16.to_le_bytes());
    out.extend_from_slice(&0u32.to_le_bytes());
    out.extend_from_slice(&local.to_le_bytes());
    out.extend_from_slice(nb);
    let cd_size = out.len() as u32 - cd;
    out.extend_from_slice(b"PK\x05\x06");
    out.extend_from_slice(&0u16.to_le_bytes());
    out.extend_from_slice(&0u16.to_le_bytes());
    out.extend_from_slice(&1u16.to_le_bytes());
    out.extend_from_slice(&1u16.to_le_bytes());
    out.extend_from_slice(&cd_size.to_le_bytes());
    out.extend_from_slice(&cd.to_le_bytes());
    out.extend_from_slice(&0u16.to_le_bytes());
    out
}

fn make_jar(group: &str, artifact: &str, version: &str) -> Vec<u8> {
    let pom = format!(
        "<?xml version=\"1.0\"?><project>\
         <groupId>{group}</groupId><artifactId>{artifact}</artifactId>\
         <version>{version}</version><packaging>jar</packaging></project>"
    );
    build_zip_stored(
        &format!("META-INF/maven/{group}/{artifact}/pom.xml"),
        pom.as_bytes(),
    )
}

fn build_archive(dir: &Path, out: &Path) {
    compress_dir(
        &dir.to_path_buf(),
        &out.to_path_buf(),
        false,
        Some(&PluginRegistry::with_plugin(Box::new(NativeMavenPlugin))),
        None,
        None,
    )
    .expect("compress_dir with maven plugin");
}

#[test]
fn maven_backend_serves_snapshot_and_classifier_via_view() {
    let src = tempfile::tempdir().unwrap();
    let out = tempfile::tempdir().unwrap();

    let gid = "org.apache.commons";
    let aid = "commons-lang3";

    // -SNAPSHOT primary jar (old `{a}-{v}.jar` path build + `rsplit_once('-')`
    // would mis-derive version="SNAPSHOT").
    let snap = make_jar(gid, aid, "3.20.0-SNAPSHOT");
    let snap_dir = src.path().join(format!("{gid}/{aid}/3.20.0-SNAPSHOT"));
    fs::create_dir_all(&snap_dir).unwrap();
    fs::write(snap_dir.join(format!("{aid}-3.20.0-SNAPSHOT.jar")), &snap).unwrap();

    // release + a -sources classifier (old regex would split the classifier wrong).
    let rel = make_jar(gid, aid, "3.12.0");
    let rel_dir = src.path().join(format!("{gid}/{aid}/3.12.0"));
    fs::create_dir_all(&rel_dir).unwrap();
    fs::write(rel_dir.join(format!("{aid}-3.12.0.jar")), &rel).unwrap();
    fs::write(rel_dir.join(format!("{aid}-3.12.0-sources.jar")), &rel).unwrap();

    let archive = out.path().join("maven.znippy");
    build_archive(src.path(), &archive);

    let repo = MavenRepoZnippy::with_archive("maven".into(), archive).unwrap();

    // fetch() resolves the -SNAPSHOT GAV through the view → exact bytes.
    let id = ArtifactId {
        namespace: Some(gid.into()),
        name: aid.into(),
        version: "3.20.0-SNAPSHOT".into(),
    };
    let got = repo.fetch(&id).unwrap();
    assert_eq!(
        got.as_deref(),
        Some(snap.as_slice()),
        "SNAPSHOT GAV must serve the right jar"
    );

    // HTTP wire path for the -sources classifier resolves via the view.
    let url = format!("/maven/{gid}/{aid}/3.12.0/{aid}-3.12.0-sources.jar");
    let (status, _h, body) = repo.handle_http2_request("GET", &url, &[]).unwrap();
    assert_eq!(status, 200, "classifier wire path must serve 200");
    assert_eq!(
        body, rel,
        "classifier serve must return the sources jar bytes"
    );

    // HTTP wire path for the -SNAPSHOT primary jar resolves too.
    let snap_url = format!("/maven/{gid}/{aid}/3.20.0-SNAPSHOT/{aid}-3.20.0-SNAPSHOT.jar");
    let (s, _h, b) = repo.handle_http2_request("GET", &snap_url, &[]).unwrap();
    assert_eq!(s, 200);
    assert_eq!(b, snap);

    // list() reports authoritative coords (group/artifact/version from columns),
    // with the classifier appended — no `rsplit_once('-')` mis-split.
    let listed = repo.list(None, 0).unwrap();
    assert!(
        listed
            .iter()
            .any(|e| e.id.name == aid && e.id.version == "3.20.0-SNAPSHOT"),
        "list must carry the authoritative SNAPSHOT version, got: {:?}",
        listed
            .iter()
            .map(|e| (&e.id.name, &e.id.version))
            .collect::<Vec<_>>()
    );
    assert!(
        listed.iter().any(|e| e.id.version == "3.12.0:sources"),
        "list must carry the classifier coord distinctly"
    );

    // Absent GAV → None (not a wrong file).
    let absent = ArtifactId {
        namespace: Some(gid.into()),
        name: aid.into(),
        version: "9.9.9".into(),
    };
    assert_eq!(repo.fetch(&absent).unwrap(), None);
}

fn sha1_hex(data: &[u8]) -> String {
    use sha1::{Digest, Sha1};
    let mut h = Sha1::new();
    h.update(data);
    h.finalize().iter().map(|b| format!("{b:02x}")).collect()
}

/// The Maven backend synthesizes `.sha1`/`.md5` sidecars over the SAME bytes the
/// underlying path serves (artifact AND synthesized `maven-metadata.xml`), so a
/// bare-`.jar` mirror still satisfies a checksum-requesting Maven client; and it
/// synthesizes version-level `-SNAPSHOT` metadata (`localCopy=true`).
#[test]
fn maven_backend_synthesizes_checksums_and_snapshot_metadata() {
    let src = tempfile::tempdir().unwrap();
    let out = tempfile::tempdir().unwrap();

    let gid = "org.apache.commons";
    let aid = "commons-lang3";

    let snap = make_jar(gid, aid, "3.20.0-SNAPSHOT");
    let snap_dir = src.path().join(format!("{gid}/{aid}/3.20.0-SNAPSHOT"));
    fs::create_dir_all(&snap_dir).unwrap();
    fs::write(snap_dir.join(format!("{aid}-3.20.0-SNAPSHOT.jar")), &snap).unwrap();

    let rel = make_jar(gid, aid, "3.12.0");
    let rel_dir = src.path().join(format!("{gid}/{aid}/3.12.0"));
    fs::create_dir_all(&rel_dir).unwrap();
    fs::write(rel_dir.join(format!("{aid}-3.12.0.jar")), &rel).unwrap();

    let archive = out.path().join("maven.znippy");
    build_archive(src.path(), &archive);
    let repo = MavenRepoZnippy::with_archive("maven".into(), archive).unwrap();

    // .sha1 of a served jar = sha1 of exactly the bytes the .jar path serves.
    let jar_url = format!("/maven/{gid}/{aid}/3.12.0/{aid}-3.12.0.jar");
    let sha_url = format!("{jar_url}.sha1");
    let (s, h, body) = repo.handle_http2_request("GET", &sha_url, &[]).unwrap();
    assert_eq!(s, 200, "synthesized .sha1 must serve 200");
    assert!(h
        .iter()
        .any(|(k, v)| k == "Content-Type" && v == "text/plain"));
    assert_eq!(
        String::from_utf8(body).unwrap(),
        sha1_hex(&rel),
        "sha1 must hash the served jar bytes"
    );

    // .md5 also synthesizes (32 lowercase hex chars).
    let (s, _h, body) = repo
        .handle_http2_request("GET", &format!("{jar_url}.md5"), &[])
        .unwrap();
    assert_eq!(s, 200);
    let md5 = String::from_utf8(body).unwrap();
    assert_eq!(md5.len(), 32, "md5 hex is 32 chars, got {md5:?}");
    assert!(md5
        .chars()
        .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()));

    // Checksum of a SYNTHESIZED maven-metadata.xml resolves too (hash of the XML).
    let meta_url = format!("/maven/{gid}/{aid}/maven-metadata.xml");
    let (s, _h, meta) = repo.handle_http2_request("GET", &meta_url, &[]).unwrap();
    assert_eq!(s, 200, "GA metadata synthesizes");
    let (s, _h, meta_sha) = repo
        .handle_http2_request("GET", &format!("{meta_url}.sha1"), &[])
        .unwrap();
    assert_eq!(s, 200);
    assert_eq!(
        String::from_utf8(meta_sha).unwrap(),
        sha1_hex(&meta),
        "metadata .sha1 hashes the metadata"
    );

    // Version-level -SNAPSHOT metadata: non-unique (localCopy) snapshot.
    let snap_meta = format!("/maven/{gid}/{aid}/3.20.0-SNAPSHOT/maven-metadata.xml");
    let (s, _h, body) = repo.handle_http2_request("GET", &snap_meta, &[]).unwrap();
    assert_eq!(s, 200, "version-level SNAPSHOT metadata synthesizes");
    let xml = String::from_utf8(body).unwrap();
    assert!(
        xml.contains("<version>3.20.0-SNAPSHOT</version>"),
        "got: {xml}"
    );
    assert!(xml.contains("<localCopy>true</localCopy>"), "got: {xml}");

    // A release version has NO version-level snapshot metadata (only GA-level).
    let rel_meta = format!("/maven/{gid}/{aid}/3.12.0/maven-metadata.xml");
    let (s, _h, _b) = repo.handle_http2_request("GET", &rel_meta, &[]).unwrap();
    assert_eq!(
        s, 404,
        "release version has no version-level snapshot metadata"
    );
}

/// The typed-view read path sizes its allocation from the archive index's
/// `uncompressed_size` — a number the ARCHIVE chose, not holger. The exact-path
/// sibling bounds every offset+length against the real file length before it
/// allocates; the view does not, so an index claiming a petabyte turned one
/// unauthenticated GET into a `Vec::with_capacity` that aborts the process.
///
/// Proven by pinning the ceiling below the fixture: the same request that serves
/// under the ceiling must be refused above it, on BOTH view doors (`fetch` and the
/// wire path), and the refusal must be a 404, never an allocation.
#[test]
fn typed_view_read_is_bounded_by_the_declared_size() {
    let src = tempfile::tempdir().unwrap();
    let out = tempfile::tempdir().unwrap();

    let gid = "org.apache.commons";
    let aid = "commons-lang3";
    let jar = make_jar(gid, aid, "3.12.0");
    let dir = src.path().join(format!("{gid}/{aid}/3.12.0"));
    fs::create_dir_all(&dir).unwrap();
    fs::write(dir.join(format!("{aid}-3.12.0.jar")), &jar).unwrap();

    let archive = out.path().join("maven.znippy");
    build_archive(src.path(), &archive);

    let id = ArtifactId {
        namespace: Some(gid.into()),
        name: aid.into(),
        version: "3.12.0".into(),
    };
    // The wire path uses group-as-DIRS, which is not the archive's stored path, so
    // it can only resolve through the typed view (no exact-path passthrough).
    let view_url = format!(
        "/maven/{}/{aid}/3.12.0/{aid}-3.12.0.jar",
        gid.replace('.', "/")
    );

    // Under the ceiling: both view doors serve the artifact.
    let ok_repo = MavenRepoZnippy::with_archive("maven".into(), archive.clone()).unwrap();
    assert!(
        ok_repo.max_artifact_bytes() >= jar.len() as u64,
        "the default ceiling must not refuse an ordinary jar"
    );
    assert_eq!(ok_repo.fetch(&id).unwrap().as_deref(), Some(jar.as_slice()));
    let (s, _h, body) = ok_repo.handle_http2_request("GET", &view_url, &[]).unwrap();
    assert_eq!(s, 200, "the view wire path must serve under the ceiling");
    assert_eq!(body, jar);

    // Ceiling below the declared size: refused, both doors, no allocation.
    let capped = MavenRepoZnippy::with_archive("maven".into(), archive)
        .unwrap()
        .with_max_artifact_bytes(jar.len() as u64 - 1);
    assert_eq!(
        capped.fetch(&id).unwrap(),
        None,
        "fetch must refuse an artifact whose declared size is over the ceiling"
    );
    let (s, _h, _b) = capped.handle_http2_request("GET", &view_url, &[]).unwrap();
    assert_eq!(
        s, 404,
        "the view wire path must refuse an over-ceiling declared size with 404"
    );
}

// ───────────────────────── the change token ─────────────────────────

/// `coordinate -> change_token` for one listing, refusing a row without one.
fn token_map(entries: &[traits::ArtifactEntry]) -> std::collections::BTreeMap<String, String> {
    entries
        .iter()
        .map(|e| {
            let key = format!(
                "{}/{}@{}",
                e.id.namespace.clone().unwrap_or_default(),
                e.id.name,
                e.id.version
            );
            let tok = e
                .change_token
                .clone()
                .unwrap_or_else(|| panic!("{key}: no change token"));
            (key, tok)
        })
        .collect()
}

/// **Every maven row carries a change token, and it moves only when the bytes do.**
///
/// Two independent seals of the same three artifacts, the second with the
/// `-sources` jar rewritten (same GAV, same classifier, same archive path, a
/// different pom body). The token is folded from the per-chunk checksums the
/// archive index carries for that row, so the two unchanged jars fold to the
/// same token across the two seals and the rewritten one does not. Before
/// `MavenPackage::relative_path` this backend listed through the typed view
/// and could not name its row, so it stamped nothing and a mirror re-fetched
/// every jar on every tick.
#[test]
fn maven_entries_carry_a_change_token_that_moves_only_when_the_bytes_do() {
    let gid = "org.apache.commons";
    let aid = "commons-lang3";

    // The rewritten pom: same GAV, one extra element, so the plugin still reads
    // the same coordinates out of different bytes.
    let rewritten_sources = build_zip_stored(
        &format!("META-INF/maven/{gid}/{aid}/pom.xml"),
        format!(
            "<?xml version=\"1.0\"?><project>\
             <groupId>{gid}</groupId><artifactId>{aid}</artifactId>\
             <version>3.12.0</version><packaging>jar</packaging>\
             <description>rewritten</description></project>"
        )
        .as_bytes(),
    );
    assert_ne!(
        rewritten_sources,
        make_jar(gid, aid, "3.12.0"),
        "the fixture must actually differ"
    );

    let seal = |rewrite: bool| -> (tempfile::TempDir, std::path::PathBuf) {
        let src = tempfile::tempdir().unwrap();
        let out = tempfile::tempdir().unwrap();
        let snap_dir = src.path().join(format!("{gid}/{aid}/3.20.0-SNAPSHOT"));
        fs::create_dir_all(&snap_dir).unwrap();
        fs::write(
            snap_dir.join(format!("{aid}-3.20.0-SNAPSHOT.jar")),
            make_jar(gid, aid, "3.20.0-SNAPSHOT"),
        )
        .unwrap();
        let rel_dir = src.path().join(format!("{gid}/{aid}/3.12.0"));
        fs::create_dir_all(&rel_dir).unwrap();
        fs::write(
            rel_dir.join(format!("{aid}-3.12.0.jar")),
            make_jar(gid, aid, "3.12.0"),
        )
        .unwrap();
        let sources = if rewrite {
            rewritten_sources.clone()
        } else {
            make_jar(gid, aid, "3.12.0")
        };
        fs::write(rel_dir.join(format!("{aid}-3.12.0-sources.jar")), sources).unwrap();
        let archive = out.path().join("maven.znippy");
        build_archive(src.path(), &archive);
        (out, archive)
    };

    let (_a, first) = seal(false);
    let (_b, second) = seal(true);
    let before = token_map(
        &MavenRepoZnippy::with_archive("maven".into(), first)
            .unwrap()
            .list(None, 0)
            .unwrap(),
    );
    let after = token_map(
        &MavenRepoZnippy::with_archive("maven".into(), second)
            .unwrap()
            .list(None, 0)
            .unwrap(),
    );

    assert_eq!(
        before.len(),
        3,
        "three rows, every one with a token: {before:?}"
    );
    assert_eq!(
        before.keys().collect::<Vec<_>>(),
        after.keys().collect::<Vec<_>>(),
        "the same coordinates on both sides"
    );
    let moved = format!("{gid}/{aid}@3.12.0:sources");
    assert!(
        before.contains_key(&moved),
        "the rewritten row is listed: {before:?}"
    );
    for (k, t) in &before {
        if *k == moved {
            assert_ne!(
                t, &after[k],
                "{k}: the bytes changed, so the token must move"
            );
        } else {
            assert_eq!(
                t, &after[k],
                "{k}: the bytes did not change, so the token must not move"
            );
        }
    }
}