use serde::{Deserialize, Deserializer, Serialize};
use std::collections::BTreeMap;
fn non_string_tolerant_map<'de, D>(de: D) -> Result<BTreeMap<String, String>, D::Error>
where
D: Deserializer<'de>,
{
let maybe: Option<BTreeMap<String, serde_json::Value>> = Option::deserialize(de)?;
Ok(maybe
.unwrap_or_default()
.into_iter()
.filter_map(|(k, v)| match v {
serde_json::Value::String(s) => Some((k, s)),
_ => None,
})
.collect())
}
pub mod client;
pub mod config;
pub mod jsr;
pub use aube_manifest::BundledDependencies;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum NetworkMode {
#[default]
Online,
PreferOffline,
Offline,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Packument {
pub name: String,
#[serde(default)]
pub modified: Option<String>,
#[serde(default)]
pub versions: BTreeMap<String, VersionMetadata>,
#[serde(
rename = "dist-tags",
default,
deserialize_with = "non_string_tolerant_map"
)]
pub dist_tags: BTreeMap<String, String>,
#[serde(default, deserialize_with = "non_string_tolerant_map")]
pub time: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct VersionMetadata {
pub name: String,
pub version: String,
#[serde(default, deserialize_with = "non_string_tolerant_map")]
pub dependencies: BTreeMap<String, String>,
#[serde(default, deserialize_with = "non_string_tolerant_map")]
pub dev_dependencies: BTreeMap<String, String>,
#[serde(default, deserialize_with = "non_string_tolerant_map")]
pub peer_dependencies: BTreeMap<String, String>,
#[serde(default)]
pub peer_dependencies_meta: BTreeMap<String, PeerDepMeta>,
#[serde(default, deserialize_with = "non_string_tolerant_map")]
pub optional_dependencies: BTreeMap<String, String>,
#[serde(default, alias = "bundleDependencies")]
pub bundled_dependencies: Option<BundledDependencies>,
pub dist: Option<Dist>,
#[serde(default, deserialize_with = "aube_util::string_or_seq")]
pub os: Vec<String>,
#[serde(default, deserialize_with = "aube_util::string_or_seq")]
pub cpu: Vec<String>,
#[serde(default, deserialize_with = "aube_util::string_or_seq")]
pub libc: Vec<String>,
#[serde(default, deserialize_with = "aube_manifest::engines_tolerant")]
pub engines: BTreeMap<String, String>,
#[serde(default, deserialize_with = "license_string")]
pub license: Option<String>,
#[serde(default, rename = "funding", deserialize_with = "funding_url")]
pub funding_url: Option<String>,
#[serde(default, rename = "bin", deserialize_with = "bin_map")]
pub bin: BTreeMap<String, String>,
#[serde(default)]
pub has_install_script: bool,
#[serde(default, deserialize_with = "deprecated_string")]
pub deprecated: Option<String>,
#[serde(default, rename = "_npmUser", deserialize_with = "npm_user_tolerant")]
pub npm_user: Option<NpmUser>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct PeerDepMeta {
#[serde(default)]
pub optional: bool,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct NpmUser {
#[serde(default, rename = "trustedPublisher")]
pub trusted_publisher: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Dist {
pub tarball: String,
pub integrity: Option<String>,
pub shasum: Option<String>,
#[serde(default)]
pub attestations: Option<Attestations>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct Attestations {
#[serde(default)]
pub provenance: Option<serde_json::Value>,
}
fn deprecated_string<'de, D>(de: D) -> Result<Option<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = Option::<serde_json::Value>::deserialize(de)?;
Ok(match value {
Some(serde_json::Value::String(s)) if !s.is_empty() => Some(s),
_ => None,
})
}
fn license_string<'de, D>(de: D) -> Result<Option<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = Option::<serde_json::Value>::deserialize(de)?;
Ok(match value {
Some(serde_json::Value::String(s)) if !s.is_empty() => Some(s),
Some(serde_json::Value::Object(m)) => m
.get("type")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from),
_ => None,
})
}
fn funding_url<'de, D>(de: D) -> Result<Option<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = Option::<serde_json::Value>::deserialize(de)?;
Ok(match value {
Some(serde_json::Value::String(s)) if !s.is_empty() => Some(s),
Some(serde_json::Value::Object(m)) => m
.get("url")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from),
Some(serde_json::Value::Array(arr)) => arr.iter().find_map(|v| match v {
serde_json::Value::String(s) if !s.is_empty() => Some(s.clone()),
serde_json::Value::Object(m) => m
.get("url")
.and_then(|u| u.as_str())
.filter(|s| !s.is_empty())
.map(String::from),
_ => None,
}),
_ => None,
})
}
fn npm_user_tolerant<'de, D>(de: D) -> Result<Option<NpmUser>, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = Option::<serde_json::Value>::deserialize(de)?;
Ok(match value {
Some(v @ serde_json::Value::Object(_)) => serde_json::from_value(v).ok(),
_ => None,
})
}
fn bin_map<'de, D>(de: D) -> Result<BTreeMap<String, String>, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = Option::<serde_json::Value>::deserialize(de)?;
Ok(match value {
None | Some(serde_json::Value::Null) => BTreeMap::new(),
Some(serde_json::Value::String(s)) if s.is_empty() => BTreeMap::new(),
Some(serde_json::Value::String(s)) => {
let mut m = BTreeMap::new();
m.insert(String::new(), s);
m
}
Some(serde_json::Value::Object(m)) => m
.into_iter()
.filter_map(|(k, v)| match v {
serde_json::Value::String(s) => Some((k, s)),
_ => None,
})
.collect(),
Some(_) => BTreeMap::new(),
})
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("HTTP error: {0}")]
Http(#[from] reqwest::Error),
#[error("package not found: {0}")]
NotFound(String),
#[error("version not found: {0}@{1}")]
VersionNotFound(String, String),
#[error("authentication required")]
Unauthorized,
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("registry rejected write: HTTP {status}: {body}")]
RegistryWrite { status: u16, body: String },
#[error("offline: {0} is not available in the local cache")]
Offline(String),
#[error("invalid package name: {0:?}")]
InvalidName(String),
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(json: &str) -> VersionMetadata {
serde_json::from_str(json).unwrap()
}
#[test]
fn libc_accepts_string() {
let v = parse(r#"{"name":"x","version":"1.0.0","libc":"glibc"}"#);
assert_eq!(v.libc, vec!["glibc"]);
}
#[test]
fn libc_accepts_array() {
let v = parse(r#"{"name":"x","version":"1.0.0","libc":["glibc","musl"]}"#);
assert_eq!(v.libc, vec!["glibc", "musl"]);
}
#[test]
fn os_and_cpu_accept_string() {
let v = parse(r#"{"name":"x","version":"1.0.0","os":"linux","cpu":"x64"}"#);
assert_eq!(v.os, vec!["linux"]);
assert_eq!(v.cpu, vec!["x64"]);
}
#[test]
fn null_is_treated_as_empty() {
let v = parse(r#"{"name":"x","version":"1.0.0","os":null,"cpu":null,"libc":null}"#);
assert!(v.os.is_empty());
assert!(v.cpu.is_empty());
assert!(v.libc.is_empty());
}
#[test]
fn libc_array_containing_null_drops_null() {
let v = parse(r#"{"name":"x","version":"1.0.0","libc":[null]}"#);
assert!(v.libc.is_empty());
}
#[test]
fn os_cpu_libc_arrays_drop_non_string_entries() {
let v = parse(
r#"{
"name":"x","version":"1.0.0",
"os":["linux",null,42],
"cpu":["x64",null],
"libc":["glibc",{"x":1}]
}"#,
);
assert_eq!(v.os, vec!["linux"]);
assert_eq!(v.cpu, vec!["x64"]);
assert_eq!(v.libc, vec!["glibc"]);
}
#[test]
fn bin_normalizes_packument_shapes() {
let missing = parse(r#"{"name":"x","version":"1.0.0"}"#);
assert!(missing.bin.is_empty(), "missing bin → empty map");
let empty_string = parse(r#"{"name":"x","version":"1.0.0","bin":""}"#);
assert!(empty_string.bin.is_empty(), "empty string bin → empty map");
let null_bin = parse(r#"{"name":"x","version":"1.0.0","bin":null}"#);
assert!(null_bin.bin.is_empty(), "null bin → empty map");
let empty_map = parse(r#"{"name":"x","version":"1.0.0","bin":{}}"#);
assert!(empty_map.bin.is_empty(), "empty map bin → empty map");
let string_bin = parse(r#"{"name":"x","version":"1.0.0","bin":"cli.js"}"#);
assert_eq!(string_bin.bin.get(""), Some(&"cli.js".to_string()));
let map_bin = parse(r#"{"name":"x","version":"1.0.0","bin":{"foo":"cli.js"}}"#);
assert_eq!(map_bin.bin.get("foo"), Some(&"cli.js".to_string()));
}
#[test]
fn bin_map_roundtrips_through_cache_serialization() {
let mut bin = BTreeMap::new();
bin.insert("semver".to_string(), "bin/semver.js".to_string());
let v = VersionMetadata {
name: "semver".to_string(),
version: "7.7.4".to_string(),
dependencies: BTreeMap::new(),
dev_dependencies: BTreeMap::new(),
peer_dependencies: BTreeMap::new(),
peer_dependencies_meta: BTreeMap::new(),
optional_dependencies: BTreeMap::new(),
bundled_dependencies: None,
dist: None,
os: Vec::new(),
cpu: Vec::new(),
libc: Vec::new(),
engines: BTreeMap::new(),
license: None,
funding_url: None,
bin,
has_install_script: false,
deprecated: None,
npm_user: None,
};
let json = serde_json::to_string(&v).unwrap();
let back: VersionMetadata = serde_json::from_str(&json).unwrap();
assert_eq!(
back.bin.get("semver"),
Some(&"bin/semver.js".to_string()),
"bin map must round-trip through cache serialization"
);
}
#[test]
fn missing_fields_default_to_empty() {
let v = parse(r#"{"name":"x","version":"1.0.0"}"#);
assert!(v.os.is_empty());
assert!(v.cpu.is_empty());
assert!(v.libc.is_empty());
}
#[test]
fn attestations_provenance_is_extracted() {
let v = parse(
r#"{"name":"x","version":"1.0.0",
"dist":{"tarball":"t","attestations":{"provenance":{"predicateType":"slsa"}}}}"#,
);
let dist = v.dist.expect("dist present");
let att = dist.attestations.expect("attestations present");
assert!(att.provenance.is_some(), "provenance present");
}
#[test]
fn attestations_missing_is_none() {
let v = parse(r#"{"name":"x","version":"1.0.0","dist":{"tarball":"t"}}"#);
let dist = v.dist.expect("dist present");
assert!(dist.attestations.is_none());
}
#[test]
fn npm_user_object_with_trusted_publisher_is_parsed() {
let v = parse(
r#"{"name":"x","version":"1.0.0",
"_npmUser":{"name":"u","email":"u@x","trustedPublisher":{"id":"gh"}}}"#,
);
let user = v.npm_user.expect("_npmUser present");
assert!(user.trusted_publisher.is_some());
}
#[test]
fn npm_user_object_without_trusted_publisher_is_parsed() {
let v = parse(r#"{"name":"x","version":"1.0.0","_npmUser":{"name":"u","email":"u@x"}}"#);
let user = v.npm_user.expect("_npmUser present");
assert!(user.trusted_publisher.is_none());
}
#[test]
fn npm_user_string_form_degrades_to_none() {
let v = parse(r#"{"name":"x","version":"1.0.0","_npmUser":"isaacs <i@npmjs.com>"}"#);
assert!(v.npm_user.is_none());
}
#[test]
fn npm_user_null_or_missing_is_none() {
let v_null = parse(r#"{"name":"x","version":"1.0.0","_npmUser":null}"#);
assert!(v_null.npm_user.is_none());
let v_missing = parse(r#"{"name":"x","version":"1.0.0"}"#);
assert!(v_missing.npm_user.is_none());
}
#[test]
fn deprecated_string_is_preserved_and_false_is_empty() {
let v = parse(r#"{"name":"x","version":"1.0.0","deprecated":"use y"}"#);
assert_eq!(v.deprecated.as_deref(), Some("use y"));
let v = parse(r#"{"name":"x","version":"1.0.1","deprecated":false}"#);
assert!(v.deprecated.is_none());
}
#[test]
fn dependency_maps_drop_null_entries() {
let v = parse(
r#"{
"name": "x",
"version": "1.0.0",
"dependencies": {"kept": "^1", "stripped": null},
"devDependencies": {"dkept": "^2", "dstripped": null},
"peerDependencies": {"pkept": "^3", "pstripped": null},
"optionalDependencies": {"okept": "^4", "ostripped": null}
}"#,
);
assert_eq!(v.dependencies.len(), 1);
assert_eq!(v.dependencies["kept"], "^1");
assert_eq!(v.dev_dependencies.len(), 1);
assert_eq!(v.peer_dependencies.len(), 1);
assert_eq!(v.optional_dependencies.len(), 1);
}
#[test]
fn dependency_maps_drop_object_valued_entries() {
let v = parse(
r#"{
"name": "deep-diff",
"version": "0.1.0",
"devDependencies": {
"vows": {"version": "0.6.4", "dependencies": {"diff": {"version": "1.0.4"}}},
"extend": {"version": "1.1.1"},
"lodash": "0.9.2"
}
}"#,
);
assert_eq!(v.dev_dependencies.len(), 1);
assert_eq!(v.dev_dependencies["lodash"], "0.9.2");
}
#[test]
fn dependency_maps_null_whole_field_is_empty() {
let v = parse(
r#"{
"name": "x",
"version": "1.0.0",
"dependencies": null,
"devDependencies": null,
"peerDependencies": null,
"optionalDependencies": null
}"#,
);
assert!(v.dependencies.is_empty());
assert!(v.dev_dependencies.is_empty());
assert!(v.peer_dependencies.is_empty());
assert!(v.optional_dependencies.is_empty());
}
fn parse_packument(json: &str) -> Packument {
serde_json::from_str(json).unwrap()
}
#[test]
fn packument_dist_tags_drops_null_tag() {
let p = parse_packument(
r#"{
"name": "pkg",
"dist-tags": {"latest": "1.2.3", "beta": null}
}"#,
);
assert_eq!(p.dist_tags.len(), 1);
assert_eq!(p.dist_tags["latest"], "1.2.3");
}
#[test]
fn packument_dist_tags_null_whole_field_is_empty() {
let p = parse_packument(r#"{"name":"pkg","dist-tags":null}"#);
assert!(p.dist_tags.is_empty());
}
#[test]
fn packument_preserves_modified_timestamp() {
let p = parse_packument(
r#"{
"name": "pkg",
"modified": "2026-04-14T14:26:11.557Z"
}"#,
);
assert_eq!(p.modified.as_deref(), Some("2026-04-14T14:26:11.557Z"));
}
#[test]
fn packument_time_drops_null_entries() {
let p = parse_packument(
r#"{
"name": "pkg",
"time": {"1.0.0": "2024-01-01T00:00:00.000Z", "0.9.0": null}
}"#,
);
assert_eq!(p.time.len(), 1);
assert!(p.time.contains_key("1.0.0"));
}
#[test]
fn packument_time_null_whole_field_is_empty() {
let p = parse_packument(r#"{"name":"pkg","time":null}"#);
assert!(p.time.is_empty());
}
#[test]
fn engines_accepts_legacy_array_shape() {
let v = parse(r#"{"name":"madge","version":"0.0.1","engines":["node >= 0.8.0"]}"#);
assert!(v.engines.is_empty());
}
#[test]
fn engines_accepts_legacy_string_shape() {
let v = parse(r#"{"name":"qs","version":"0.6.0","engines":"node >= 0.4.0"}"#);
assert!(v.engines.is_empty());
}
#[test]
fn engines_accepts_map_shape() {
let v = parse(r#"{"name":"x","version":"1.0.0","engines":{"node":">=18"}}"#);
assert_eq!(v.engines.get("node"), Some(&">=18".to_string()));
}
#[test]
fn engines_null_is_empty() {
let v = parse(r#"{"name":"x","version":"1.0.0","engines":null}"#);
assert!(v.engines.is_empty());
}
}