use std::path::Path;
#[derive(Debug, Clone, Default)]
pub struct DetectResult {
pub purl: Option<String>,
pub warnings: Vec<String>,
}
pub async fn detect_product(cwd: &Path) -> DetectResult {
let mut result = DetectResult::default();
if let Some(purl) = detect_git_remote(cwd).await {
result.purl = Some(purl);
return result;
}
let pkg_json = cwd.join("package.json");
let pyproject = cwd.join("pyproject.toml");
let cargo = cwd.join("Cargo.toml");
let pkg_json_exists = tokio::fs::metadata(&pkg_json).await.is_ok();
let pyproject_exists = tokio::fs::metadata(&pyproject).await.is_ok();
let cargo_exists = tokio::fs::metadata(&cargo).await.is_ok();
let present_count = [pkg_json_exists, pyproject_exists, cargo_exists]
.iter()
.filter(|b| **b)
.count();
if present_count > 1 {
let mut found = Vec::new();
if pkg_json_exists {
found.push("package.json");
}
if pyproject_exists {
found.push("pyproject.toml");
}
if cargo_exists {
found.push("Cargo.toml");
}
result.warnings.push(format!(
"Multiple project manifests detected ({}); using {} for the top-level product",
found.join(", "),
found[0]
));
}
if pkg_json_exists {
if let Some(purl) = read_package_json(&pkg_json).await {
result.purl = Some(purl);
return result;
}
}
if pyproject_exists {
if let Some(purl) = read_pyproject(&pyproject).await {
result.purl = Some(purl);
return result;
}
}
if cargo_exists {
if let Some(purl) = read_cargo_toml(&cargo).await {
result.purl = Some(purl);
return result;
}
}
result
}
async fn read_package_json(path: &Path) -> Option<String> {
let content = tokio::fs::read_to_string(path).await.ok()?;
let v: serde_json::Value = serde_json::from_str(&content).ok()?;
let name = v.get("name")?.as_str()?;
let version = v.get("version")?.as_str()?;
if name.is_empty() || version.is_empty() {
return None;
}
Some(format!("pkg:npm/{name}@{version}"))
}
async fn read_pyproject(path: &Path) -> Option<String> {
let content = tokio::fs::read_to_string(path).await.ok()?;
let (name, version) = scan_toml_section(&content, "project")
.or_else(|| scan_toml_section(&content, "tool.poetry"))?;
Some(format!("pkg:pypi/{name}@{version}"))
}
async fn read_cargo_toml(path: &Path) -> Option<String> {
let content = tokio::fs::read_to_string(path).await.ok()?;
let (name, version) = scan_toml_section(&content, "package")?;
Some(format!("pkg:cargo/{name}@{version}"))
}
fn scan_toml_section(content: &str, section: &str) -> Option<(String, String)> {
let mut in_section = false;
let mut name: Option<String> = None;
let mut version: Option<String> = None;
let header = format!("[{section}]");
for raw in content.lines() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if line.starts_with('[') {
in_section = line == header;
continue;
}
if !in_section {
continue;
}
if let Some(v) = parse_toml_string_kv(line, "name") {
name = Some(v);
} else if let Some(v) = parse_toml_string_kv(line, "version") {
version = Some(v);
}
}
let name = name?;
let version = version?;
if name.is_empty() || version.is_empty() {
return None;
}
Some((name, version))
}
async fn detect_git_remote(start: &Path) -> Option<String> {
let git_config_path = find_git_config(start).await?;
let content = tokio::fs::read_to_string(&git_config_path).await.ok()?;
let url = scan_remote_origin_url(&content)?;
Some(remote_url_to_purl(&url))
}
async fn find_git_config(start: &Path) -> Option<std::path::PathBuf> {
let mut cursor = match tokio::fs::canonicalize(start).await {
Ok(p) => p,
Err(_) => start.to_path_buf(),
};
loop {
let candidate = cursor.join(".git").join("config");
if tokio::fs::metadata(&candidate)
.await
.map(|m| m.is_file())
.unwrap_or(false)
{
return Some(candidate);
}
match cursor.parent() {
Some(p) => cursor = p.to_path_buf(),
None => return None,
}
}
}
fn scan_remote_origin_url(content: &str) -> Option<String> {
let mut in_section = false;
for raw in content.lines() {
let line = raw.trim();
if line.starts_with('[') && line.ends_with(']') {
in_section = line == "[remote \"origin\"]";
continue;
}
if !in_section {
continue;
}
if let Some(rest) = line.strip_prefix("url") {
let rest = rest.trim_start();
let rest = rest.strip_prefix('=')?.trim();
if rest.is_empty() {
return None;
}
return Some(rest.to_string());
}
}
None
}
fn remote_url_to_purl(url: &str) -> String {
if let Some((host, path)) = split_remote_host_path(url) {
let cleaned = path.strip_suffix(".git").unwrap_or(path);
let cleaned = cleaned.trim_matches('/');
let parts: Vec<&str> = cleaned.split('/').collect();
if parts.len() == 2 && !parts[0].is_empty() && !parts[1].is_empty() {
let ecosystem = match host {
"github.com" => Some("github"),
"gitlab.com" => Some("gitlab"),
"bitbucket.org" => Some("bitbucket"),
_ => None,
};
if let Some(eco) = ecosystem {
return format!("pkg:{eco}/{}/{}", parts[0], parts[1]);
}
}
}
url.to_string()
}
fn split_remote_host_path(url: &str) -> Option<(&str, &str)> {
if let Some(rest) = url.strip_prefix("git@") {
let (host, path) = rest.split_once(':')?;
return Some((host, path));
}
let stripped = url
.strip_prefix("ssh://")
.or_else(|| url.strip_prefix("git+ssh://"))
.or_else(|| url.strip_prefix("git://"))
.or_else(|| url.strip_prefix("https://"))
.or_else(|| url.strip_prefix("http://"));
if let Some(rest) = stripped {
let rest = match rest.split_once('@') {
Some((_, after)) => after,
None => rest,
};
let (host_with_port, path) = rest.split_once('/')?;
let host = host_with_port
.split_once(':')
.map(|(h, _)| h)
.unwrap_or(host_with_port);
return Some((host, path));
}
None
}
fn parse_toml_string_kv(line: &str, key: &str) -> Option<String> {
let eq = line.find('=')?;
let (lhs, rhs) = line.split_at(eq);
if lhs.trim() != key {
return None;
}
let rhs = rhs[1..].trim(); let stripped = rhs.strip_prefix('"')?;
let end = stripped.find('"')?;
let value = &stripped[..end];
if value.is_empty() {
None
} else {
Some(value.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn detect_package_json() {
let dir = tempfile::tempdir().unwrap();
tokio::fs::write(
dir.path().join("package.json"),
r#"{"name":"my-app","version":"1.2.3"}"#,
)
.await
.unwrap();
let r = detect_product(dir.path()).await;
assert_eq!(r.purl.as_deref(), Some("pkg:npm/my-app@1.2.3"));
assert!(r.warnings.is_empty());
}
#[tokio::test]
async fn detect_scoped_npm_package() {
let dir = tempfile::tempdir().unwrap();
tokio::fs::write(
dir.path().join("package.json"),
r#"{"name":"@socket/foo","version":"0.1.0"}"#,
)
.await
.unwrap();
let r = detect_product(dir.path()).await;
assert_eq!(r.purl.as_deref(), Some("pkg:npm/@socket/foo@0.1.0"));
}
#[tokio::test]
async fn detect_pyproject() {
let dir = tempfile::tempdir().unwrap();
let content = "[project]\nname = \"my-pylib\"\nversion = \"0.4.0\"\n";
tokio::fs::write(dir.path().join("pyproject.toml"), content)
.await
.unwrap();
let r = detect_product(dir.path()).await;
assert_eq!(r.purl.as_deref(), Some("pkg:pypi/my-pylib@0.4.0"));
}
#[tokio::test]
async fn detect_cargo_toml() {
let dir = tempfile::tempdir().unwrap();
let content = "[package]\nname = \"my-rust\"\nversion = \"2.0.0\"\nedition = \"2021\"\n";
tokio::fs::write(dir.path().join("Cargo.toml"), content)
.await
.unwrap();
let r = detect_product(dir.path()).await;
assert_eq!(r.purl.as_deref(), Some("pkg:cargo/my-rust@2.0.0"));
}
#[tokio::test]
async fn cargo_workspace_inheritance_is_unsupported() {
let dir = tempfile::tempdir().unwrap();
let content = "[package]\nname = \"my-rust\"\nversion.workspace = true\n";
tokio::fs::write(dir.path().join("Cargo.toml"), content)
.await
.unwrap();
let r = detect_product(dir.path()).await;
assert!(r.purl.is_none());
}
#[tokio::test]
async fn multiple_manifests_warns_and_picks_package_json() {
let dir = tempfile::tempdir().unwrap();
tokio::fs::write(
dir.path().join("package.json"),
r#"{"name":"my-app","version":"1.0.0"}"#,
)
.await
.unwrap();
tokio::fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"alt\"\nversion = \"9.9.9\"\n",
)
.await
.unwrap();
let r = detect_product(dir.path()).await;
assert_eq!(r.purl.as_deref(), Some("pkg:npm/my-app@1.0.0"));
assert_eq!(r.warnings.len(), 1);
assert!(r.warnings[0].contains("Multiple"));
}
#[tokio::test]
async fn empty_dir_returns_none() {
let dir = tempfile::tempdir().unwrap();
let r = detect_product(dir.path()).await;
assert!(r.purl.is_none());
assert!(r.warnings.is_empty());
}
#[test]
fn scan_toml_skips_other_sections() {
let toml = "[other]\nname = \"wrong\"\nversion = \"0.0.0\"\n\n[package]\nname = \"right\"\nversion = \"1.0.0\"\n";
let (n, v) = scan_toml_section(toml, "package").unwrap();
assert_eq!(n, "right");
assert_eq!(v, "1.0.0");
}
#[test]
fn scan_toml_ignores_comments_and_blank_lines() {
let toml = "[package]\n# a comment\n\nname = \"x\"\nversion = \"1.0\"\n";
let (n, v) = scan_toml_section(toml, "package").unwrap();
assert_eq!(n, "x");
assert_eq!(v, "1.0");
}
#[test]
fn scan_toml_missing_version_returns_none() {
let toml = "[package]\nname = \"only-name\"\n";
assert!(scan_toml_section(toml, "package").is_none());
}
#[test]
fn remote_url_github_ssh_becomes_pkg_github() {
assert_eq!(
remote_url_to_purl("git@github.com:SocketDev/socket-patch.git"),
"pkg:github/SocketDev/socket-patch"
);
}
#[test]
fn remote_url_github_https_becomes_pkg_github() {
assert_eq!(
remote_url_to_purl("https://github.com/SocketDev/socket-patch.git"),
"pkg:github/SocketDev/socket-patch"
);
}
#[test]
fn remote_url_github_https_no_dot_git() {
assert_eq!(
remote_url_to_purl("https://github.com/SocketDev/socket-patch"),
"pkg:github/SocketDev/socket-patch"
);
}
#[test]
fn remote_url_gitlab_and_bitbucket() {
assert_eq!(
remote_url_to_purl("git@gitlab.com:foo/bar.git"),
"pkg:gitlab/foo/bar"
);
assert_eq!(
remote_url_to_purl("https://bitbucket.org/foo/bar"),
"pkg:bitbucket/foo/bar"
);
}
#[test]
fn remote_url_unknown_host_returns_url_as_is() {
let raw = "https://git.example.com/team/repo.git";
assert_eq!(remote_url_to_purl(raw), raw);
}
#[test]
fn remote_url_ssh_protocol_form() {
assert_eq!(
remote_url_to_purl("ssh://git@github.com/foo/bar.git"),
"pkg:github/foo/bar"
);
}
#[test]
fn scan_origin_url_picks_url_in_section() {
let cfg = "[core]\nbare = false\n[remote \"origin\"]\nurl = git@github.com:foo/bar.git\nfetch = +refs/heads/*:refs/remotes/origin/*\n";
assert_eq!(
scan_remote_origin_url(cfg).as_deref(),
Some("git@github.com:foo/bar.git")
);
}
#[test]
fn scan_origin_url_ignores_other_remotes() {
let cfg = "[remote \"upstream\"]\nurl = git@github.com:other/repo.git\n[remote \"origin\"]\nurl = git@github.com:me/repo.git\n";
assert_eq!(
scan_remote_origin_url(cfg).as_deref(),
Some("git@github.com:me/repo.git")
);
}
#[test]
fn scan_origin_url_returns_none_when_missing() {
assert!(scan_remote_origin_url("[core]\nbare = false\n").is_none());
}
#[tokio::test]
async fn detect_prefers_git_remote_over_package_manifest() {
let dir = tempfile::tempdir().unwrap();
tokio::fs::write(
dir.path().join("package.json"),
r#"{"name":"from-pkg","version":"1.0.0"}"#,
)
.await
.unwrap();
let git_dir = dir.path().join(".git");
tokio::fs::create_dir_all(&git_dir).await.unwrap();
tokio::fs::write(
git_dir.join("config"),
"[remote \"origin\"]\n\turl = git@github.com:owner/from-git.git\n",
)
.await
.unwrap();
let r = detect_product(dir.path()).await;
assert_eq!(r.purl.as_deref(), Some("pkg:github/owner/from-git"));
}
#[tokio::test]
async fn detect_falls_back_to_package_manifest_when_no_git_remote() {
let dir = tempfile::tempdir().unwrap();
tokio::fs::write(
dir.path().join("package.json"),
r#"{"name":"pkg-only","version":"2.0.0"}"#,
)
.await
.unwrap();
let git_dir = dir.path().join(".git");
tokio::fs::create_dir_all(&git_dir).await.unwrap();
tokio::fs::write(git_dir.join("config"), "[core]\nbare = false\n")
.await
.unwrap();
let r = detect_product(dir.path()).await;
assert_eq!(r.purl.as_deref(), Some("pkg:npm/pkg-only@2.0.0"));
}
#[tokio::test]
async fn detect_finds_git_config_in_parent_directory() {
let root = tempfile::tempdir().unwrap();
let git_dir = root.path().join(".git");
tokio::fs::create_dir_all(&git_dir).await.unwrap();
tokio::fs::write(
git_dir.join("config"),
"[remote \"origin\"]\n\turl = git@github.com:org/proj.git\n",
)
.await
.unwrap();
let nested = root.path().join("packages").join("inner");
tokio::fs::create_dir_all(&nested).await.unwrap();
let r = detect_product(&nested).await;
assert_eq!(r.purl.as_deref(), Some("pkg:github/org/proj"));
}
#[tokio::test]
async fn git_config_with_only_non_origin_remote_falls_through() {
let dir = tempfile::tempdir().unwrap();
tokio::fs::write(
dir.path().join("package.json"),
r#"{"name":"fallback-app","version":"1.0.0"}"#,
)
.await
.unwrap();
let git_dir = dir.path().join(".git");
tokio::fs::create_dir_all(&git_dir).await.unwrap();
tokio::fs::write(
git_dir.join("config"),
"[remote \"upstream\"]\n\turl = git@github.com:other/proj.git\n",
)
.await
.unwrap();
let r = detect_product(dir.path()).await;
assert_eq!(r.purl.as_deref(), Some("pkg:npm/fallback-app@1.0.0"));
}
#[tokio::test]
async fn git_config_with_empty_url_falls_through() {
let dir = tempfile::tempdir().unwrap();
tokio::fs::write(
dir.path().join("package.json"),
r#"{"name":"fallback-app","version":"1.0.0"}"#,
)
.await
.unwrap();
let git_dir = dir.path().join(".git");
tokio::fs::create_dir_all(&git_dir).await.unwrap();
tokio::fs::write(
git_dir.join("config"),
"[remote \"origin\"]\n\turl = \n",
)
.await
.unwrap();
let r = detect_product(dir.path()).await;
assert_eq!(r.purl.as_deref(), Some("pkg:npm/fallback-app@1.0.0"));
}
#[test]
fn scan_origin_url_handles_crlf_line_endings() {
let cfg =
"[remote \"origin\"]\r\n\turl = git@github.com:foo/bar.git\r\n";
assert_eq!(
scan_remote_origin_url(cfg).as_deref(),
Some("git@github.com:foo/bar.git")
);
}
#[test]
fn remote_url_git_plus_ssh_form() {
assert_eq!(
remote_url_to_purl("git+ssh://git@github.com/owner/repo.git"),
"pkg:github/owner/repo"
);
}
#[test]
fn remote_url_git_protocol_form() {
assert_eq!(
remote_url_to_purl("git://github.com/owner/repo.git"),
"pkg:github/owner/repo"
);
}
#[test]
fn remote_url_http_form() {
assert_eq!(
remote_url_to_purl("http://github.com/owner/repo.git"),
"pkg:github/owner/repo"
);
}
#[test]
fn remote_url_ssh_with_port_strips_port() {
assert_eq!(
remote_url_to_purl("ssh://git@github.com:22/owner/repo.git"),
"pkg:github/owner/repo"
);
}
#[test]
fn remote_url_ssh_no_user_prefix() {
assert_eq!(
remote_url_to_purl("ssh://github.com/foo/bar.git"),
"pkg:github/foo/bar"
);
}
#[test]
fn remote_url_unknown_shape_returned_verbatim() {
let weird = "file:///srv/repos/proj.git";
assert_eq!(remote_url_to_purl(weird), weird);
}
#[tokio::test]
async fn detect_pyproject_tool_poetry_layout() {
let dir = tempfile::tempdir().unwrap();
let content = "[tool.poetry]\nname = \"poetry-app\"\nversion = \"0.9.0\"\n";
tokio::fs::write(dir.path().join("pyproject.toml"), content)
.await
.unwrap();
let r = detect_product(dir.path()).await;
assert_eq!(r.purl.as_deref(), Some("pkg:pypi/poetry-app@0.9.0"));
}
#[tokio::test]
async fn detect_pyproject_project_section_wins_over_tool_poetry() {
let dir = tempfile::tempdir().unwrap();
let content = "[project]\nname = \"pep621-app\"\nversion = \"1.0.0\"\n\n[tool.poetry]\nname = \"poetry-app\"\nversion = \"0.9.0\"\n";
tokio::fs::write(dir.path().join("pyproject.toml"), content)
.await
.unwrap();
let r = detect_product(dir.path()).await;
assert_eq!(r.purl.as_deref(), Some("pkg:pypi/pep621-app@1.0.0"));
}
#[tokio::test]
async fn detect_pyproject_over_cargo_when_no_package_json() {
let dir = tempfile::tempdir().unwrap();
tokio::fs::write(
dir.path().join("pyproject.toml"),
"[project]\nname = \"py-app\"\nversion = \"1.0.0\"\n",
)
.await
.unwrap();
tokio::fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"rust-app\"\nversion = \"2.0.0\"\n",
)
.await
.unwrap();
let r = detect_product(dir.path()).await;
assert_eq!(r.purl.as_deref(), Some("pkg:pypi/py-app@1.0.0"));
assert_eq!(r.warnings.len(), 1);
assert!(r.warnings[0].contains("pyproject.toml"));
assert!(r.warnings[0].contains("Cargo.toml"));
}
#[tokio::test]
async fn package_json_missing_name_returns_none() {
let dir = tempfile::tempdir().unwrap();
tokio::fs::write(
dir.path().join("package.json"),
r#"{"version":"1.0.0"}"#,
)
.await
.unwrap();
let r = detect_product(dir.path()).await;
assert!(r.purl.is_none());
}
#[tokio::test]
async fn package_json_empty_name_returns_none() {
let dir = tempfile::tempdir().unwrap();
tokio::fs::write(
dir.path().join("package.json"),
r#"{"name":"","version":"1.0.0"}"#,
)
.await
.unwrap();
let r = detect_product(dir.path()).await;
assert!(r.purl.is_none());
}
#[tokio::test]
async fn package_json_invalid_json_returns_none() {
let dir = tempfile::tempdir().unwrap();
tokio::fs::write(dir.path().join("package.json"), "{ not json").await.unwrap();
let r = detect_product(dir.path()).await;
assert!(r.purl.is_none());
}
#[test]
fn parse_toml_kv_returns_none_when_no_equals() {
assert!(parse_toml_string_kv("name without equals", "name").is_none());
}
#[test]
fn parse_toml_kv_returns_none_when_key_mismatch() {
assert!(parse_toml_string_kv(r#"other = "value""#, "name").is_none());
}
#[test]
fn parse_toml_kv_returns_none_when_unterminated_string() {
assert!(parse_toml_string_kv(r#"name = "no-close"#, "name").is_none());
}
#[test]
fn parse_toml_kv_returns_none_when_value_empty() {
assert!(parse_toml_string_kv(r#"name = """#, "name").is_none());
}
#[test]
fn parse_toml_kv_returns_none_when_value_not_quoted() {
assert!(parse_toml_string_kv(r#"name = 42"#, "name").is_none());
}
#[test]
fn split_host_path_rejects_ssh_without_colon() {
assert!(split_remote_host_path("git@github.com").is_none());
}
#[test]
fn split_host_path_rejects_scheme_url_without_path() {
assert!(split_remote_host_path("https://github.com").is_none());
}
#[test]
fn remote_url_three_path_segments_returns_url_as_is() {
let raw = "https://github.com/owner/repo/extra";
assert_eq!(remote_url_to_purl(raw), raw);
}
#[test]
fn remote_url_trailing_slash_is_normalized() {
assert_eq!(
remote_url_to_purl("https://github.com/owner/repo/"),
"pkg:github/owner/repo"
);
}
#[tokio::test]
async fn cargo_toml_missing_version_returns_none() {
let dir = tempfile::tempdir().unwrap();
tokio::fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"only-name\"\n",
)
.await
.unwrap();
let r = detect_product(dir.path()).await;
assert!(r.purl.is_none());
}
#[tokio::test]
async fn pyproject_with_no_recognized_section_returns_none() {
let dir = tempfile::tempdir().unwrap();
tokio::fs::write(
dir.path().join("pyproject.toml"),
"[build-system]\nrequires = [\"setuptools\"]\n",
)
.await
.unwrap();
let r = detect_product(dir.path()).await;
assert!(r.purl.is_none());
}
#[test]
fn detect_result_default_is_empty() {
let r = DetectResult::default();
assert!(r.purl.is_none());
assert!(r.warnings.is_empty());
}
#[tokio::test]
async fn find_git_config_returns_none_when_no_repo_ancestor() {
let dir = tempfile::tempdir().unwrap();
let r = find_git_config(dir.path()).await;
assert!(r.is_none(), "unexpected .git/config above {dir:?}: {r:?}");
}
#[tokio::test]
async fn find_git_config_handles_non_existent_start_path() {
let dir = tempfile::tempdir().unwrap();
let nonexistent = dir.path().join("does/not/exist");
let r = find_git_config(&nonexistent).await;
assert!(r.is_none());
}
#[tokio::test]
async fn package_json_with_non_string_name_returns_none() {
let dir = tempfile::tempdir().unwrap();
tokio::fs::write(
dir.path().join("package.json"),
r#"{"name":42,"version":"1.0.0"}"#,
)
.await
.unwrap();
let r = detect_product(dir.path()).await;
assert!(r.purl.is_none());
}
#[tokio::test]
async fn package_json_with_non_string_version_returns_none() {
let dir = tempfile::tempdir().unwrap();
tokio::fs::write(
dir.path().join("package.json"),
r#"{"name":"x","version":42}"#,
)
.await
.unwrap();
let r = detect_product(dir.path()).await;
assert!(r.purl.is_none());
}
#[test]
fn scan_origin_url_skips_url_line_without_equals_sign() {
let cfg = "[remote \"origin\"]\n\turl no-equals-here\n";
assert!(scan_remote_origin_url(cfg).is_none());
}
#[tokio::test]
async fn package_json_missing_version_key_returns_none() {
let dir = tempfile::tempdir().unwrap();
tokio::fs::write(
dir.path().join("package.json"),
r#"{"name":"x"}"#,
)
.await
.unwrap();
let r = detect_product(dir.path()).await;
assert!(r.purl.is_none());
}
}