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