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

nordisk/holger

server/lib/src/pull_intake/maven.rs

//! **A Maven repository as a remote source** (`kind: maven`): Maven Central
//! (`https://repo1.maven.org/maven2/`), Chainguard Libraries for Java
//! (`https://libraries.cgr.dev/java/`, basic auth with a pull token), any
//! repository in the Maven 2 layout.
//!
//! A version's POM and JAR are pulled together; each file is taken only when its
//! bytes hash to the checksums the repository publishes beside it (`.sha512`,
//! `.sha256`, `.sha1`: at least one must be there, and every one that is there
//! must match). Chainguard's `<artifactId>-<version>.slsa-attestation.json`
//! beside the JAR is fetched with it and handed to the provenance verifier.
//! Chainguard does not document that file's format; it is verified as what it
//! is (a DSSE envelope, a Sigstore bundle, an in-toto statement), and a file
//! the verifier cannot read is recorded as unreadable, never as a pass.
//! Maven Central's detached OpenPGP signature beside every file (`<file>.asc`)
//! is fetched with it and handed to the verifier, which holds it to the OpenPGP
//! keys the trust bundle pins (`holger_provenance::pgp`, narrowed by the source's
//! `require(pgp_keys: [...])`); a signer that is not pinned is unverified.

use super::http::Fetch;
use super::provenance::Material;
use sha2::Digest as _;

/// The cap on one metadata or checksum file.
const MAX_SMALL: u64 = 16 * 1024 * 1024;

/// The checksum suffixes a client asks for beside a file, strongest first.
pub const CHECKSUMS: &[&str] = &["sha512", "sha256", "sha1", "md5"];

/// What a path below a Maven source names.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MavenPath {
    /// `<group>/<artifact>/maven-metadata.xml`.
    Metadata { group: String, artifact: String },
    /// `<group>/<artifact>/<version>/<file>`.
    File {
        group: String,
        artifact: String,
        version: String,
        file: String,
    },
    /// A checksum of another path (`<path>.sha1`, …).
    Checksum {
        of: Box<MavenPath>,
        algo: &'static str,
    },
}

fn segment_ok(s: &str) -> bool {
    !s.is_empty()
        && s != "."
        && s != ".."
        && s.chars()
            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '+'))
}

/// Read a path below the source (`org/hamcrest/hamcrest-core/1.3/hamcrest-core-1.3.jar`).
pub fn parse(path: &str) -> Option<MavenPath> {
    let path = path.trim_start_matches('/');
    for algo in CHECKSUMS {
        if let Some(base) = path.strip_suffix(&format!(".{algo}")) {
            let of = parse(base)?;
            if matches!(of, MavenPath::Checksum { .. }) {
                return None;
            }
            return Some(MavenPath::Checksum {
                of: Box::new(of),
                algo,
            });
        }
    }
    let segs: Vec<&str> = path.split('/').collect();
    if segs.len() < 3 || !segs.iter().all(|s| segment_ok(s)) {
        return None;
    }
    if segs[segs.len() - 1] == "maven-metadata.xml" {
        let artifact = segs[segs.len() - 2].to_string();
        let group = segs[..segs.len() - 2].join("/");
        return (!group.is_empty()).then_some(MavenPath::Metadata { group, artifact });
    }
    if segs.len() < 4 {
        return None;
    }
    let n = segs.len();
    let (artifact, version, file) = (segs[n - 3], segs[n - 2], segs[n - 1]);
    // The file must belong to its directory: `<artifact>-<version>[-<classifier>].<ext>`.
    if !file.starts_with(&format!("{artifact}-{version}")) || version.ends_with("-SNAPSHOT") {
        return None;
    }
    Some(MavenPath::File {
        group: segs[..n - 3].join("/"),
        artifact: artifact.into(),
        version: version.into(),
        file: file.into(),
    })
}

impl MavenPath {
    /// The path below the source.
    pub fn path(&self) -> String {
        match self {
            MavenPath::Metadata { group, artifact } => {
                format!("{group}/{artifact}/maven-metadata.xml")
            }
            MavenPath::File {
                group,
                artifact,
                version,
                file,
            } => format!("{group}/{artifact}/{version}/{file}"),
            MavenPath::Checksum { of, algo } => format!("{}.{algo}", of.path()),
        }
    }
}

/// The coordinate a pulled Maven file stands at: `<source>/<path>`.
pub fn id(source: &str, path: &str) -> traits::ArtifactId {
    traits::ArtifactId {
        namespace: None,
        name: format!("{source}/{}", path.trim_start_matches('/')),
        version: String::new(),
    }
}

/// The POM and the JAR of a version.
pub fn version_files(artifact: &str, version: &str) -> [String; 2] {
    [
        format!("{artifact}-{version}.pom"),
        format!("{artifact}-{version}.jar"),
    ]
}

/// The hex digest of `bytes` under a checksum suffix.
pub fn digest(algo: &str, bytes: &[u8]) -> Option<String> {
    Some(match algo {
        "sha512" => hex::encode(sha2::Sha512::digest(bytes)),
        "sha256" => hex::encode(sha2::Sha256::digest(bytes)),
        "sha1" => hex::encode(sha1::Sha1::digest(bytes)),
        "md5" => hex::encode(md5::Md5::digest(bytes)),
        _ => return None,
    })
}

fn base(url: &str) -> String {
    if url.ends_with('/') {
        url.to_string()
    } else {
        format!("{url}/")
    }
}

/// **Fetch one file** at `path` below the repository at `url`, held against
/// every checksum published beside it, and the material that came with it.
/// `None` when the repository has no such file.
pub fn fetch_file(
    http: &dyn Fetch,
    url: &str,
    path: &str,
    auth: Option<(&str, &str)>,
    cap: u64,
) -> anyhow::Result<Option<(Vec<u8>, Vec<Material>)>> {
    let url = format!("{}{path}", base(url));
    let r = http.get(&url, &[], auth, cap)?;
    match r.status {
        200 => {}
        404 | 410 => return Ok(None),
        s => anyhow::bail!("{url} answered HTTP {s}"),
    }
    let file = path.rsplit('/').next().unwrap_or(path);
    let mut held = Vec::new();
    for algo in ["sha512", "sha256", "sha1"] {
        let c = http.get(&format!("{url}.{algo}"), &[], auth, MAX_SMALL)?;
        match c.status {
            200 => {
                // A checksum file is the hex digest, sometimes followed by the file name.
                let text = String::from_utf8_lossy(&c.body);
                let want = text
                    .split_whitespace()
                    .next()
                    .unwrap_or("")
                    .to_ascii_lowercase();
                let got = digest(algo, &r.body).unwrap_or_default();
                if want != got {
                    anyhow::bail!("{file}: the bytes hash to {algo} {got}, the repository publishes {want}; not taken");
                }
                held.push(algo);
            }
            404 | 410 => {}
            s => anyhow::bail!("{url}.{algo} answered HTTP {s}"),
        }
    }
    if held.is_empty() {
        anyhow::bail!("{file}: the repository publishes no .sha512, .sha256 or .sha1 beside it, so it is not taken");
    }
    let mut materials = Vec::new();
    // Maven Central: the detached OpenPGP signature beside every file.
    let asc = http.get(&format!("{url}.asc"), &[], auth, MAX_SMALL)?;
    match asc.status {
        200 => materials.push(Material {
            name: format!("{file}.asc"),
            bytes: asc.body,
        }),
        404 | 410 => {}
        s => anyhow::bail!("{file}: its signature {url}.asc answered HTTP {s}"),
    }
    // Chainguard Libraries for Java: the SLSA provenance beside the JAR.
    if let Some(stem) = file.strip_suffix(".jar") {
        let dir = url.rsplit_once('/').map(|(d, _)| d).unwrap_or(&url);
        let att = format!("{dir}/{stem}.slsa-attestation.json");
        let a = http.get(&att, &[], auth, MAX_SMALL)?;
        if a.status == 200 {
            materials.push(Material {
                name: format!("{stem}.slsa-attestation.json"),
                bytes: a.body,
            });
        } else if !matches!(a.status, 404 | 410) {
            anyhow::bail!("{file}: its attestation {att} answered HTTP {}", a.status);
        }
    }
    Ok(Some((r.body, materials)))
}

/// **The `maven-metadata.xml` a repository publishes at `<group>/<artifact>/`**, as text;
/// `None` when it does not know the path.
pub fn metadata_text(
    http: &dyn Fetch,
    url: &str,
    group: &str,
    artifact: &str,
    auth: Option<(&str, &str)>,
) -> anyhow::Result<Option<String>> {
    let url = format!("{}{group}/{artifact}/maven-metadata.xml", base(url));
    let r = http.get(&url, &[], auth, MAX_SMALL)?;
    match r.status {
        200 => {}
        404 | 410 => return Ok(None),
        s => anyhow::bail!("{url} answered HTTP {s}"),
    }
    Ok(Some(String::from_utf8_lossy(&r.body).into_owned()))
}

fn tags(t: &str, s: &str) -> Vec<String> {
    let (open, close) = (format!("<{t}>"), format!("</{t}>"));
    s.split(&open)
        .skip(1)
        .filter_map(|x| x.split(&close).next())
        .map(|x| x.trim().to_string())
        .collect()
}

/// **The newest release a `maven-metadata.xml` names** (`<release>`, else the last
/// non-SNAPSHOT `<version>`).
pub fn newest_release_of(text: &str) -> Option<String> {
    if let Some(r) = tags("release", text).into_iter().find(|v| segment_ok(v)) {
        return Some(r);
    }
    tags("version", text)
        .into_iter()
        .rfind(|v| segment_ok(v) && !v.ends_with("-SNAPSHOT"))
}

/// **The newest release a repository's `maven-metadata.xml` names.**
pub fn newest_release(
    http: &dyn Fetch,
    url: &str,
    group: &str,
    artifact: &str,
    auth: Option<(&str, &str)>,
) -> anyhow::Result<Option<String>> {
    Ok(metadata_text(http, url, group, artifact, auth)?.and_then(|t| newest_release_of(&t)))
}

/// **A plugin group's plugins** (lane HOL-FIELD-TEST P13): the `<plugin>` entries of a GROUP's
/// `maven-metadata.xml` (`org/apache/maven/plugins/`), each `(name, prefix, artifactId)`, which
/// Maven resolves a prefix (`dependency:get`) from. Names only; an entry whose prefix or
/// artifactId is not a path segment is dropped.
pub fn group_plugins(text: &str) -> Vec<(String, String, String)> {
    text.split("<plugin>")
        .skip(1)
        .filter_map(|p| {
            let p = p.split("</plugin>").next()?;
            let one = |t: &str| tags(t, p).into_iter().next();
            let (prefix, artifact) = (one("prefix")?, one("artifactId")?);
            (segment_ok(&prefix) && segment_ok(&artifact)).then(|| {
                let name: String = one("name")
                    .unwrap_or_default()
                    .chars()
                    .filter(|c| !matches!(c, '<' | '>' | '&'))
                    .collect();
                (name, prefix, artifact)
            })
        })
        .collect()
}

/// **The group `maven-metadata.xml` Holger serves**: the plugin list, nothing else.
pub fn plugins_xml(plugins: &[(String, String, String)]) -> String {
    let mut out =
        String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<metadata>\n  <plugins>\n");
    for (name, prefix, artifact) in plugins {
        out.push_str(&format!(
            "    <plugin>\n      <name>{name}</name>\n      <prefix>{prefix}</prefix>\n      <artifactId>{artifact}</artifactId>\n    </plugin>\n"
        ));
    }
    out.push_str("  </plugins>\n</metadata>\n");
    out
}

/// Maven's version order, enough for `<latest>`: numeric parts numerically, a
/// qualifier before the release it qualifies.
pub fn compare(a: &str, b: &str) -> std::cmp::Ordering {
    fn parts(v: &str) -> Vec<(u64, String)> {
        v.split(['.', '-'])
            .map(|p| {
                (
                    p.parse::<u64>().unwrap_or(0),
                    if p.parse::<u64>().is_ok() {
                        String::new()
                    } else {
                        p.to_ascii_lowercase()
                    },
                )
            })
            .collect()
    }
    let (pa, pb) = (parts(a), parts(b));
    for i in 0..pa.len().max(pb.len()) {
        let o = match (pa.get(i), pb.get(i)) {
            (Some(x), Some(y)) => match (x.1.is_empty(), y.1.is_empty()) {
                (true, true) => x.0.cmp(&y.0),
                (false, false) => x.1.cmp(&y.1),
                (true, false) => std::cmp::Ordering::Greater,
                (false, true) => std::cmp::Ordering::Less,
            },
            (Some(x), None) => {
                if x.1.is_empty() {
                    std::cmp::Ordering::Greater
                } else {
                    std::cmp::Ordering::Less
                }
            }
            (None, Some(y)) => {
                if y.1.is_empty() {
                    std::cmp::Ordering::Less
                } else {
                    std::cmp::Ordering::Greater
                }
            }
            (None, None) => std::cmp::Ordering::Equal,
        };
        if o != std::cmp::Ordering::Equal {
            return o;
        }
    }
    std::cmp::Ordering::Equal
}

/// **The `maven-metadata.xml` Holger serves**: the AVAILABLE versions only.
pub fn metadata_xml(group: &str, artifact: &str, mut versions: Vec<String>) -> String {
    versions.sort_by(|a, b| compare(a, b));
    versions.dedup();
    let latest = versions.last().cloned().unwrap_or_default();
    let mut out = format!(
        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<metadata>\n  <groupId>{}</groupId>\n  <artifactId>{artifact}</artifactId>\n  <versioning>\n    <latest>{latest}</latest>\n    <release>{latest}</release>\n    <versions>\n",
        group.replace('/', ".")
    );
    for v in &versions {
        out.push_str(&format!("      <version>{v}</version>\n"));
    }
    out.push_str("    </versions>\n  </versioning>\n</metadata>\n");
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn paths_read_as_maven_lays_them_out() {
        assert_eq!(
            parse("org/hamcrest/hamcrest-core/1.3/hamcrest-core-1.3.jar"),
            Some(MavenPath::File {
                group: "org/hamcrest".into(),
                artifact: "hamcrest-core".into(),
                version: "1.3".into(),
                file: "hamcrest-core-1.3.jar".into()
            })
        );
        assert_eq!(
            parse("org/hamcrest/hamcrest-core/maven-metadata.xml"),
            Some(MavenPath::Metadata {
                group: "org/hamcrest".into(),
                artifact: "hamcrest-core".into()
            })
        );
        let c = parse("org/hamcrest/hamcrest-core/1.3/hamcrest-core-1.3.pom.sha1").unwrap();
        assert!(
            matches!(&c, MavenPath::Checksum { algo: "sha1", of } if of.path() == "org/hamcrest/hamcrest-core/1.3/hamcrest-core-1.3.pom")
        );
        assert_eq!(parse("org/../etc/passwd/x"), None);
        assert_eq!(
            parse("org/a/1.0/other-1.0.jar"),
            None,
            "a file of another artifact"
        );
        assert_eq!(
            parse("org/a/1.0-SNAPSHOT/a-1.0-SNAPSHOT.jar"),
            None,
            "snapshots are not pulled"
        );
    }

    #[test]
    fn metadata_lists_the_versions_in_maven_order() {
        let x = metadata_xml(
            "org/hamcrest",
            "hamcrest-core",
            vec!["1.3".into(), "1.10".into(), "1.3.RC2".into()],
        );
        assert!(x.contains("<groupId>org.hamcrest</groupId>"));
        assert!(x.contains("<release>1.10</release>"), "{x}");
        let at = |v: &str| x.find(&format!("<version>{v}</version>")).unwrap();
        assert!(at("1.3.RC2") < at("1.3") && at("1.3") < at("1.10"), "{x}");
    }

    #[test]
    fn checksums_are_the_digests_maven_writes() {
        assert_eq!(
            digest("sha1", b"abc").unwrap(),
            "a9993e364706816aba3e25717850c26c9cd0d89d"
        );
        assert_eq!(
            digest("md5", b"abc").unwrap(),
            "900150983cd24fb0d6963f7d28e17f72"
        );
    }
}