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
27 changes: 27 additions & 0 deletions crates/socket-patch-cli/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,33 @@ impl GlobalArgs {
Ok(())
}

/// Refuse a `--global-prefix` naming a Deno tool's per-tool
/// `node_modules` that Deno never loads (#444): patching it reported
/// `applied` (and VEX `not_affected`) while the tool kept running the
/// copy in Deno's npm cache. Only commands that patch, find or attest
/// call this; the ones that restore (`rollback`, `remove`,
/// `vendor --revert`, `repair`) still reach a tree an earlier release
/// patched.
pub fn refuse_unloaded_deno_prefix(&self) -> Result<(), String> {
match &self.global_prefix {
Some(prefix)
if socket_patch_core::crawlers::deno_crawler::is_unloaded_deno_tool_node_modules(
prefix,
) =>
{
Err(format!(
"--global-prefix (or SOCKET_GLOBAL_PREFIX) `{}` is inside a Deno tool's \
node_modules that Deno does not load (the tool's Deno config leaves \
nodeModulesDir unset or \"none\"); the tool runs its npm packages from \
Deno's npm cache, so use -g, or --global-prefix naming $DENO_DIR or \
$DENO_DIR/npm",
prefix.display()
))
}
_ => Ok(()),
}
}

/// The crawler options this run's `--cwd` / `--global` /
/// `--global-prefix` select.
pub(crate) fn crawler_options(&self) -> socket_patch_core::crawlers::CrawlerOptions {
Expand Down
11 changes: 8 additions & 3 deletions crates/socket-patch-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,9 +157,14 @@ impl Commands {
/// that does not exist yet stays legal for them.
pub fn validate_paths(&self) -> Result<(), String> {
match self {
Commands::SelfUpdate(_) | Commands::HostedBundle(_) => Ok(()),
Commands::Get(_) | Commands::Scan(_) => self.global_args().validate_paths(true),
other => other.global_args().validate_paths(false),
Commands::SelfUpdate(_) | Commands::HostedBundle(_) => return Ok(()),
Commands::Get(_) | Commands::Scan(_) => self.global_args().validate_paths(true)?,
other => other.global_args().validate_paths(false)?,
}
match self {
Commands::Rollback(_) | Commands::Remove(_) | Commands::Repair(_) => Ok(()),
Commands::Vendor(args) if args.revert => Ok(()),
other => other.global_args().refuse_unloaded_deno_prefix(),
}
}
}
Expand Down
119 changes: 119 additions & 0 deletions crates/socket-patch-cli/tests/cli_path_flags_validated.rs
Original file line number Diff line number Diff line change
Expand Up @@ -218,3 +218,122 @@ fn manifest_creating_commands_accept_a_manifest_path_in_a_new_directory() {
);
}
}

/// Stage the per-tool dir Deno >= 2.7 writes beside a `deno install -g`
/// tool, `<install-root>/bin/.<tool>/`, with a `node_modules` holding
/// is-odd, and return that `node_modules`.
fn stage_deno_tool_dir(root: &Path, deno_json: &str, package_json: bool) -> std::path::PathBuf {
let tool = root.join("bin").join(".sptool");
let pkg = tool
.join("node_modules")
.join(".deno")
.join("is-odd@3.0.1")
.join("node_modules")
.join("is-odd");
std::fs::create_dir_all(&pkg).unwrap();
std::fs::write(
pkg.join("package.json"),
r#"{"name":"is-odd","version":"3.0.1"}"#,
)
.unwrap();
std::fs::write(tool.join("deno.json"), deno_json).unwrap();
std::fs::write(tool.join("deno.lock"), "{}").unwrap();
if package_json {
std::fs::write(tool.join("package.json"), "{}").unwrap();
}
std::fs::write(root.join("bin").join("sptool"), "#!/bin/sh\n").unwrap();
tool.join("node_modules")
}

/// #444: Deno writes a per-tool `bin/.<tool>/node_modules` for a tool
/// installed from a local or JSR entrypoint, but the tool's `deno.json`
/// sets no `nodeModulesDir`, so Deno loads its npm packages from
/// `$DENO_DIR/npm` instead. A `--global-prefix` naming that tree used to
/// report `applied` and a VEX `not_affected` while the tool kept running
/// the unpatched copy; it is now a usage error pointing at `-g`.
#[test]
fn deno_tool_node_modules_deno_never_loads_is_a_usage_error() {
let tmp = tempfile::tempdir().unwrap();
for deno_json in [
r#"{"imports":{}}"#,
r#"{"nodeModulesDir":"none"}"#,
r#"{"nodeModulesDir":false}"#,
] {
let root = tempfile::tempdir().unwrap();
let decoy = stage_deno_tool_dir(root.path(), deno_json, false);
for command in COMMANDS.iter().filter(|c| !restores(c)) {
let (code, stdout, stderr) = run(
tmp.path(),
command,
&["--global-prefix", decoy.to_str().unwrap()],
);
assert_eq!(
code, 2,
"{deno_json} {command:?} must be a usage error\nstdout={stdout}\nstderr={stderr}"
);
assert!(
stderr.contains("--global-prefix")
&& stderr.contains("Deno does not load")
&& stderr.contains("-g"),
"{deno_json} {command:?}: {stderr}"
);
assert!(stdout.is_empty(), "{command:?}: {stdout}");
}
}
}

/// The commands that restore what an earlier run patched; they still
/// accept a Deno per-tool tree, so a decoy patched by an earlier release
/// can be reverted.
fn restores(command: &[&str]) -> bool {
matches!(
command.first(),
Some(&"rollback") | Some(&"remove") | Some(&"repair")
) || command.contains(&"--revert")
}

/// #444: `rollback`, `remove`, `vendor --revert` and `repair` still
/// accept the decoy tree, so files an earlier release patched there can
/// be restored.
#[test]
fn deno_tool_node_modules_decoy_stays_restorable() {
let tmp = tempfile::tempdir().unwrap();
let root = tempfile::tempdir().unwrap();
let decoy = stage_deno_tool_dir(root.path(), r#"{"imports":{}}"#, false);
for command in COMMANDS.iter().filter(|c| restores(c)) {
let (code, stdout, stderr) = run(
tmp.path(),
command,
&["--global-prefix", decoy.to_str().unwrap()],
);
assert!(
!stderr.contains("Deno does not load"),
"{command:?} (exit {code}) must not refuse the decoy\nstdout={stdout}\nstderr={stderr}"
);
}
}

/// #444: a per-tool `node_modules` Deno does load (an `npm:` entrypoint
/// tool, whose `deno.json` sets `"nodeModulesDir": "manual"`, or one with
/// a `package.json`) is still a valid `--global-prefix`.
#[test]
fn deno_tool_node_modules_deno_loads_is_accepted() {
let tmp = tempfile::tempdir().unwrap();
for (deno_json, package_json) in [
(r#"{"workspace":[],"nodeModulesDir":"manual"}"#, true),
(r#"{"nodeModulesDir":"auto"}"#, false),
(r#"{"imports":{}}"#, true),
] {
let root = tempfile::tempdir().unwrap();
let live = stage_deno_tool_dir(root.path(), deno_json, package_json);
let (code, stdout, stderr) = run(
tmp.path(),
&["list"],
&["--global-prefix", live.to_str().unwrap()],
);
assert_ne!(
code, 2,
"{deno_json}: a live tree is no usage error\nstdout={stdout}\nstderr={stderr}"
);
}
}
138 changes: 137 additions & 1 deletion crates/socket-patch-core/src/crawlers/deno_crawler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ use serde_json::{Map, Value};

use super::types::{CrawledPackage, CrawlerOptions};
use crate::patch::path_safety;
use crate::utils::fs::{home_dir, is_dir};
use crate::utils::fs::{home_dir, is_dir, read_regular_to_string_sync};

/// Deno (JSR) ecosystem crawler.
pub struct DenoCrawler;
Expand Down Expand Up @@ -332,6 +332,65 @@ async fn is_deno_project(cwd: &Path) -> bool {
false
}

/// Whether `path` is, or is inside, the per-tool `node_modules` Deno
/// (>= 2.7) writes beside a `deno install -g` tool,
/// `<install-root>/bin/.<tool>/node_modules`, that the tool never loads
/// (#444). A tool installed from a local or JSR entrypoint gets a
/// `deno.json` there with no `nodeModulesDir` and no `package.json`, so
/// Deno resolves its npm packages from `$DENO_DIR/npm` and the tree is a
/// decoy: patching it leaves the code the tool runs unpatched. An `npm:`
/// entrypoint tool's `deno.json` sets `"nodeModulesDir": "manual"` (with a
/// `package.json`), and Deno does load that tree. Symlinks are resolved
/// first, so a link to the decoy is judged as the decoy.
pub fn is_unloaded_deno_tool_node_modules(path: &Path) -> bool {
let path = std::fs::canonicalize(path)
.or_else(|_| std::path::absolute(path))
.unwrap_or_else(|_| path.to_path_buf());
path.ancestors().any(|dir| {
dir.file_name().is_some_and(|name| name == "node_modules")
&& dir.parent().is_some_and(tool_dir_skips_node_modules)
})
}

/// Whether `tool` is a Deno per-tool dir (`bin/.<tool>`) whose config
/// tells Deno not to use its `node_modules`, the way Deno decides it: an
/// explicit `nodeModulesDir` wins (`"none"` or `false` skip it), and when
/// unset, Deno uses it only beside a `package.json`. The config is read as
/// JSONC, as Deno reads it; one that does not parse is not judged.
fn tool_dir_skips_node_modules(tool: &Path) -> bool {
let hidden_tool_dir = tool
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.len() > 1 && name.starts_with('.'));
let under_bin = tool
.parent()
.and_then(Path::file_name)
.is_some_and(|name| name == "bin");
if !hidden_tool_dir || !under_bin {
return false;
}
let Some(config) = ["deno.json", "deno.jsonc"]
.iter()
.map(|name| tool.join(name))
.find(|config| config.is_file())
else {
return false;
};
let Some(config) = read_regular_to_string_sync(&config).ok().and_then(|text| {
let text =
crate::vendor::bun_lock_text::strip_jsonc(crate::formats::text::strip_bom(&text));
serde_json::from_str::<Value>(&text).ok()
}) else {
return false;
};
match config.get("nodeModulesDir") {
Some(Value::Bool(false)) => true,
Some(Value::String(mode)) => mode == "none",
None | Some(Value::Null) => !tool.join("package.json").exists(),
Some(_) => false,
}
}

/// Resolve `$DENO_DIR`, falling back to platform defaults.
///
/// Deno itself derives its default cache root from the platform's
Expand Down Expand Up @@ -910,6 +969,83 @@ mod tests {
}
}

/// #444: only Deno's `bin/.<tool>/node_modules` whose config leaves
/// `nodeModulesDir` unset (or `none`/`false`), with no `package.json`,
/// is a tree the tool never loads.
#[test]
fn unloaded_deno_tool_node_modules_matches_only_the_decoy() {
let tmp = tempfile::tempdir().unwrap();
let stage = |tool: &str, config: Option<(&str, &str)>, package_json: bool| {
let dir = tmp.path().join(tool);
std::fs::create_dir_all(dir.join("node_modules")).unwrap();
if let Some((name, text)) = config {
std::fs::write(dir.join(name), text).unwrap();
}
if package_json {
std::fs::write(dir.join("package.json"), "{}").unwrap();
}
dir.join("node_modules")
};
let decoy = stage("a/bin/.tool", Some(("deno.json", "{}")), false);
assert!(is_unloaded_deno_tool_node_modules(&decoy));
let none = stage(
"b/bin/.tool",
Some(("deno.jsonc", r#"{"nodeModulesDir":"none"}"#)),
false,
);
assert!(is_unloaded_deno_tool_node_modules(&none));

let manual = stage(
"c/bin/.tool",
Some(("deno.json", r#"{"nodeModulesDir":"manual"}"#)),
false,
);
assert!(!is_unloaded_deno_tool_node_modules(&manual));
let with_package_json = stage("d/bin/.tool", Some(("deno.json", "{}")), true);
assert!(!is_unloaded_deno_tool_node_modules(&with_package_json));
let no_config = stage("e/bin/.tool", None, false);
assert!(!is_unloaded_deno_tool_node_modules(&no_config));
let jsonc_comments = stage(
"f/bin/.tool",
Some(("deno.jsonc", "// c\n{\"nodeModulesDir\":\"auto\"}")),
false,
);
assert!(!is_unloaded_deno_tool_node_modules(&jsonc_comments));
let commented = stage(
"i/bin/.tool",
Some(("deno.jsonc", "\u{feff}// generated\n{\"imports\":{},}")),
false,
);
assert!(is_unloaded_deno_tool_node_modules(&commented));
let none_beside_package_json = stage(
"j/bin/.tool",
Some(("deno.json", r#"{"nodeModulesDir":"none"}"#)),
true,
);
assert!(is_unloaded_deno_tool_node_modules(
&none_beside_package_json
));
let nested = decoy
.join(".deno")
.join("is-odd@3.0.1")
.join("node_modules");
std::fs::create_dir_all(&nested).unwrap();
assert!(is_unloaded_deno_tool_node_modules(&nested));
#[cfg(unix)]
{
let link = tmp.path().join("decoy-link");
std::os::unix::fs::symlink(&decoy, &link).unwrap();
assert!(is_unloaded_deno_tool_node_modules(&link));
}
let not_under_bin = stage("g/lib/.tool", Some(("deno.json", "{}")), false);
assert!(!is_unloaded_deno_tool_node_modules(&not_under_bin));
let project = stage("h/bin/tool", Some(("deno.json", "{}")), false);
assert!(!is_unloaded_deno_tool_node_modules(&project));
assert!(!is_unloaded_deno_tool_node_modules(
&tmp.path().join("a/bin/.tool")
));
}

#[test]
#[serial_test::serial]
fn deno_dir_honors_explicit_env() {
Expand Down
48 changes: 46 additions & 2 deletions crates/socket-patch-core/src/crawlers/npm_crawler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1886,11 +1886,51 @@ const DENO_NPM_REGISTRY_MAX_DEPTH: usize = 8;
/// Deno crawler's JSR root, not an npm registry. Sorted, for a stable
/// scan order.
fn deno_npm_cache_roots() -> Vec<PathBuf> {
let Some(npm) = super::deno_crawler::deno_dir().map(|dir| dir.join("npm")) else {
match super::deno_crawler::deno_dir() {
Some(dir) => deno_npm_registry_dirs(&dir.join("npm")),
None => Vec::new(),
}
}

/// The registry dirs a `--global-prefix` naming a parent of Deno's npm
/// cache stands for (#444): `$DENO_DIR` (its `npm` child) or
/// `$DENO_DIR/npm` itself, walked the way [`deno_npm_cache_roots`] walks
/// the `-g` one. Empty for anything else: a `node_modules`, a dir that is
/// itself a package (an `npm` with a `package.json` is the npm CLI, not
/// Deno's cache), an `npm` holding a `node_modules` (npm's own global
/// prefix on Windows, `%APPDATA%\npm`), or one with no registry dir below
/// it, so every other prefix keeps its `node_modules` walk.
fn deno_cache_parent_registry_dirs(prefix: &Path) -> Vec<PathBuf> {
let prefix = std::path::absolute(prefix).unwrap_or_else(|_| prefix.to_path_buf());
let prefix = prefix.as_path();
let is_cache_npm_dir = |dir: &Path| {
dir.file_name().is_some_and(|name| name == "npm")
&& dir.is_dir()
&& !dir.join("package.json").exists()
&& !dir.join("node_modules").exists()
};
if prefix
.file_name()
.is_some_and(|name| name == "node_modules")
|| prefix.join("package.json").exists()
{
return Vec::new();
}
let npm = if is_cache_npm_dir(prefix) {
prefix.to_path_buf()
} else if is_cache_npm_dir(&prefix.join("npm")) {
prefix.join("npm")
} else {
return Vec::new();
};
deno_npm_registry_dirs(&npm)
}

/// Every registry dir below `npm`, a Deno npm cache dir
/// (`$DENO_DIR/npm`); see [`deno_npm_cache_roots`].
fn deno_npm_registry_dirs(npm: &Path) -> Vec<PathBuf> {
let mut roots = Vec::new();
let mut level: Vec<PathBuf> = sorted_subdirs_sync(&npm)
let mut level: Vec<PathBuf> = sorted_subdirs_sync(npm)
.into_iter()
.filter(|name| name != "jsr.io")
.map(|name| npm.join(name))
Expand Down Expand Up @@ -3279,6 +3319,10 @@ impl NpmCrawler {
if is_deno_npm_cache_root(custom) {
return vec![custom.clone()];
}
let registries = deno_cache_parent_registry_dirs(custom);
if !registries.is_empty() {
return registries;
}
return pnpm_isolated_global_install_roots(custom);
}
return NpmCrawler.get_global_node_modules_paths();
Expand Down
Loading
Loading