use std::path::Path;
use serde_json::Value;
#[derive(Debug, PartialEq, Eq)]
pub struct Foreign {
pub name: String,
pub source: String,
}
pub fn scan(dir: &Path) -> Vec<Foreign> {
let read = |file: &str| std::fs::read_to_string(dir.join(file)).ok();
let mut found = Vec::new();
if let Some(text) = read("package-lock.json") {
found.extend(npm(&text));
}
if let Some(text) = read("pnpm-lock.yaml") {
found.extend(pnpm(&text));
}
if let Some(text) = read("yarn.lock") {
found.extend(yarn(&text));
}
if let Some(text) = read("bun.lock") {
found.extend(bun(&text));
}
if let Some(text) = read("Cargo.lock") {
found.extend(cargo(&text));
}
found.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.source.cmp(&b.source)));
found.dedup();
found
}
fn outside_registry(spec: &str) -> bool {
let spec = spec.trim();
if [
"git+",
"git:",
"git@",
"ssh:",
"github:",
"gitlab:",
"bitbucket:",
"gist:",
]
.iter()
.any(|prefix| spec.starts_with(prefix))
{
return true;
}
if spec.starts_with("http://") || spec.starts_with("https://") {
return !spec.contains("/-/");
}
["file:", "link:", "portal:"]
.iter()
.find_map(|prefix| spec.strip_prefix(prefix))
.is_some_and(|path| path.starts_with("..") || path.starts_with('/'))
}
fn foreign(name: &str, source: &str) -> Foreign {
Foreign {
name: name.to_owned(),
source: source.to_owned(),
}
}
fn npm(json: &str) -> Vec<Foreign> {
let Ok(value) = serde_json::from_str::<Value>(json) else {
return Vec::new();
};
let Some(packages) = value.get("packages").and_then(Value::as_object) else {
return Vec::new();
};
packages
.iter()
.filter_map(|(path, entry)| {
let name = path.rsplit_once("node_modules/")?.1;
let resolved = entry.get("resolved")?.as_str()?;
let source = if entry.get("link").and_then(Value::as_bool) == Some(true) {
if !resolved.starts_with("..") {
return None;
}
format!("link:{resolved}")
} else if outside_registry(resolved) {
resolved.to_owned()
} else {
return None;
};
Some(foreign(name, &source))
})
.collect()
}
fn pnpm(yaml: &str) -> Vec<Foreign> {
let mut found = Vec::new();
let mut name = String::new();
for line in yaml.lines() {
let trimmed = line.trim();
if let Some(key) = trimmed
.strip_suffix(':')
.filter(|_| line.starts_with(" ") && !line.starts_with(" "))
{
let key = key.trim_matches(|c| c == '\'' || c == '"');
name = key_name(key).to_owned();
} else if let Some(spec) = trimmed.strip_prefix("resolution:") {
let spec = spec.trim().trim_start_matches('{').trim_end_matches('}');
let field = |key: &str| {
spec.split(',')
.find_map(|part| part.trim().strip_prefix(key))
.map(|value| value.trim().trim_matches(|c| c == '\'' || c == '"'))
};
let source = if let Some(url) = field("tarball:") {
Some(url.to_owned())
} else if field("type:") == Some("git") {
field("repo:").map(|repo| format!("git+{repo}"))
} else {
field("directory:")
.filter(|path| path.starts_with(".."))
.map(|path| format!("file:{path}"))
};
if let Some(source) = source.filter(|s| s.starts_with("git+") || outside_registry(s)) {
found.push(foreign(&name, &source));
}
} else if let Some(version) = trimmed.strip_prefix("version:") {
let version = version.trim().trim_matches(|c| c == '\'' || c == '"');
if version.starts_with("link:") && outside_registry(version) {
found.push(foreign(&name, version));
}
} else if line.starts_with(" ")
&& !line.starts_with(" ")
&& trimmed.ends_with(':')
{
name = trimmed
.trim_end_matches(':')
.trim_matches(|c| c == '\'' || c == '"')
.to_owned();
}
}
found
}
fn key_name(key: &str) -> &str {
let key = key.strip_prefix('/').unwrap_or(key);
match key.get(1..).and_then(|rest| rest.find('@')) {
Some(at) => &key[..at + 1],
None => key,
}
}
fn yarn(text: &str) -> Vec<Foreign> {
let mut found = Vec::new();
let mut name = String::new();
for line in text.lines() {
if !line.starts_with(' ') && line.ends_with(':') && !line.starts_with('#') {
let first = line.trim_end_matches(':').split(", ").next().unwrap_or("");
name = key_name(first.trim_matches('"')).to_owned();
continue;
}
let trimmed = line.trim();
let source = if let Some(url) = trimmed.strip_prefix("resolved ") {
Some(
url.trim_matches('"')
.split('#')
.next()
.unwrap_or("")
.to_owned(),
)
.filter(|url| outside_registry(url) || url.contains(".git"))
} else if let Some(resolution) = trimmed.strip_prefix("resolution:") {
let resolution = resolution.trim().trim_matches('"');
let spec = resolution
.get(1..)
.and_then(|rest| rest.find('@'))
.map(|at| &resolution[at + 2..]);
spec.filter(|spec| outside_registry(spec) || spec.contains(".git"))
.map(str::to_owned)
} else {
None
};
if let Some(source) = source {
found.push(foreign(&name, &source));
}
}
found
}
fn bun(text: &str) -> Vec<Foreign> {
let Ok(value) = serde_json::from_str::<Value>(&without_trailing_commas(text)) else {
return Vec::new();
};
let Some(packages) = value.get("packages").and_then(Value::as_object) else {
return Vec::new();
};
packages
.values()
.filter_map(|entry| {
let ident = entry.get(0)?.as_str()?;
let at = ident.get(1..)?.find('@')? + 1;
let (name, spec) = (&ident[..at], &ident[at + 1..]);
(outside_registry(spec) || spec.contains(".git")).then(|| foreign(name, spec))
})
.collect()
}
fn without_trailing_commas(text: &str) -> String {
let chars: Vec<char> = text.chars().collect();
let mut out = String::with_capacity(text.len());
let (mut in_string, mut escaped) = (false, false);
for (i, &c) in chars.iter().enumerate() {
if in_string {
escaped = !escaped && c == '\\';
if c == '"' && !escaped {
in_string = false;
}
} else if c == '"' {
in_string = true;
} else if c == ','
&& chars[i + 1..]
.iter()
.find(|c| !c.is_whitespace())
.is_some_and(|next| matches!(next, '}' | ']'))
{
continue;
}
out.push(c);
}
out
}
fn cargo(toml: &str) -> Vec<Foreign> {
let mut found = Vec::new();
let mut name = "";
for line in toml.lines() {
let value = |key: &str| {
line.strip_prefix(key)
.and_then(|rest| rest.strip_prefix(" = \""))
.and_then(|rest| rest.strip_suffix('"'))
};
if let Some(n) = value("name") {
name = n;
} else if let Some(source) = value("source").filter(|s| s.starts_with("git+")) {
found.push(foreign(name, source));
}
}
found
}
#[cfg(test)]
mod tests {
use super::*;
fn pairs(found: Vec<Foreign>) -> Vec<(String, String)> {
found.into_iter().map(|f| (f.name, f.source)).collect()
}
#[test]
fn npm_lockfile_names_git_tarball_and_outside_links() {
let found = npm(r#"{"packages": {
"": {"name": "app"},
"node_modules/ok": {"version": "1.0.0", "resolved": "https://registry.npmjs.org/ok/-/ok-1.0.0.tgz"},
"node_modules/@s/ok": {"resolved": "https://registry.npmjs.org/@s/ok/-/ok-1.0.0.tgz"},
"node_modules/gitdep": {"resolved": "git+ssh://git@github.com/o/gitdep.git#abc"},
"node_modules/tar": {"resolved": "https://example.com/tar-1.0.0.tgz"},
"node_modules/inner": {"resolved": "packages/inner", "link": true},
"node_modules/outer": {"resolved": "../outer", "link": true}
}}"#);
assert_eq!(
pairs(found),
[
(
"gitdep".to_owned(),
"git+ssh://git@github.com/o/gitdep.git#abc".to_owned()
),
("outer".to_owned(), "link:../outer".to_owned()),
(
"tar".to_owned(),
"https://example.com/tar-1.0.0.tgz".to_owned()
),
]
);
}
#[test]
fn pnpm_lockfile_names_git_tarball_and_outside_links() {
let found = pnpm(
r#"lockfileVersion: '9.0'
importers:
.:
dependencies:
shared:
specifier: link:../shared
version: link:../shared
inner:
specifier: link:packages/inner
version: link:packages/inner
packages:
ok@1.0.0:
resolution: {integrity: sha512-abc}
'@s/tar@https://example.com/tar-1.0.0.tgz':
resolution: {tarball: https://example.com/tar-1.0.0.tgz}
gitdep@https://codeload.github.com/o/gitdep/tar.gz/abc:
resolution: {type: git, repo: https://github.com/o/gitdep.git, commit: abc}
"#,
);
assert_eq!(
pairs(found),
[
("shared", "link:../shared"),
("@s/tar", "https://example.com/tar-1.0.0.tgz"),
("gitdep", "git+https://github.com/o/gitdep.git"),
]
.map(|(n, s)| (n.to_owned(), s.to_owned()))
);
}
#[test]
fn yarn_classic_and_berry_lockfiles_are_read() {
let classic = yarn(
"ok@^1.0.0:\n version \"1.0.0\"\n resolved \"https://registry.yarnpkg.com/ok/-/ok-1.0.0.tgz#abc\"\n\n\
\"gitdep@github:o/gitdep\":\n version \"1.0.0\"\n resolved \"https://codeload.github.com/o/gitdep/tar.gz/abc\"\n",
);
assert_eq!(
pairs(classic),
[(
"gitdep".to_owned(),
"https://codeload.github.com/o/gitdep/tar.gz/abc".to_owned()
)]
);
let berry = yarn(
"\"ok@npm:^1.0.0\":\n version: 1.0.0\n resolution: \"ok@npm:1.0.0\"\n\n\
\"outer@file:../outer::locator=app%40workspace%3A.\":\n version: 0.0.0-use.local\n resolution: \"outer@file:../outer#../outer::hash=1&locator=app%40workspace%3A.\"\n\n\
\"inner@workspace:packages/inner\":\n resolution: \"inner@workspace:packages/inner\"\n\n\
\"gitdep@https://github.com/o/gitdep.git#commit=abc\":\n resolution: \"gitdep@https://github.com/o/gitdep.git#commit=abc\"\n",
);
let names: Vec<_> = berry.iter().map(|f| f.name.as_str()).collect();
assert_eq!(names, ["outer", "gitdep"]);
}
#[test]
fn bun_lockfile_tolerates_trailing_commas() {
let found = bun(r#"{
"lockfileVersion": 1,
"packages": {
"ok": ["ok@1.0.0", "", {}, "sha512-abc"],
"gitdep": ["gitdep@github:o/gitdep#abc", {}, "o-gitdep-abc"],
"outer": ["outer@file:../outer", {}],
"inner": ["inner@workspace:packages/inner"],
},
}"#);
assert_eq!(
pairs(found),
[
("gitdep", "github:o/gitdep#abc"),
("outer", "file:../outer")
]
.map(|(n, s)| (n.to_owned(), s.to_owned()))
);
}
#[test]
fn cargo_lockfile_names_git_sources_only() {
let found = cargo(
"[[package]]\nname = \"local\"\nversion = \"0.1.0\"\n\n\
[[package]]\nname = \"serde\"\nversion = \"1.0.0\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\n\n\
[[package]]\nname = \"forked\"\nversion = \"0.2.0\"\nsource = \"git+https://github.com/o/forked?branch=x#abc\"\n",
);
assert_eq!(
pairs(found),
[(
"forked".to_owned(),
"git+https://github.com/o/forked?branch=x#abc".to_owned()
)]
);
}
}