server/lib/src/pull_intake/npm.rs
//! **An npm registry as a remote source** (`kind: npm`): registry.npmjs.org,
//! Chainguard Libraries for JavaScript (`https://libraries.cgr.dev/javascript/`,
//! basic auth with a pull token), any registry that speaks npm's documents.
//!
//! A version is taken only with the `dist.integrity` (SHA-512) its packument
//! publishes, and only when the tarball's bytes hash to it. What came with it
//! travels to the provenance verifier:
//!
//! * the registry's signatures (`dist.signatures`, ECDSA P-256 over
//! `<name>@<version>:<integrity>`) as `holger.npm-registry-signatures/v1`,
//! checked against the floor's `rules.keys` (npm publishes its keys at
//! `/-/npm/v1/keys`; Holger never fetches a key it then trusts);
//! * the attestations `dist.attestations.url` names (npm's SLSA provenance,
//! keyless, and its publish attestation, key-signed).
//!
//! The packument Holger serves is a view of the AVAILABLE set: each version is its
//! sealed tarball's own `package.json` (`crate::promote::npm::packument`).
use super::http::{join, Fetch};
use super::provenance::Material;
use serde_json::Value;
/// The cap on one packument.
const MAX_DOC: u64 = 64 * 1024 * 1024;
/// One version a packument publishes.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteVersion {
pub package: String,
pub version: String,
pub tarball: String,
/// `sha512-<base64>`.
pub integrity: Option<String>,
/// `dist.signatures`, as the packument carried them.
pub signatures: Option<Value>,
/// `dist.attestations.url`.
pub attestations: Option<String>,
pub deprecated: bool,
}
/// A package name as the registry's URL spells it (`@scope%2fname`).
pub fn url_name(package: &str) -> String {
package.replacen('/', "%2f", 1)
}
/// **Read one package's packument** from the registry at `base`. `None` when
/// the registry does not know it.
pub fn packument(
http: &dyn Fetch,
base: &str,
package: &str,
auth: Option<(&str, &str)>,
) -> anyhow::Result<Option<Value>> {
if !crate::promote::npm::valid_package_name(package) {
anyhow::bail!("'{package}' is not an npm package name");
}
let base = if base.ends_with('/') {
base.to_string()
} else {
format!("{base}/")
};
let url = format!("{base}{}", url_name(package));
let r = http.get(
&url,
&[("Accept".into(), "application/json".into())],
auth,
MAX_DOC,
)?;
match r.status {
200 => {}
404 | 410 => return Ok(None),
s => anyhow::bail!("the registry {url} answered HTTP {s}"),
}
let v: Value = serde_json::from_slice(&r.body)
.map_err(|e| anyhow::anyhow!("the packument {url} is not JSON: {e}"))?;
Ok(Some(v))
}
/// The version `dist-tags.latest` names.
pub fn latest(doc: &Value) -> Option<String> {
doc.pointer("/dist-tags/latest")
.and_then(Value::as_str)
.map(str::to_string)
}
/// One version of a packument, its links resolved against `page`.
pub fn version(doc: &Value, page: &str, package: &str, version: &str) -> Option<RemoteVersion> {
let v = doc.get("versions")?.get(version)?;
let dist = v.get("dist")?;
let tarball = join(page, dist.get("tarball")?.as_str()?)?;
Some(RemoteVersion {
package: package.to_string(),
version: version.to_string(),
tarball,
integrity: dist
.get("integrity")
.and_then(Value::as_str)
.map(str::to_string),
signatures: dist
.get("signatures")
.filter(|s| s.as_array().is_some_and(|a| !a.is_empty()))
.cloned(),
attestations: dist
.pointer("/attestations/url")
.and_then(Value::as_str)
.and_then(|u| join(page, u)),
deprecated: v
.get("deprecated")
.is_some_and(|d| !d.is_null() && d != &Value::Bool(false)),
})
}
/// `sha512-<base64>` of `bytes`, npm's integrity.
pub fn integrity_of(bytes: &[u8]) -> String {
crate::promote::npm::integrity_of(bytes)
}
/// **Fetch one version's tarball**, held against its published `dist.integrity`,
/// with the registry's signatures and the attestations it names.
pub fn fetch_version(
http: &dyn Fetch,
v: &RemoteVersion,
auth: Option<(&str, &str)>,
cap: u64,
) -> anyhow::Result<(Vec<u8>, Vec<Material>)> {
let file = crate::promote::npm::tarball_file(&v.package, &v.version);
let want = v
.integrity
.as_deref()
.filter(|i| i.starts_with("sha512-"))
.ok_or_else(|| {
anyhow::anyhow!(
"{}@{}: the packument publishes no sha512 integrity for it, so it is not taken",
v.package,
v.version
)
})?;
let r = http.get(&v.tarball, &[], auth, cap)?;
if r.status != 200 {
anyhow::bail!("{file}: {} answered HTTP {}", v.tarball, r.status);
}
let got = integrity_of(&r.body);
if got != want {
anyhow::bail!("{file}: the bytes are {got}, the packument publishes {want}; not taken");
}
let mut materials = Vec::new();
if let Some(sigs) = &v.signatures {
let doc = serde_json::json!({
"schema": holger_provenance::material::NPM_REGISTRY_SIGNATURES_SCHEMA,
"name": v.package, "version": v.version, "integrity": want, "signatures": sigs,
});
materials.push(Material {
name: format!("{file}.registry-signatures.json"),
bytes: serde_json::to_vec(&doc)?,
});
}
if let Some(url) = &v.attestations {
let a = http.get(
url,
&[("Accept".into(), "application/json".into())],
auth,
MAX_DOC,
)?;
if a.status == 200 {
materials.push(Material {
name: format!("{file}.attestations.json"),
bytes: a.body,
});
} else if a.status != 404 {
anyhow::bail!("{file}: its attestations {url} answered HTTP {}", a.status);
}
}
Ok((r.body, materials))
}
/// The coordinate a pulled npm version stands at: `<source>/<package>/-/<file>.tgz`,
/// the tarball's registry path below the source.
pub fn id(source: &str, package: &str, version: &str) -> traits::ArtifactId {
let c = crate::promote::npm::coordinate(package, version);
traits::ArtifactId {
namespace: None,
name: format!("{source}/{}", c.name),
version: String::new(),
}
}
/// The (package, version) a coordinate below `source` is the tarball of.
pub fn coords_of(source: &str, id: &traits::ArtifactId) -> Option<(String, String)> {
let rest = id.name.strip_prefix(source)?.strip_prefix('/')?;
crate::promote::npm::coords_of(&traits::ArtifactId {
namespace: id.namespace.clone(),
name: rest.to_string(),
version: id.version.clone(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_scoped_name_is_spelled_as_the_registry_wants_it() {
assert_eq!(url_name("@sigstore/core"), "@sigstore%2fcore");
assert_eq!(url_name("lodash"), "lodash");
}
#[test]
fn a_coordinate_names_the_source_and_reads_back() {
let i = id("npmjs", "@sigstore/core", "4.0.0");
assert_eq!(i.name, "npmjs/@sigstore/core/-/core-4.0.0.tgz");
assert_eq!(
coords_of("npmjs", &i),
Some(("@sigstore/core".into(), "4.0.0".into()))
);
assert_eq!(coords_of("other", &i), None);
}
#[test]
fn a_version_reads_its_dist() {
let doc = serde_json::json!({"dist-tags": {"latest": "1.0.0"}, "versions": {"1.0.0": {"dist": {
"tarball": "/a/-/a-1.0.0.tgz", "integrity": "sha512-x", "signatures": [{"keyid": "k", "sig": "s"}],
"attestations": {"url": "https://r.example/-/npm/v1/attestations/a@1.0.0"}}}}});
let v = version(&doc, "https://r.example/a", "a", &latest(&doc).unwrap()).unwrap();
assert_eq!(v.tarball, "https://r.example/a/-/a-1.0.0.tgz");
assert!(v.signatures.is_some() && v.attestations.is_some() && !v.deprecated);
}
}