use serde_json::Value;
use std::collections::HashMap;
use std::fs;
use std::path::Path;
#[derive(Debug, Clone)]
pub struct Dependency {
pub name: String,
pub version: String,
pub is_git: bool,
}
pub fn parse_dependencies(
package_json_path: &Path,
) -> Result<HashMap<String, Dependency>, Box<dyn std::error::Error>> {
let content = fs::read_to_string(package_json_path)?;
let json: Value = serde_json::from_str(&content)?;
let deps = json
.get("dependencies")
.and_then(|d| d.as_object())
.ok_or("no dependencies section found in package.json")?;
let mut dependencies = HashMap::new();
for (name, value) in deps {
if let Some(version_str) = value.as_str() {
let is_git = version_str.contains("github.com") || version_str.starts_with("git");
let version = extract_version(version_str);
validate_package_name(name)?;
validate_version(&version)?;
dependencies.insert(
name.clone(),
Dependency {
name: name.clone(),
version,
is_git,
},
);
}
}
Ok(dependencies)
}
fn validate_package_name(name: &str) -> Result<(), Box<dyn std::error::Error>> {
if name.is_empty() || name.len() > 200 {
return Err(format!("package name {name:?} has invalid length").into());
}
if name.contains("..") {
return Err(format!("package name {name:?} contains '..'").into());
}
if !name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_' | b'@' | b'/'))
{
return Err(format!("package name {name:?} contains disallowed characters").into());
}
Ok(())
}
fn validate_version(version: &str) -> Result<(), Box<dyn std::error::Error>> {
if version.is_empty() || version.len() > 100 {
return Err(format!("version {version:?} has invalid length").into());
}
if version.contains("..") {
return Err(format!("version {version:?} contains '..'").into());
}
if !version
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'+' | b'_'))
{
return Err(format!("version {version:?} contains disallowed characters").into());
}
Ok(())
}
fn extract_version(value: &str) -> String {
if value.contains("github.com") || value.starts_with("git") {
if let Some(hash_pos) = value.rfind('#') {
return value[hash_pos + 1..].to_string();
}
}
value
.trim_start_matches('^')
.trim_start_matches('~')
.to_string()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PackageType {
Module,
CommonJs,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Entry {
Bare(String),
Subpath { subpath: String, target: String },
Prefix { subpath: String, dir: String },
}
#[derive(Debug, Clone)]
pub struct PackageJson {
raw: Value,
}
const BROWSER_CONDITIONS: &[&str] = &["browser", "module", "import", "default"];
impl PackageJson {
pub fn from_path(path: &Path) -> Result<Self, Box<dyn std::error::Error>> {
Self::from_json(&fs::read_to_string(path)?)
}
pub fn from_json(s: &str) -> Result<Self, Box<dyn std::error::Error>> {
Ok(Self::from_value(serde_json::from_str(s)?))
}
pub fn from_value(raw: Value) -> Self {
Self { raw }
}
pub fn name(&self) -> Option<&str> {
self.raw.get("name").and_then(Value::as_str)
}
pub fn version(&self) -> Option<&str> {
self.raw.get("version").and_then(Value::as_str)
}
pub fn package_type(&self) -> PackageType {
match self.raw.get("type").and_then(Value::as_str) {
Some("module") => PackageType::Module,
_ => PackageType::CommonJs,
}
}
pub fn resolve_main(&self) -> Option<String> {
if let Some(exports) = self.raw.get("exports") {
if let Some(s) = exports.as_str() {
return safe_target(s);
}
if let Some(obj) = exports.as_object() {
return if is_subpath_map(obj) {
obj.get(".")
.and_then(select_condition)
.and_then(|s| safe_target(&s))
} else {
select_condition(exports).and_then(|s| safe_target(&s))
};
}
}
if let Some(s) = self.raw.get("module").and_then(Value::as_str) {
return safe_target(s);
}
if let Some(browser) = self.raw.get("browser") {
if let Some(s) = browser.as_str() {
return safe_target(s);
}
if let (Some(map), Some(main)) = (
browser.as_object(),
self.raw.get("main").and_then(Value::as_str),
) {
let main = safe_target(main)?;
for (key, value) in map {
if safe_target(key).as_deref() == Some(main.as_str()) {
if let Some(s) = value.as_str() {
return safe_target(s);
}
}
}
}
}
self.raw
.get("main")
.and_then(Value::as_str)
.and_then(safe_target)
}
pub fn resolve_subpath(&self, subpath: &str) -> Option<String> {
let key = normalize_subpath_key(subpath);
let exports = self.raw.get("exports")?.as_object()?;
if !is_subpath_map(exports) {
return None;
}
if let Some(value) = exports.get(&key) {
return select_condition(value).and_then(|s| safe_target(&s));
}
let mut best_len = 0usize;
let mut best: Option<String> = None;
for (pattern, value) in exports {
let Some(star) = pattern.find('*') else {
continue;
};
let (prefix, suffix) = (&pattern[..star], &pattern[star + 1..]);
if key.len() >= prefix.len() + suffix.len()
&& key.starts_with(prefix)
&& key.ends_with(suffix)
{
let matched = &key[prefix.len()..key.len() - suffix.len()];
if let Some(target) = select_condition(value) {
if let Some(resolved) = safe_target(&target.replace('*', matched)) {
if best.is_none() || prefix.len() > best_len {
best_len = prefix.len();
best = Some(resolved);
}
}
}
}
}
best
}
pub fn entries(&self) -> Vec<Entry> {
let mut entries = Vec::new();
match self.raw.get("exports") {
Some(Value::Object(obj)) if is_subpath_map(obj) => {
for (key, value) in obj {
if key == "." {
if let Some(t) = select_condition(value).and_then(|s| safe_target(&s)) {
entries.push(Entry::Bare(t));
}
} else if let Some(sub) = key.strip_prefix("./") {
if let Some(star) = sub.find('*') {
if let Some(dir) = select_condition(value).and_then(|t| target_dir(&t))
{
entries.push(Entry::Prefix {
subpath: sub[..star].to_string(),
dir,
});
}
} else if let Some(t) =
select_condition(value).and_then(|s| safe_target(&s))
{
entries.push(Entry::Subpath {
subpath: sub.to_string(),
target: t,
});
}
}
}
}
_ => {
if let Some(t) = self.resolve_main() {
entries.push(Entry::Bare(t));
}
}
}
entries
}
pub fn referenced_paths(&self) -> Vec<String> {
self.entries()
.into_iter()
.map(|e| match e {
Entry::Bare(t) | Entry::Subpath { target: t, .. } => t,
Entry::Prefix { dir, .. } => dir,
})
.collect()
}
}
fn is_subpath_map(obj: &serde_json::Map<String, Value>) -> bool {
obj.keys().any(|k| k.starts_with('.'))
}
fn select_condition(node: &Value) -> Option<String> {
match node {
Value::String(s) => Some(s.clone()),
Value::Array(arr) => arr.iter().find_map(select_condition),
Value::Object(map) => BROWSER_CONDITIONS
.iter()
.find_map(|cond| map.get(*cond).and_then(select_condition)),
_ => None,
}
}
fn safe_target(s: &str) -> Option<String> {
let t = s.strip_prefix("./").unwrap_or(s).trim_start_matches('/');
if t.is_empty() || t.split('/').any(|seg| seg == "..") {
return None;
}
Some(t.to_string())
}
fn normalize_subpath_key(subpath: &str) -> String {
if subpath.starts_with("./") {
subpath.to_string()
} else {
format!("./{}", subpath.trim_start_matches('/'))
}
}
fn target_dir(target: &str) -> Option<String> {
let star = target.find('*')?;
let before = target[..star].strip_prefix("./").unwrap_or(&target[..star]);
if before.split('/').any(|seg| seg == "..") {
return None;
}
Some(before.trim_start_matches('/').to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn parses_pinned_caret_and_git_specs() {
let tmp = tempdir().unwrap();
let p = tmp.path().join("package.json");
fs::write(
&p,
r#"{ "dependencies": {
"lit": "3.3.3",
"bootstrap": "^5.3.8",
"forked": "github:owner/repo#abc123"
} }"#,
)
.unwrap();
let deps = parse_dependencies(&p).unwrap();
assert_eq!(deps["lit"].version, "3.3.3");
assert!(!deps["lit"].is_git);
assert_eq!(deps["bootstrap"].version, "5.3.8");
assert_eq!(deps["forked"].version, "abc123");
assert!(deps["forked"].is_git);
}
#[test]
fn resolve_main_from_exports_and_fallbacks() {
let a = PackageJson::from_json(
r#"{"exports":{".":{"types":"./dev.d.ts","default":"./index.js"},"./decorators.js":{"default":"./decorators.js"}}}"#,
)
.unwrap();
assert_eq!(a.resolve_main().as_deref(), Some("index.js"));
assert_eq!(
a.resolve_subpath("./decorators.js").as_deref(),
Some("decorators.js")
);
let b = PackageJson::from_json(
r#"{"type":"module","exports":{".":{"browser":{"development":"./development/lit-html.js","default":"./lit-html.js"},"default":"./lit-html.js"}}}"#,
)
.unwrap();
assert_eq!(b.resolve_main().as_deref(), Some("lit-html.js"));
let c = PackageJson::from_json(
r#"{"main":"dist/js/bootstrap.js","module":"dist/js/bootstrap.esm.js"}"#,
)
.unwrap();
assert_eq!(
c.resolve_main().as_deref(),
Some("dist/js/bootstrap.esm.js")
);
}
#[test]
fn resolve_subpath_picks_import_condition_for_cjs_package() {
let rt = PackageJson::from_json(
r#"{"type":"commonjs","exports":{"./helpers/decorate":[{"node":"./src/helpers/decorate.js","import":"./src/helpers/esm/decorate.js","default":"./src/helpers/decorate.js"}]}}"#,
)
.unwrap();
assert_eq!(rt.package_type(), PackageType::CommonJs);
assert!(rt.resolve_main().is_none());
assert_eq!(
rt.resolve_subpath("./helpers/decorate").as_deref(),
Some("src/helpers/esm/decorate.js")
);
assert_eq!(
rt.resolve_subpath("helpers/decorate").as_deref(),
Some("src/helpers/esm/decorate.js")
);
assert!(rt
.referenced_paths()
.iter()
.any(|p| p == "src/helpers/esm/decorate.js"));
}
#[test]
fn condition_order_prefers_browser_and_import_never_node() {
let x = PackageJson::from_json(
r#"{"exports":{".":{"node":"./n.js","require":"./r.js","import":"./esm.js","default":"./def.js"}}}"#,
)
.unwrap();
assert_eq!(x.resolve_main().as_deref(), Some("esm.js"));
let y = PackageJson::from_json(
r#"{"exports":{".":{"module":"./m.js","browser":"./b.js","default":"./d.js"}}}"#,
)
.unwrap();
assert_eq!(y.resolve_main().as_deref(), Some("b.js"));
}
#[test]
fn subpath_pattern_becomes_prefix_entry() {
let pkg = PackageJson::from_json(r#"{"exports":{".":"./index.js","./*":"./dist/*.js"}}"#)
.unwrap();
assert_eq!(pkg.resolve_subpath("./foo").as_deref(), Some("dist/foo.js"));
assert!(pkg.entries().iter().any(
|e| matches!(e, Entry::Prefix { subpath, dir } if subpath.is_empty() && dir == "dist/")
));
assert!(pkg
.entries()
.iter()
.any(|e| matches!(e, Entry::Bare(t) if t == "index.js")));
}
#[test]
fn rejects_path_traversal_targets() {
let evil = PackageJson::from_json(r#"{"exports":{".":"../escape.js"}}"#).unwrap();
assert!(evil.resolve_main().is_none());
}
}