use std::fmt;
use serde_json::{Map, Value};
use url::Url;
use crate::policy::{Digest, Ecosystem, PublicationTime};
use crate::store::rows::{ArtifactReference, ReferenceId};
const TIME_METADATA_KEYS: [&str; 2] = ["created", "modified"];
pub struct PackageDocument {
pub name: String,
pub versions: Map<String, Value>,
pub time: Map<String, Value>,
pub dist_tags: Map<String, Value>,
pub rest: Map<String, Value>,
}
impl PackageDocument {
pub fn parse(bytes: &[u8]) -> Result<PackageDocument, DocumentError> {
let value: Value = serde_json::from_slice(bytes).map_err(DocumentError::Json)?;
let Value::Object(mut object) = value else {
return Err(DocumentError::NotAnObject);
};
let name = match object.remove("name") {
Some(Value::String(name)) => name,
_ => return Err(DocumentError::MissingName),
};
let versions = take_object(&mut object, "versions")?;
let time = take_object(&mut object, "time")?;
let dist_tags = take_object(&mut object, "dist-tags")?;
Ok(PackageDocument {
name,
versions,
time,
dist_tags,
rest: object,
})
}
pub fn entries(&self, max_references: u32) -> Result<Vec<VersionEntry>, DocumentError> {
if self.versions.len() > max_references as usize {
return Err(DocumentError::TooManyReferences {
count: self.versions.len(),
limit: max_references,
});
}
let mut entries = Vec::with_capacity(self.versions.len());
for (version, record) in &self.versions {
match self.entry(version, record) {
Ok(entry) => entries.push(entry),
Err(reason) => tracing::warn!(
package = %self.name,
version = %version,
%reason,
"excluding a version whose artifact identity cannot be established"
),
}
}
Ok(entries)
}
fn entry(&self, version: &str, record: &Value) -> Result<VersionEntry, UnusableVersion> {
let dist = record
.get("dist")
.and_then(Value::as_object)
.ok_or(UnusableVersion::NoDist)?;
let tarball = dist
.get("tarball")
.and_then(Value::as_str)
.ok_or(UnusableVersion::NoTarball)?;
let upstream_url = Url::parse(tarball).map_err(|_| UnusableVersion::TarballNotAUrl)?;
let filename = upstream_url
.path_segments()
.and_then(|mut segments| segments.next_back())
.filter(|segment| !segment.is_empty())
.ok_or(UnusableVersion::NoFilename)?
.to_owned();
let reference = ArtifactReference {
ecosystem: Ecosystem::Npm,
name: self.name.clone(),
version: version.to_owned(),
filename,
upstream_url,
expected: advertised_digests(dist)?,
};
Ok(VersionEntry {
id: ReferenceId::compute(&reference),
reference,
publication: self.publication_time(version),
record: record.clone(),
})
}
fn publication_time(&self, version: &str) -> PublicationTime {
match self.time.get(version) {
None => PublicationTime::Unknown,
Some(Value::String(text)) => match text.parse::<jiff::Timestamp>() {
Ok(timestamp) => PublicationTime::Upstream(timestamp.as_microsecond()),
Err(_) => PublicationTime::Malformed,
},
Some(_) => PublicationTime::Malformed,
}
}
}
pub struct VersionEntry {
pub id: ReferenceId,
pub reference: ArtifactReference,
pub publication: PublicationTime,
pub record: Value,
}
impl VersionEntry {
pub fn version(&self) -> &str {
&self.reference.version
}
}
fn advertised_digests(dist: &Map<String, Value>) -> Result<Vec<Digest>, UnusableVersion> {
let mut digests = Vec::new();
if let Some(integrity) = dist.get("integrity") {
let integrity = integrity
.as_str()
.ok_or(UnusableVersion::MalformedIntegrity)?;
for entry in integrity.split_whitespace() {
digests.push(
Digest::parse_sri_entry(entry).map_err(|_| UnusableVersion::MalformedIntegrity)?,
);
}
}
if let Some(shasum) = dist.get("shasum") {
let shasum = shasum.as_str().ok_or(UnusableVersion::MalformedIntegrity)?;
digests.push(
Digest::parse_hex(crate::policy::HashAlgorithm::Sha1, shasum)
.map_err(|_| UnusableVersion::MalformedIntegrity)?,
);
}
Ok(digests)
}
fn take_object(
object: &mut Map<String, Value>,
key: &'static str,
) -> Result<Map<String, Value>, DocumentError> {
match object.remove(key) {
None | Some(Value::Null) => Ok(Map::new()),
Some(Value::Object(inner)) => Ok(inner),
Some(_) => Err(DocumentError::NotAnObjectAt(key)),
}
}
pub fn prune_time(time: &Map<String, Value>, keep: impl Fn(&str) -> bool) -> Map<String, Value> {
time.iter()
.filter(|(key, _)| TIME_METADATA_KEYS.contains(&key.as_str()) || keep(key))
.map(|(key, value)| (key.clone(), value.clone()))
.collect()
}
#[derive(Debug)]
pub enum DocumentError {
Json(serde_json::Error),
NotAnObject,
NotAnObjectAt(&'static str),
MissingName,
TooManyReferences {
count: usize,
limit: u32,
},
}
impl fmt::Display for DocumentError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
DocumentError::Json(err) => write!(f, "the document is not usable JSON: {err}"),
DocumentError::NotAnObject => f.write_str("the document is not a JSON object"),
DocumentError::NotAnObjectAt(key) => write!(f, "`{key}` is not a JSON object"),
DocumentError::MissingName => f.write_str("the document carries no `name`"),
DocumentError::TooManyReferences { count, limit } => write!(
f,
"the document advertises {count} versions, above the {limit} this instance \
commits in one transaction"
),
}
}
}
impl std::error::Error for DocumentError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
DocumentError::Json(err) => Some(err),
_ => None,
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum UnusableVersion {
NoDist,
NoTarball,
TarballNotAUrl,
NoFilename,
MalformedIntegrity,
}
impl fmt::Display for UnusableVersion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
UnusableVersion::NoDist => "no `dist` object",
UnusableVersion::NoTarball => "no `dist.tarball`",
UnusableVersion::TarballNotAUrl => "`dist.tarball` is not a URL",
UnusableVersion::NoFilename => "`dist.tarball` carries no filename",
UnusableVersion::MalformedIntegrity => "the advertised integrity does not decode",
})
}
}
#[cfg(test)]
mod tests {
use super::*;
const DOCUMENT: &str = r#"{
"name": "widget",
"unknown-future-field": {"kept": [1, 2, 3]},
"dist-tags": {"latest": "1.0.0"},
"versions": {
"1.0.0": {
"name": "widget", "version": "1.0.0",
"dependencies": {"left-pad": "^1.3.0"},
"dist": {
"tarball": "https://npm.invalid/widget/-/widget-1.0.0.tgz",
"integrity": "sha512-Ky+7uDR6YC9cEIbfCn6zqL7lgs8mBiPQrVtxnmWNbpjHSJnMLJUEGwlxYLLDEBpotoKp9GOwWEYYCQaB7+Jsug==",
"shasum": "0123456789abcdef0123456789abcdef01234567"
}
},
"1.1.0": {
"name": "widget", "version": "1.1.0",
"dist": {"tarball": "not a url"}
}
},
"time": {
"created": "2026-01-01T00:00:00.000Z",
"1.0.0": "2026-01-02T00:00:00.000Z",
"1.1.0": "yesterday"
}
}"#;
#[test]
fn the_four_filtered_parts_come_out_and_everything_else_stays_whole() {
let document = PackageDocument::parse(DOCUMENT.as_bytes()).expect("a valid document");
assert_eq!(document.name, "widget");
assert_eq!(document.versions.len(), 2);
assert_eq!(document.dist_tags["latest"], Value::from("1.0.0"));
assert_eq!(
document.rest["unknown-future-field"]["kept"],
Value::from(vec![1, 2, 3]),
"a field this release has never heard of survives untouched"
);
assert!(!document.rest.contains_key("versions"));
}
#[test]
fn a_version_without_a_usable_tarball_is_excluded() {
let document = PackageDocument::parse(DOCUMENT.as_bytes()).expect("a valid document");
let entries = document.entries(100).expect("the entries");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].version(), "1.0.0");
assert_eq!(entries[0].reference.filename, "widget-1.0.0.tgz");
assert_eq!(
entries[0].reference.expected.len(),
2,
"the SRI sha512 and the legacy shasum are both kept"
);
assert_eq!(
entries[0].publication,
PublicationTime::Upstream(
"2026-01-02T00:00:00Z"
.parse::<jiff::Timestamp>()
.expect("a timestamp")
.as_microsecond()
)
);
}
#[test]
fn malformed_and_absent_publication_times_are_different_answers() {
let document = PackageDocument::parse(DOCUMENT.as_bytes()).expect("a valid document");
assert_eq!(
document.publication_time("1.1.0"),
PublicationTime::Malformed
);
assert_eq!(document.publication_time("9.9.9"), PublicationTime::Unknown);
}
#[test]
fn the_reference_cap_is_a_document_error_not_a_truncation() {
let document = PackageDocument::parse(DOCUMENT.as_bytes()).expect("a valid document");
assert!(matches!(
document.entries(1),
Err(DocumentError::TooManyReferences { count: 2, limit: 1 })
));
}
#[test]
fn a_deeply_nested_document_is_a_parse_error_not_a_crash() {
let deep = format!("{}{}", "[".repeat(100_000), "]".repeat(100_000));
assert!(matches!(
PackageDocument::parse(deep.as_bytes()),
Err(DocumentError::Json(_))
));
}
}