use std::collections::BTreeMap;
use std::collections::HashMap;
use serde::Deserialize;
use serde_json::Value;
#[derive(Debug, thiserror::Error)]
pub enum NpmLockfileImportError {
#[error("Failed to parse package-lock.json")]
Parse(#[source] serde_json::Error),
#[error(
"Unsupported package-lock.json `lockfileVersion`: {0}. Only versions 2 and 3 are supported."
)]
UnsupportedVersion(u32),
}
#[derive(Debug, Deserialize)]
struct NpmLockfile {
#[serde(rename = "lockfileVersion")]
lockfile_version: u32,
#[serde(default)]
packages: BTreeMap<String, NpmLockPackage>,
}
#[derive(Debug, Default, Deserialize)]
struct NpmLockPackage {
#[serde(default)]
name: Option<String>,
#[serde(default)]
version: Option<String>,
#[serde(default)]
integrity: Option<String>,
#[serde(default)]
link: bool,
#[serde(default)]
dependencies: BTreeMap<String, String>,
#[serde(default, rename = "devDependencies")]
dev_dependencies: BTreeMap<String, String>,
#[serde(default, rename = "optionalDependencies")]
optional_dependencies: BTreeMap<String, String>,
#[serde(default, rename = "peerDependencies")]
peer_dependencies: BTreeMap<String, String>,
#[serde(default, rename = "peerDependenciesMeta")]
peer_dependencies_meta: BTreeMap<String, Value>,
#[serde(default)]
os: Vec<String>,
#[serde(default)]
cpu: Vec<String>,
}
pub fn package_lock_to_deno_lock_v5(
json_text: &str,
) -> Result<String, NpmLockfileImportError> {
let lockfile: NpmLockfile =
serde_json::from_str(json_text).map_err(NpmLockfileImportError::Parse)?;
if !(2..=3).contains(&lockfile.lockfile_version) {
return Err(NpmLockfileImportError::UnsupportedVersion(
lockfile.lockfile_version,
));
}
let mut resolved: HashMap<&str, (String, String)> = HashMap::new();
for (path, pkg) in lockfile.packages.iter() {
if path.is_empty() || pkg.link {
continue;
}
let Some(version) = pkg.version.as_deref() else {
continue;
};
let name = match pkg.name.as_deref() {
Some(n) => n.to_string(),
None => match package_name_from_path(path) {
Some(n) => n.to_string(),
None => continue,
},
};
resolved.insert(path.as_str(), (name, version.to_string()));
}
let resolve_dep =
|from_path: &str, dep_name: &str| -> Option<&(String, String)> {
if from_path.is_empty() {
let candidate = format!("node_modules/{}", dep_name);
return resolved.get(candidate.as_str());
}
let mut prefix = from_path.to_string();
loop {
let candidate = format!("{}/node_modules/{}", prefix, dep_name);
if let Some(entry) = resolved.get(candidate.as_str()) {
return Some(entry);
}
match prefix.rfind("/node_modules/") {
Some(idx) => prefix.truncate(idx),
None => break,
}
}
let candidate = format!("node_modules/{}", dep_name);
resolved.get(candidate.as_str())
};
let mut npm = BTreeMap::<String, Value>::new();
for (path, pkg) in lockfile.packages.iter() {
if path.is_empty() || pkg.link {
continue;
}
let Some((name, version)) = resolved.get(path.as_str()) else {
continue;
};
let Some(integrity) = pkg.integrity.as_deref() else {
continue;
};
let (regular_deps, optional_peers_from_peers) = {
let mut regular: Vec<String> = Vec::new();
let mut opt_peers: Vec<String> = Vec::new();
for dep_name in pkg.dependencies.keys() {
if let Some((n, v)) = resolve_dep(path, dep_name) {
regular.push(format_dep_entry(dep_name, n, v));
}
}
for dep_name in pkg.peer_dependencies.keys() {
let is_optional = pkg
.peer_dependencies_meta
.get(dep_name)
.and_then(|v| v.get("optional"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
if let Some((n, v)) = resolve_dep(path, dep_name) {
let entry = format_dep_entry(dep_name, n, v);
if is_optional {
opt_peers.push(entry);
} else {
regular.push(entry);
}
}
}
regular.sort();
regular.dedup();
opt_peers.sort();
opt_peers.dedup();
(regular, opt_peers)
};
let optional_deps: Vec<String> = {
let mut v: Vec<String> = pkg
.optional_dependencies
.keys()
.filter_map(|dep_name| {
resolve_dep(path, dep_name)
.map(|(n, ver)| format_dep_entry(dep_name, n, ver))
})
.collect();
v.sort();
v.dedup();
v
};
let mut entry = serde_json::Map::new();
entry.insert(
"integrity".to_string(),
Value::String(integrity.to_string()),
);
if !regular_deps.is_empty() {
entry.insert(
"dependencies".to_string(),
Value::Array(regular_deps.into_iter().map(Value::String).collect()),
);
}
if !optional_deps.is_empty() {
entry.insert(
"optionalDependencies".to_string(),
Value::Array(optional_deps.into_iter().map(Value::String).collect()),
);
}
if !optional_peers_from_peers.is_empty() {
entry.insert(
"optionalPeers".to_string(),
Value::Array(
optional_peers_from_peers
.into_iter()
.map(Value::String)
.collect(),
),
);
}
if !pkg.os.is_empty() {
entry.insert(
"os".to_string(),
Value::Array(pkg.os.iter().cloned().map(Value::String).collect()),
);
}
if !pkg.cpu.is_empty() {
entry.insert(
"cpu".to_string(),
Value::Array(pkg.cpu.iter().cloned().map(Value::String).collect()),
);
}
let key = format!("{}@{}", name, version);
npm.insert(key, Value::Object(entry));
}
let mut specifiers = BTreeMap::<String, String>::new();
if let Some(root) = lockfile.packages.get("") {
let root_dep_iters = [
&root.dependencies,
&root.dev_dependencies,
&root.optional_dependencies,
&root.peer_dependencies,
];
for deps in root_dep_iters {
for (dep_name, req) in deps.iter() {
if !is_supported_root_req(req) {
continue;
}
let Some((_n, version)) = resolve_dep("", dep_name) else {
continue;
};
let key = format!("npm:{}@{}", dep_name, req);
specifiers.entry(key).or_insert_with(|| version.clone());
}
}
}
let mut output = serde_json::Map::new();
output.insert("version".to_string(), Value::String("5".to_string()));
if !specifiers.is_empty() {
output.insert(
"specifiers".to_string(),
Value::Object(
specifiers
.into_iter()
.map(|(k, v)| (k, Value::String(v)))
.collect(),
),
);
}
if !npm.is_empty() {
output.insert("npm".to_string(), Value::Object(npm.into_iter().collect()));
}
Ok(serde_json::to_string(&Value::Object(output)).unwrap())
}
fn format_dep_entry(alias: &str, name: &str, version: &str) -> String {
if alias == name {
format!("{}@{}", name, version)
} else {
format!("{}@npm:{}@{}", alias, name, version)
}
}
fn package_name_from_path(path: &str) -> Option<&str> {
if let Some(idx) = path.rfind("/node_modules/") {
Some(&path[idx + "/node_modules/".len()..])
} else {
path.strip_prefix("node_modules/")
}
}
fn is_supported_root_req(req: &str) -> bool {
!req.starts_with("file:")
&& !req.starts_with("link:")
&& !req.starts_with("workspace:")
&& !req.starts_with("git+")
&& !req.starts_with("git:")
&& !req.starts_with("github:")
&& !req.starts_with("http:")
&& !req.starts_with("https:")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn translates_simple_package_lock() {
let input = r#"{
"name": "myapp",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "myapp",
"version": "1.0.0",
"dependencies": {
"lodash": "^4.17.0"
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
}
}
}"#;
let out = package_lock_to_deno_lock_v5(input).unwrap();
let v: Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["version"], "5");
assert_eq!(v["specifiers"]["npm:lodash@^4.17.0"], "4.17.21");
let lodash = &v["npm"]["lodash@4.17.21"];
assert!(lodash["integrity"].as_str().unwrap().starts_with("sha512-"));
}
#[test]
fn nested_dependency_resolution() {
let input = r#"{
"name": "myapp",
"version": "1.0.0",
"lockfileVersion": 3,
"packages": {
"": {
"dependencies": { "a": "^1" }
},
"node_modules/a": {
"version": "1.0.0",
"integrity": "sha512-AAAAA",
"dependencies": { "b": "^2" }
},
"node_modules/a/node_modules/b": {
"version": "2.0.0",
"integrity": "sha512-BBBBB"
},
"node_modules/b": {
"version": "1.0.0",
"integrity": "sha512-BBB1"
}
}
}"#;
let out = package_lock_to_deno_lock_v5(input).unwrap();
let v: Value = serde_json::from_str(&out).unwrap();
let a_deps = v["npm"]["a@1.0.0"]["dependencies"].as_array().unwrap();
assert_eq!(a_deps[0], "b@2.0.0");
assert!(v["npm"].as_object().unwrap().contains_key("b@1.0.0"));
assert!(v["npm"].as_object().unwrap().contains_key("b@2.0.0"));
}
#[test]
fn scoped_packages() {
let input = r#"{
"lockfileVersion": 3,
"packages": {
"": {
"dependencies": { "@scope/pkg": "^1" }
},
"node_modules/@scope/pkg": {
"version": "1.2.3",
"integrity": "sha512-XXXX"
}
}
}"#;
let out = package_lock_to_deno_lock_v5(input).unwrap();
let v: Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["specifiers"]["npm:@scope/pkg@^1"], "1.2.3");
assert!(
v["npm"]
.as_object()
.unwrap()
.contains_key("@scope/pkg@1.2.3")
);
}
#[test]
fn skips_workspace_links() {
let input = r#"{
"lockfileVersion": 3,
"packages": {
"": {
"dependencies": { "ws-pkg": "*", "lodash": "^4" }
},
"node_modules/ws-pkg": {
"resolved": "../ws-pkg",
"link": true
},
"node_modules/lodash": {
"version": "4.17.21",
"integrity": "sha512-AAA"
}
}
}"#;
let out = package_lock_to_deno_lock_v5(input).unwrap();
let v: Value = serde_json::from_str(&out).unwrap();
assert!(v["specifiers"].get("npm:ws-pkg@*").is_none());
assert!(v["npm"].as_object().unwrap().get("ws-pkg").is_none());
assert_eq!(v["specifiers"]["npm:lodash@^4"], "4.17.21");
}
#[test]
fn rejects_v1_lockfile() {
let input = r#"{ "lockfileVersion": 1, "packages": {} }"#;
let err = package_lock_to_deno_lock_v5(input).unwrap_err();
assert!(matches!(err, NpmLockfileImportError::UnsupportedVersion(1)));
}
#[test]
fn rejects_unknown_future_lockfile() {
let input = r#"{ "lockfileVersion": 4, "packages": {} }"#;
let err = package_lock_to_deno_lock_v5(input).unwrap_err();
assert!(matches!(err, NpmLockfileImportError::UnsupportedVersion(4)));
}
}