Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
279 changes: 244 additions & 35 deletions crates/socket-patch-core/src/crawlers/deno_crawler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@
//! scannable JSR-shaped trees, and (b) any future Deno that
//! adopts a stable scope/name/version layout (or a third-party
//! tool that materializes JSR packages this way) gets picked
//! up automatically.
//! up automatically. In a project with a `deno.lock`, only the
//! lock's `jsr` entries are looked up in that layout; the cache is
//! not walked.
//!
//! In the meantime, `socket-patch scan --global --ecosystems
//! deno --global-prefix <path>` is what real users would invoke
Expand All @@ -41,6 +43,8 @@
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};

use serde_json::{Map, Value};

use super::types::{CrawledPackage, CrawlerOptions};
use crate::patch::path_safety;
use crate::utils::fs::{home_dir, is_dir};
Expand Down Expand Up @@ -82,16 +86,44 @@ impl DenoCrawler {
}
}

/// Crawl JSR cache(s) and return every `pkg:jsr/...` package
/// present. JSR cache layout is
/// Crawl JSR cache(s) and return the `pkg:jsr/...` packages present.
/// JSR cache layout is
/// `<root>/@<scope>/<name>/<version>/<package contents>`.
///
/// A local project with a `deno.lock` only gets the JSR packages its
/// lock records, each looked up in the cache ([`jsr_lock_scope`],
/// [`locate`]), so a package another project left in the cache is
/// never crawled and the cache tree is not walked. Everything else
/// (`--global`, `--global-prefix`, no or unreadable `deno.lock`) walks
/// the whole cache.
pub async fn crawl_all(&self, options: &CrawlerOptions) -> Vec<CrawledPackage> {
let mut packages = Vec::new();
let mut seen = HashSet::new();

let cache_paths = self.get_jsr_cache_paths(options).await.unwrap_or_default();
if cache_paths.is_empty() {
return packages;
}
let scope = if options.global || options.global_prefix.is_some() {
None
} else {
jsr_lock_scope(&options.cwd).await
};
for cache_path in &cache_paths {
scan_jsr_cache(cache_path, &mut seen, &mut packages).await;
match &scope {
Some(locked) => {
for purl in locked {
if seen.contains(purl) {
continue;
}
if let Some(pkg) = locate(cache_path, purl).await {
seen.insert(pkg.purl.clone());
packages.push(pkg);
}
}
}
None => scan_jsr_cache(cache_path, &mut seen, &mut packages).await,
}
}

packages
Expand All @@ -108,38 +140,9 @@ impl DenoCrawler {
let mut result: HashMap<String, CrawledPackage> = HashMap::new();

for purl in purls {
let Some(((scope, name), version)) = crate::utils::purl::parse_jsr_purl(purl) else {
continue;
};
// SECURITY: scope/name/version come straight from the (untrusted)
// manifest PURL and are joined onto the cache root below. A real
// JSR coordinate is a single path segment, so reject any that
// could traverse out of the cache (`..`/`.`, a separator, NUL).
// The parser percent-decodes components, so these guards see the
// decoded form — `%2e%2e` cannot smuggle a traversal past them.
// Unlike the cargo/npm crawlers there is no content check to catch
// a bogus path, and jsr patches in place — so fail closed here.
if !(is_safe_jsr_component(&scope)
&& is_safe_jsr_component(&name)
&& is_safe_jsr_component(&version))
{
continue;
if let Some(pkg) = locate(jsr_cache_path, purl).await {
result.insert(purl.clone(), pkg);
}
// Cache layout: <root>/<scope>/<name>/<version>/
let pkg_dir = jsr_cache_path.join(&*scope).join(&*name).join(&*version);
if !is_dir(&pkg_dir).await {
continue;
}
result.insert(
purl.clone(),
CrawledPackage {
name: name.to_string(),
version: version.to_string(),
namespace: Some(scope.to_string()),
purl: purl.clone(),
path: pkg_dir,
},
);
}

Ok(result)
Expand All @@ -152,6 +155,70 @@ impl Default for DenoCrawler {
}
}

/// The cache directory of JSR `purl` under `jsr_cache_path`, reported
/// under `purl`: the one lookup behind [`DenoCrawler::find_by_purls`] and
/// the `deno.lock`-scoped crawl. `None` for a non-`pkg:jsr/` purl, an
/// unsafe coordinate, or a version directory that is not there.
async fn locate(jsr_cache_path: &Path, purl: &str) -> Option<CrawledPackage> {
let ((scope, name), version) = crate::utils::purl::parse_jsr_purl(purl)?;
// SECURITY: scope/name/version come straight from the (untrusted)
// manifest PURL or project `deno.lock` and are joined onto the cache
// root below. A real JSR coordinate is a single path segment, so reject
// any that could traverse out of the cache (`..`/`.`, a separator,
// NUL). The parser percent-decodes components, so these guards see the
// decoded form — `%2e%2e` cannot smuggle a traversal past them.
// Unlike the cargo/npm crawlers there is no content check to catch
// a bogus path, and jsr patches in place — so fail closed here.
if !(is_safe_jsr_component(&scope)
&& is_safe_jsr_component(&name)
&& is_safe_jsr_component(&version))
{
return None;
}
// Cache layout: <root>/<scope>/<name>/<version>/
let pkg_dir = jsr_cache_path.join(&*scope).join(&*name).join(&*version);
if !is_dir(&pkg_dir).await {
return None;
}
Some(CrawledPackage {
name: name.to_string(),
version: version.to_string(),
namespace: Some(scope.to_string()),
purl: purl.to_string(),
path: pkg_dir,
})
}

/// The `pkg:jsr/` purl of every key of `<cwd>/deno.lock`'s `jsr` section
/// (`@<scope>/<name>@<version>`), in lock order. A lock without a `jsr`
/// section records no JSR package (`Some` of nothing). `None` when there
/// is no readable `deno.lock`, it is not JSON, or it carries no `version`
/// (not a lock Deno wrote); the crawl then walks the whole cache.
async fn jsr_lock_scope(cwd: &Path) -> Option<Vec<String>> {
let text = crate::utils::fs::read_regular_to_string(&cwd.join("deno.lock"))
.await
.ok()?;
let lock: Value = serde_json::from_str(&text).ok()?;
lock.get("version")?;
Some(
lock_section(&lock, "jsr")
.map(|jsr| jsr.keys().map(|key| format!("pkg:jsr/{key}")).collect())
.unwrap_or_default(),
)
}

/// The `section` (`npm` or `jsr`) package map of a parsed `deno.lock`, in
/// any lockfile version: top-level in versions 4 and 5, `packages.<section>`
/// in version 3 and `<section>.packages` in version 2. The one reader of
/// the lock's layout, shared with VEX discovery (`vex::discover::deno`).
pub(crate) fn lock_section<'a>(lock: &'a Value, section: &str) -> Option<&'a Map<String, Value>> {
let top = lock.get(section);
top.and_then(|s| s.get("packages"))
.or(top)
.or_else(|| lock.get("packages").and_then(|p| p.get(section)))
.and_then(Value::as_object)
}

/// Walk `<root>/@<scope>/<name>/<version>/` and emit a
/// `CrawledPackage` per (scope, name, version) tuple found.
async fn scan_jsr_cache(root: &Path, seen: &mut HashSet<String>, out: &mut Vec<CrawledPackage>) {
Expand Down Expand Up @@ -502,6 +569,148 @@ mod tests {
assert!(crawler.crawl_all(&opts).await.is_empty());
}

// ── deno.lock-scoped project crawl ─────────────────────────────

/// A project at a fresh tempdir with `deno.lock` = `lock`, and a
/// `DENO_DIR` whose JSR cache holds `@std/path@0.220.0` (locked in the
/// fixtures below) plus `@std/fs@1.0.0` and `@other/x@2.0.0` (never
/// locked). Returns the purls `crawl_all` reports, sorted.
async fn crawl_project_with_lock(lock: &str, global: bool) -> Vec<String> {
let project = tempfile::tempdir().unwrap();
tokio::fs::write(project.path().join("deno.lock"), lock)
.await
.unwrap();
let deno_home = tempfile::tempdir().unwrap();
let jsr = deno_home.path().join("npm").join("jsr.io");
stage(&jsr, "@std", "path", "0.220.0").await;
stage(&jsr, "@std", "fs", "1.0.0").await;
stage(&jsr, "@other", "x", "2.0.0").await;
let _g = EnvGuard::set("DENO_DIR", deno_home.path().to_str().unwrap());

let opts = CrawlerOptions {
cwd: project.path().to_path_buf(),
global,
global_prefix: None,
};
let mut purls: Vec<String> = DenoCrawler
.crawl_all(&opts)
.await
.into_iter()
.map(|p| p.purl)
.collect();
purls.sort();
purls
}

const WHOLE_CACHE: [&str; 3] = [
"pkg:jsr/@other/x@2.0.0",
"pkg:jsr/@std/fs@1.0.0",
"pkg:jsr/@std/path@0.220.0",
];

#[tokio::test]
#[serial_test::serial]
async fn crawl_all_local_lock_scopes_to_locked_jsr_packages() {
// v4/v5 (top-level `jsr`) and v3 (`packages.jsr`): only the locked
// package is reported; the other cached packages are not.
for lock in [
r#"{"version":"5","jsr":{"@std/path@0.220.0":{"integrity":"a"}}}"#,
r#"{"version":"4","jsr":{"@std/path@0.220.0":{"integrity":"a"}}}"#,
r#"{"version":"3","packages":{"jsr":{"@std/path@0.220.0":{"integrity":"a"}}}}"#,
] {
assert_eq!(
crawl_project_with_lock(lock, false).await,
vec!["pkg:jsr/@std/path@0.220.0"],
"{lock}"
);
}
}

#[tokio::test]
#[serial_test::serial]
async fn crawl_all_local_lock_without_jsr_reports_nothing() {
// An npm-only project resolves no JSR package at all.
let lock = r#"{"version":"4","npm":{"left-pad@1.3.0":{"integrity":"x"}}}"#;
assert!(crawl_project_with_lock(lock, false).await.is_empty());
}

#[tokio::test]
#[serial_test::serial]
async fn crawl_all_local_lock_skips_uncached_and_unsafe_entries() {
// A locked package missing from the cache, and a key whose version
// would traverse out of it, are skipped; the cached one is found.
let lock = r#"{"version":"4","jsr":{
"@std/path@0.220.0":{},
"@std/path@9.9.9":{},
"@std/fs@../../@other/x/2.0.0":{}
}}"#;
assert_eq!(
crawl_project_with_lock(lock, false).await,
vec!["pkg:jsr/@std/path@0.220.0"]
);
}

#[tokio::test]
#[serial_test::serial]
async fn crawl_all_walks_without_a_usable_lock_or_in_global_mode() {
let scoped = r#"{"version":"4","jsr":{"@std/path@0.220.0":{}}}"#;
// `--global` ignores the project's lock.
assert_eq!(crawl_project_with_lock(scoped, true).await, WHOLE_CACHE);
// Not JSON, or JSON without a `version` (no lock Deno wrote): walk.
assert_eq!(
crawl_project_with_lock("not json", false).await,
WHOLE_CACHE
);
assert_eq!(crawl_project_with_lock("{}", false).await, WHOLE_CACHE);
}

#[tokio::test]
#[serial_test::serial]
async fn crawl_all_local_without_lock_walks_the_cache() {
// A `deno.json`-only project has no resolution to scope by.
let project = tempfile::tempdir().unwrap();
tokio::fs::write(project.path().join("deno.json"), b"{}")
.await
.unwrap();
let deno_home = tempfile::tempdir().unwrap();
let jsr = deno_home.path().join("npm").join("jsr.io");
stage(&jsr, "@std", "path", "0.220.0").await;
stage(&jsr, "@std", "fs", "1.0.0").await;
let _g = EnvGuard::set("DENO_DIR", deno_home.path().to_str().unwrap());
let opts = CrawlerOptions {
cwd: project.path().to_path_buf(),
global: false,
global_prefix: None,
};
assert_eq!(DenoCrawler.crawl_all(&opts).await.len(), 2);
}

/// The shared section reader answers every lockfile version for both
/// callers: the crawl's `jsr` scope and VEX discovery's `npm` keys.
#[test]
fn lock_section_reads_every_lock_version() {
let keys = |text: &str, section: &str| -> Vec<String> {
let lock: Value = serde_json::from_str(text).unwrap();
lock_section(&lock, section)
.map(|m| m.keys().cloned().collect())
.unwrap_or_default()
};
// v4 / v5: top-level sections.
let v4 = r#"{"version":"4","npm":{"left-pad@1.3.0":{}},"jsr":{"@std/path@1.0.0":{}}}"#;
assert_eq!(keys(v4, "npm"), ["left-pad@1.3.0"]);
assert_eq!(keys(v4, "jsr"), ["@std/path@1.0.0"]);
// v3: under `packages`.
let v3 = r#"{"version":"3","packages":{"npm":{"left-pad@1.3.0":{}},"jsr":{"@std/path@1.0.0":{}}}}"#;
assert_eq!(keys(v3, "npm"), ["left-pad@1.3.0"]);
assert_eq!(keys(v3, "jsr"), ["@std/path@1.0.0"]);
// v2: `npm.packages`; there is no jsr section.
let v2 = r#"{"version":"2","npm":{"specifiers":{},"packages":{"left-pad@1.3.0":{}}}}"#;
assert_eq!(keys(v2, "npm"), ["left-pad@1.3.0"]);
assert!(keys(v2, "jsr").is_empty());
// A non-object section is no section.
assert!(keys(r#"{"version":"4","jsr":[]}"#, "jsr").is_empty());
}

/// Unit contract for the coordinate gate: real scope/name/version
/// components pass; a `:` is rejected because a Windows drive-relative
/// component (`C:evil`) joins as an absolute path under `Path::join`.
Expand Down
14 changes: 6 additions & 8 deletions crates/socket-patch-core/src/vex/discover/deno.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ use serde_json::Value;

use std::path::PathBuf;

use crate::crawlers::deno_crawler::lock_section;

use super::{
canonical_base_purl, npm_purl, parse_json, CopyTarget, DiscoverCtx, Discovery, UnattestedKind,
UnwiredCopy,
Expand Down Expand Up @@ -59,15 +61,11 @@ pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) {
}
}

/// The keys of `deno.lock`'s npm package map, in any lockfile version.
/// The keys of `deno.lock`'s npm package map, in any lockfile version
/// ([`lock_section`]).
fn deno_npm_keys(lock: &Value) -> Vec<&str> {
let npm = lock.get("npm");
let map = npm
.and_then(|n| n.get("packages"))
.or(npm)
.or_else(|| lock.get("packages").and_then(|p| p.get("npm")))
.and_then(Value::as_object);
map.map(|m| m.keys().map(String::as_str).collect())
lock_section(lock, "npm")
.map(|m| m.keys().map(String::as_str).collect())
.unwrap_or_default()
}

Expand Down
Loading