use std::path::Path;
use serde_json::{Map, Value};
use super::{manifest, spec};
use crate::registry::{Omission, Registry, ResolveEvent};
#[derive(Debug, Clone)]
pub struct Lockfile {
pub version: u64,
pub packages: Vec<LockedPackage>,
}
#[derive(Debug, Clone)]
pub struct LockedPackage {
pub key: String,
pub name: String,
pub version: String,
pub resolved: Option<String>,
pub integrity: Option<String>,
pub license: Option<String>,
pub dev: bool,
pub optional: bool,
pub dev_optional: bool,
pub link: bool,
pub os: Vec<String>,
pub cpu: Vec<String>,
pub bin: Vec<(String, String)>,
}
impl Lockfile {
pub fn parse(s: &str) -> Result<Lockfile, Box<dyn std::error::Error + Send + Sync>> {
let json: Value = serde_json::from_str(s)?;
let version = json
.get("lockfileVersion")
.and_then(Value::as_u64)
.unwrap_or(0);
if version < 2 {
return Err(format!(
"package-lock.json lockfileVersion {version} is unsupported \
(need 2 or 3, which carry the `packages` map)"
)
.into());
}
let packages = json
.get("packages")
.and_then(Value::as_object)
.ok_or("package-lock.json has no `packages` map")?;
let mut out: Vec<LockedPackage> = packages
.iter()
.filter_map(|(key, entry)| {
entry
.as_object()
.map(|entry| LockedPackage::from_entry(key, entry))
})
.collect();
out.sort_by(|a, b| a.key.cmp(&b.key));
Ok(Lockfile {
version,
packages: out,
})
}
pub fn installable(&self, host_os: &str, host_arch: &str) -> Vec<&LockedPackage> {
self.packages
.iter()
.filter(|p| p.key.starts_with("node_modules/") && !p.link)
.filter(|p| p.matches_platform(host_os, host_arch))
.collect()
}
}
impl crate::package_json::License for LockedPackage {
fn license(&self) -> Option<String> {
self.license.clone()
}
}
#[derive(Debug, Clone)]
pub struct LockEntry {
pub name: String,
pub version: String,
pub resolved: String,
pub integrity: Option<String>,
pub license: Option<String>,
}
pub fn render_v3(
root_name: &str,
root_version: &str,
direct: &[(String, String)],
entries: &[LockEntry],
) -> String {
use serde_json::json;
let mut packages = Map::new();
let mut root = Map::new();
root.insert("name".into(), json!(root_name));
root.insert("version".into(), json!(root_version));
if !direct.is_empty() {
let mut deps = Map::new();
for (name, range) in direct {
deps.insert(name.clone(), json!(range));
}
root.insert("dependencies".into(), Value::Object(deps));
}
packages.insert(String::new(), Value::Object(root));
for entry in entries {
let mut pkg = Map::new();
pkg.insert("version".into(), json!(entry.version));
pkg.insert("resolved".into(), json!(entry.resolved));
if let Some(integrity) = &entry.integrity {
pkg.insert("integrity".into(), json!(integrity));
}
if let Some(license) = &entry.license {
pkg.insert("license".into(), json!(license));
}
packages.insert(format!("node_modules/{}", entry.name), Value::Object(pkg));
}
let doc = json!({
"name": root_name,
"version": root_version,
"lockfileVersion": 3,
"requires": true,
"packages": Value::Object(packages),
});
let mut out = serde_json::to_string_pretty(&doc).expect("serialize package-lock.json");
out.push('\n');
out
}
pub fn render_v3_from_manifest(
doc: &Value,
registry: &Registry,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
render_v3_from_manifest_observed(doc, registry, |_| {})
}
pub(crate) fn render_v3_from_manifest_observed(
doc: &Value,
registry: &Registry,
on_resolve: impl Fn(ResolveEvent<'_>) + Sync,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
let direct = manifest::dependencies(doc);
let roots = registry_roots(doc)?;
let entries: Vec<LockEntry> = registry
.resolve_tree_observed(&roots, on_resolve)?
.into_iter()
.map(|r| LockEntry {
name: r.name,
version: r.version.to_string(),
resolved: r.tarball_url,
integrity: r.integrity,
license: r.license,
})
.collect();
let name = doc.get("name").and_then(Value::as_str).unwrap_or("");
let version = doc
.get("version")
.and_then(Value::as_str)
.unwrap_or("0.0.0");
Ok(render_v3(name, version, &direct, &entries))
}
pub(crate) fn registry_roots(
doc: &Value,
) -> Result<Vec<(String, spec::Range)>, Box<dyn std::error::Error + Send + Sync>> {
manifest::dependencies(doc)
.iter()
.filter(|(_, range)| spec::Spec::parse(range).is_registry())
.map(|(name, range)| Ok((name.clone(), spec::Range::parse(range)?)))
.collect()
}
#[cfg_attr(not(feature = "cli"), allow(dead_code))]
pub(crate) fn audit_roots(
doc: &Value,
) -> Result<AuditRoots, Box<dyn std::error::Error + Send + Sync>> {
let mut merged: Vec<(String, String, bool)> = manifest::dependencies(doc)
.into_iter()
.map(|(name, spec_text)| (name, spec_text, false))
.collect();
for (name, spec_text) in manifest::optional_dependencies(doc) {
match merged.iter_mut().find(|(existing, ..)| *existing == name) {
Some(entry) => *entry = (name, spec_text, true),
None => merged.push((name, spec_text, true)),
}
}
let mut roots = Vec::new();
let mut omissions = Vec::new();
for (name, spec_text, optional) in merged {
let action = crate::registry::classify_dep(&name, &spec_text)
.map_err(|e| format!("package.json dependency `{name}`: {e}"))?;
match action {
crate::registry::EdgeAction::Resolve { name, range } => {
roots.push((name, range, optional))
}
crate::registry::EdgeAction::Omit(omission) => omissions.push(omission),
}
}
if has_workspaces(doc) {
omissions.push(Omission::new("workspaces", "", "not traversed"));
}
Ok((roots, omissions))
}
pub(crate) type AuditRoots = (Vec<(String, spec::Range, bool)>, Vec<Omission>);
#[cfg_attr(not(feature = "cli"), allow(dead_code))]
fn has_workspaces(doc: &Value) -> bool {
match doc.get("workspaces") {
Some(Value::Array(list)) => !list.is_empty(),
Some(Value::Object(map)) => map
.get("packages")
.and_then(Value::as_array)
.is_some_and(|list| !list.is_empty()),
_ => false,
}
}
pub fn write_from_manifest(
manifest_path: &Path,
lockfile_path: &Path,
registry: &Registry,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
write_from_manifest_observed(manifest_path, lockfile_path, registry, |_| {})
}
pub(crate) fn write_from_manifest_observed(
manifest_path: &Path,
lockfile_path: &Path,
registry: &Registry,
on_resolve: impl Fn(ResolveEvent<'_>) + Sync,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let text = std::fs::read_to_string(manifest_path)
.map_err(|e| format!("reading {}: {e}", manifest_path.display()))?;
let doc: Value = serde_json::from_str(&text)
.map_err(|e| format!("parsing {}: {e}", manifest_path.display()))?;
let lockfile = render_v3_from_manifest_observed(&doc, registry, on_resolve)?;
std::fs::write(lockfile_path, lockfile)
.map_err(|e| format!("writing {}: {e}", lockfile_path.display()))?;
Ok(())
}
impl LockedPackage {
fn from_entry(key: &str, entry: &Map<String, Value>) -> LockedPackage {
let name = entry
.get("name")
.and_then(Value::as_str)
.filter(|n| !n.is_empty())
.map(str::to_string)
.unwrap_or_else(|| {
key.rsplit_once("node_modules/")
.map(|(_, n)| n)
.unwrap_or(key)
.to_string()
});
LockedPackage {
bin: bin_entries(entry, &name),
key: key.to_string(),
name,
version: string_field(entry, "version"),
resolved: opt_string(entry, "resolved"),
integrity: opt_string(entry, "integrity"),
license: opt_string(entry, "license"),
dev: bool_field(entry, "dev"),
optional: bool_field(entry, "optional"),
dev_optional: bool_field(entry, "devOptional"),
link: bool_field(entry, "link"),
os: string_list(entry, "os"),
cpu: string_list(entry, "cpu"),
}
}
pub fn is_registry_tarball(&self) -> bool {
self.resolved
.as_deref()
.is_some_and(|r| r.starts_with("https://") || r.starts_with("http://"))
}
pub fn matches_platform(&self, host_os: &str, host_arch: &str) -> bool {
constraint_allows(&self.os, node_os(host_os))
&& constraint_allows(&self.cpu, node_cpu(host_arch))
}
}
pub fn constraint_allows(constraint: &[String], host: &str) -> bool {
let mut has_positive = false;
let mut matched_positive = false;
for item in constraint {
if let Some(excluded) = item.strip_prefix('!') {
if excluded == host {
return false;
}
} else {
has_positive = true;
if item == host {
matched_positive = true;
}
}
}
!has_positive || matched_positive
}
const OS_MAP: &[(&str, &str)] = &[("macos", "darwin"), ("windows", "win32")];
const CPU_MAP: &[(&str, &str)] = &[("x86_64", "x64"), ("aarch64", "arm64"), ("x86", "ia32")];
fn node_os(rust: &str) -> &str {
map_value(rust, OS_MAP)
}
fn node_cpu(rust: &str) -> &str {
map_value(rust, CPU_MAP)
}
fn map_value<'a>(rust: &'a str, map: &[(&'static str, &'static str)]) -> &'a str {
map.iter()
.find(|(r, _)| *r == rust)
.map(|(_, n)| *n)
.unwrap_or(rust)
}
fn string_field(entry: &Map<String, Value>, key: &str) -> String {
entry
.get(key)
.and_then(Value::as_str)
.unwrap_or_default()
.to_string()
}
fn opt_string(entry: &Map<String, Value>, key: &str) -> Option<String> {
entry.get(key).and_then(Value::as_str).map(str::to_string)
}
fn bool_field(entry: &Map<String, Value>, key: &str) -> bool {
entry.get(key).and_then(Value::as_bool).unwrap_or(false)
}
fn string_list(entry: &Map<String, Value>, key: &str) -> Vec<String> {
entry
.get(key)
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect()
})
.unwrap_or_default()
}
fn bin_entries(entry: &Map<String, Value>, name: &str) -> Vec<(String, String)> {
match entry.get("bin") {
Some(Value::String(path)) => {
let bin_name = name.rsplit('/').next().unwrap_or(name).to_string();
vec![(bin_name, path.clone())]
}
Some(Value::Object(map)) => map
.iter()
.filter_map(|(n, v)| v.as_str().map(|p| (n.clone(), p.to_string())))
.collect(),
_ => Vec::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_LOCK: &str = r#"{
"name": "harness",
"lockfileVersion": 3,
"packages": {
"": { "name": "harness", "devDependencies": { "typescript": "^5" } },
"node_modules/@scope/pkg": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@scope/pkg/-/pkg-1.2.3.tgz",
"integrity": "sha512-BBBB"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-AAAA",
"dev": true,
"bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-CCCC",
"dev": true,
"optional": true,
"os": ["darwin"]
},
"node_modules/local-link": { "resolved": "file:../local", "link": true }
}
}"#;
fn names(packages: &[&LockedPackage]) -> Vec<String> {
packages.iter().map(|p| p.name.clone()).collect()
}
#[test]
fn parses_fields_and_selects_installable_per_host() {
let lock = Lockfile::parse(SAMPLE_LOCK).unwrap();
assert_eq!(lock.version, 3);
assert_eq!(
names(&lock.installable("linux", "x86_64")),
["@scope/pkg", "typescript"]
);
assert_eq!(
names(&lock.installable("macos", "aarch64")),
["@scope/pkg", "fsevents", "typescript"]
);
let ts = lock
.packages
.iter()
.find(|p| p.name == "typescript")
.unwrap();
assert!(ts.dev);
assert_eq!(ts.integrity.as_deref(), Some("sha512-AAAA"));
assert!(ts.bin.iter().any(|(n, p)| n == "tsc" && p == "bin/tsc"));
assert!(ts.bin.iter().any(|(n, _)| n == "tsserver"));
assert!(lock.packages.iter().any(|p| p.link));
}
#[test]
fn name_field_wins_over_the_install_path() {
let lock = Lockfile::parse(
r#"{
"name": "ws", "lockfileVersion": 3,
"packages": {
"": { "name": "ws" },
"node_modules/lodash-alias": {
"name": "lodash",
"version": "4.17.11",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz",
"bin": "cli.js"
},
"app/node_modules/minimist": { "version": "1.2.0" }
}
}"#,
)
.unwrap();
let by_key = |k: &str| lock.packages.iter().find(|p| p.key == k).unwrap();
assert_eq!(by_key("node_modules/lodash-alias").name, "lodash");
assert_eq!(
by_key("node_modules/lodash-alias").bin,
[("lodash".to_string(), "cli.js".to_string())]
);
assert_eq!(by_key("app/node_modules/minimist").name, "minimist");
assert_eq!(by_key("").name, "ws");
}
#[test]
fn distinguishes_registry_tarballs_from_other_sources() {
let lock = Lockfile::parse(SAMPLE_LOCK).unwrap();
let ts = lock
.packages
.iter()
.find(|p| p.name == "typescript")
.unwrap();
assert!(
ts.is_registry_tarball(),
"https resolved is a registry tarball"
);
let link = lock.packages.iter().find(|p| p.link).unwrap();
assert!(!link.is_registry_tarball(), "a file: link is not");
}
#[test]
fn rejects_lockfile_version_1() {
assert!(Lockfile::parse(r#"{"lockfileVersion":1,"dependencies":{}}"#).is_err());
}
#[test]
fn constraint_allows_follows_npm_os_cpu_rules() {
let v = |xs: &[&str]| xs.iter().map(|s| s.to_string()).collect::<Vec<_>>();
assert!(constraint_allows(&[], "linux"), "no constraint allows all");
assert!(constraint_allows(&v(&["linux"]), "linux"));
assert!(!constraint_allows(&v(&["darwin"]), "linux"));
assert!(constraint_allows(&v(&["darwin", "linux"]), "linux"));
assert!(constraint_allows(&v(&["!win32"]), "linux"));
assert!(!constraint_allows(&v(&["!linux"]), "linux"));
}
#[test]
fn matches_platform_maps_rust_host_to_npm_spelling() {
let lock = Lockfile::parse(SAMPLE_LOCK).unwrap();
let fsevents = lock.packages.iter().find(|p| p.name == "fsevents").unwrap();
assert!(!fsevents.matches_platform("linux", "x86_64"));
assert!(fsevents.matches_platform("macos", "aarch64"));
}
#[test]
fn render_v3_emits_npm_order_and_round_trips_through_parse() {
let entries = vec![
LockEntry {
name: "ms".into(),
version: "2.1.3".into(),
resolved: "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz".into(),
integrity: Some("sha512-MS".into()),
license: Some("MIT".into()),
},
LockEntry {
name: "@scope/pkg".into(),
version: "1.0.0".into(),
resolved: "https://registry.npmjs.org/@scope/pkg/-/pkg-1.0.0.tgz".into(),
integrity: Some("sha512-SP".into()),
license: None,
},
];
let direct = vec![("ms".to_string(), "^2".to_string())];
let json = render_v3("fixture", "1.0.0", &direct, &entries);
let doc: Value = serde_json::from_str(&json).unwrap();
let keys: Vec<&str> = doc
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect();
assert_eq!(
keys,
["name", "version", "lockfileVersion", "requires", "packages"]
);
assert_eq!(doc["packages"][""]["dependencies"]["ms"], "^2");
assert_eq!(doc["packages"]["node_modules/ms"]["license"], "MIT");
assert!(doc["packages"]["node_modules/@scope/pkg"]
.get("license")
.is_none());
let lock = Lockfile::parse(&json).unwrap();
assert_eq!(lock.version, 3);
let names: Vec<&str> = lock
.installable("linux", "x86_64")
.iter()
.map(|p| p.name.as_str())
.collect();
assert_eq!(names, ["@scope/pkg", "ms"]);
let ms = lock.packages.iter().find(|p| p.name == "ms").unwrap();
assert_eq!(ms.integrity.as_deref(), Some("sha512-MS"));
assert!(
ms.is_registry_tarball(),
"resolved is an https registry tarball"
);
}
#[test]
fn audit_roots_merges_optional_over_regular_and_flags_it() {
let doc = serde_json::json!({
"dependencies": { "a": "^1", "x": "^1" },
"optionalDependencies": { "x": "^2", "opt": "^3" }
});
let (roots, omissions) = audit_roots(&doc).unwrap();
let flat: Vec<(String, String, bool)> = roots
.into_iter()
.map(|(n, r, o)| (n, r.to_string(), o))
.collect();
assert_eq!(
flat,
[
("a".to_string(), "^1".to_string(), false),
("x".to_string(), "^2".to_string(), true),
("opt".to_string(), "^3".to_string(), true),
],
"the optional entry overrides the regular one in place, flag flipped"
);
assert!(omissions.is_empty());
}
#[test]
fn audit_roots_records_non_registry_specs_and_workspaces_as_omissions() {
let doc = serde_json::json!({
"dependencies": {
"g": "git+ssh://git@github.com/x/y.git",
"local": "file:../local",
"w": "workspace:*",
"keep": "^1"
},
"workspaces": ["packages/*"]
});
let (roots, omissions) = audit_roots(&doc).unwrap();
assert_eq!(roots.len(), 1, "only the registry dep resolves");
assert_eq!(roots[0].0, "keep");
let rendered: Vec<String> = omissions.iter().map(ToString::to_string).collect();
assert_eq!(
rendered,
[
"g (git+ssh://git@github.com/x/y.git: git dependency)",
"local (file:../local: local path)",
"w (workspace:*: workspace: protocol)",
"workspaces (not traversed)",
],
"verbatim spec text, and workspace:* no longer aborts the audit"
);
}
#[test]
fn audit_roots_resolves_an_alias_target_and_rejects_garbage() {
let doc = serde_json::json!({ "dependencies": { "aliased": "npm:real@^2" } });
let (roots, omissions) = audit_roots(&doc).unwrap();
assert_eq!(roots[0].0, "real", "the alias target is what gets audited");
assert!(omissions.is_empty());
let doc = serde_json::json!({ "dependencies": { "bad": "%% nope %%" } });
let err = audit_roots(&doc).unwrap_err().to_string();
assert!(
err.contains("package.json dependency `bad`"),
"malformed ranges stay hard errors naming the dependency: {err}"
);
}
#[test]
fn has_workspaces_reads_both_declaration_forms() {
assert!(has_workspaces(&serde_json::json!({ "workspaces": ["a"] })));
assert!(has_workspaces(
&serde_json::json!({ "workspaces": { "packages": ["a"] } })
));
assert!(!has_workspaces(&serde_json::json!({ "workspaces": [] })));
assert!(!has_workspaces(&serde_json::json!({ "name": "x" })));
}
}