Skip to main content

rpi_cli/
npm.rs

1//! Native Pi-compatible npm command selection and argument construction.
2//!
3//! `npmCommand` is an argv array, not a shell command. The first entry is the
4//! executable and every remaining entry is a fixed prefix prepended to npm
5//! operations. This permits wrappers such as
6//! `["mise", "exec", "node@20", "--", "npm"]` without shell parsing.
7
8use std::ffi::OsString;
9use std::fmt;
10use std::path::{Component, Path, PathBuf};
11use std::process::ExitStatus;
12use std::time::Duration;
13
14use tokio::io::AsyncReadExt;
15
16#[cfg(unix)]
17use std::process::Stdio;
18
19use crate::settings::Settings;
20
21const NPM_PROCESS_TIMEOUT: Duration = Duration::from_secs(5);
22const MAX_NPM_PROCESS_OUTPUT_BYTES: usize = 64 * 1024;
23const NPM_STARTUP_REMEDIATION_TIMEOUT: Duration = Duration::from_secs(120);
24const MAX_NPM_STARTUP_REMEDIATION_OUTPUT_BYTES: usize = 1024 * 1024;
25const NPM_PROCESS_POLL_INTERVAL: Duration = Duration::from_millis(10);
26const NPM_TERMINATION_TIMEOUT: Duration = Duration::from_secs(2);
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub(crate) enum NpmProcessCwd {
30    /// Run outside the caller's working tree so project-local npm config is
31    /// not consulted before that project has passed its trust gate.
32    Isolated,
33    /// Use a project directory only after the caller has established trust.
34    Trusted(PathBuf),
35}
36
37impl NpmProcessCwd {
38    pub(crate) fn startup(trusted_project_cwd: Option<&Path>) -> Self {
39        trusted_project_cwd
40            .map(|path| Self::Trusted(path.to_path_buf()))
41            .unwrap_or(Self::Isolated)
42    }
43}
44
45#[derive(Debug)]
46pub(crate) struct NpmProcessOutput {
47    pub(crate) status: ExitStatus,
48    pub(crate) stdout: Vec<u8>,
49    pub(crate) stderr: Vec<u8>,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub(crate) enum NpmProcessError {
54    Setup(String),
55    Spawn(String),
56    Read(String),
57    Wait(String),
58    Cancelled,
59    TimedOut,
60    OutputLimitExceeded { limit: usize },
61}
62
63impl fmt::Display for NpmProcessError {
64    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
65        match self {
66            Self::Setup(error) => write!(formatter, "could not prepare npm process: {error}"),
67            Self::Spawn(error) => write!(formatter, "could not start npm process: {error}"),
68            Self::Read(error) => write!(formatter, "could not read npm process output: {error}"),
69            Self::Wait(error) => write!(formatter, "could not wait for npm process: {error}"),
70            Self::Cancelled => write!(formatter, "npm process was cancelled"),
71            Self::TimedOut => write!(formatter, "npm process timed out"),
72            Self::OutputLimitExceeded { limit } => {
73                write!(
74                    formatter,
75                    "npm process exceeded the {limit} byte output limit"
76                )
77            }
78        }
79    }
80}
81
82#[derive(Debug, Clone, Copy)]
83struct NpmProcessLimits {
84    timeout: Duration,
85    max_output_bytes: usize,
86}
87
88impl Default for NpmProcessLimits {
89    fn default() -> Self {
90        Self {
91            timeout: NPM_PROCESS_TIMEOUT,
92            max_output_bytes: MAX_NPM_PROCESS_OUTPUT_BYTES,
93        }
94    }
95}
96
97impl NpmProcessLimits {
98    fn startup_remediation() -> Self {
99        Self {
100            timeout: NPM_STARTUP_REMEDIATION_TIMEOUT,
101            max_output_bytes: MAX_NPM_STARTUP_REMEDIATION_OUTPUT_BYTES,
102        }
103    }
104}
105
106/// Package-manager behavior selected from the effective `npmCommand` argv.
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub enum NpmManagerKind {
109    Npm,
110    Pnpm,
111    Bun,
112    Other,
113}
114
115/// A validated package-manager executable plus its fixed argument prefix.
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct NpmCommand {
118    program: String,
119    prefix_args: Vec<String>,
120    manager_kind: NpmManagerKind,
121    configured: bool,
122}
123
124impl NpmCommand {
125    /// Resolve the effective command using `.rpi`, `.pi`, global, then npm.
126    /// Project settings are considered only after the project trust gate has
127    /// passed because `npmCommand` can name an arbitrary executable.
128    pub fn resolve(cwd: &Path, project_trusted: bool) -> Result<Self, String> {
129        let project_settings = if project_trusted {
130            crate::settings::load_project_settings(cwd)
131        } else {
132            Vec::new()
133        };
134        let global_settings = crate::settings::load_settings()
135            .map_err(|error| format!("could not load npmCommand settings: {error}"))?;
136        Self::from_argv(select_argv(
137            &project_settings,
138            &global_settings,
139            project_trusted,
140        ))
141    }
142
143    /// Build a command from Pi's optional argv setting. A missing or empty
144    /// array selects the platform npm launcher. A blank executable is invalid
145    /// and never falls back to another command.
146    pub fn from_argv(argv: Option<&[String]>) -> Result<Self, String> {
147        let Some(argv) = argv.filter(|argv| !argv.is_empty()) else {
148            let program = default_npm_program().to_string();
149            return Ok(Self {
150                manager_kind: manager_kind(&program, &[]),
151                program,
152                prefix_args: Vec::new(),
153                configured: false,
154            });
155        };
156        if argv[0].trim().is_empty() {
157            return Err(
158                "invalid npmCommand: first array entry must be a non-empty command".to_string(),
159            );
160        }
161        let program = windows_command_program(&argv[0]);
162        let prefix_args = argv[1..].to_vec();
163        Ok(Self {
164            manager_kind: manager_kind(&program, &prefix_args),
165            program,
166            prefix_args,
167            configured: true,
168        })
169    }
170
171    /// Executable launched as an argv command. Windows `.cmd`/`.bat` shims
172    /// use the standard library's hardened batch argument encoding.
173    pub fn program(&self) -> &str {
174        &self.program
175    }
176
177    /// Fixed arguments configured before each package-manager operation.
178    pub fn prefix_args(&self) -> &[String] {
179        &self.prefix_args
180    }
181
182    /// Whether the effective command came from an explicit `npmCommand`.
183    pub fn is_configured(&self) -> bool {
184        self.configured
185    }
186
187    /// Package-manager family used for manager-specific install flags.
188    pub fn manager_kind(&self) -> NpmManagerKind {
189        self.manager_kind
190    }
191
192    /// Prepend the fixed configured argv to one operation's arguments.
193    pub fn combined_args(&self, operation_args: &[String]) -> Vec<String> {
194        let mut args = Vec::with_capacity(self.prefix_args.len() + operation_args.len());
195        args.extend(self.prefix_args.iter().cloned());
196        args.extend(operation_args.iter().cloned());
197        args
198    }
199
200    /// Construct the complete argv for native Pi's registry version lookup.
201    pub fn view_args(&self, source_spec: &str) -> Result<Vec<String>, String> {
202        let spec = source_spec
203            .strip_prefix("npm:")
204            .unwrap_or(source_spec)
205            .trim();
206        if spec.is_empty() || spec.starts_with('-') || spec.chars().any(char::is_control) {
207            return Err("npm package spec must be a safe, non-empty argument".to_string());
208        }
209        Ok(self.combined_args(&[
210            "view".to_string(),
211            spec.to_string(),
212            "version".to_string(),
213            "--json".to_string(),
214        ]))
215    }
216
217    /// Construct manager-specific operation args for a managed npm root.
218    /// These match native Pi's `getNpmInstallArgs` ordering exactly.
219    pub fn install_args(&self, specs: &[String], install_root: &Path) -> Vec<String> {
220        let root = install_root.to_string_lossy().into_owned();
221        match self.manager_kind {
222            NpmManagerKind::Bun => {
223                let mut args = vec!["install".to_string()];
224                args.extend(specs.iter().cloned());
225                args.extend(["--cwd".to_string(), root, "--omit=peer".to_string()]);
226                args
227            }
228            NpmManagerKind::Pnpm => {
229                let mut args = vec!["install".to_string()];
230                args.extend(specs.iter().cloned());
231                args.extend([
232                    "--prefix".to_string(),
233                    root,
234                    "--config.auto-install-peers=false".to_string(),
235                    "--config.strict-peer-dependencies=false".to_string(),
236                    "--config.strict-dep-builds=false".to_string(),
237                ]);
238                args
239            }
240            NpmManagerKind::Npm | NpmManagerKind::Other => {
241                let mut args = vec!["install".to_string()];
242                args.extend(specs.iter().cloned());
243                args.extend([
244                    "--prefix".to_string(),
245                    root,
246                    "--legacy-peer-deps".to_string(),
247                ]);
248                args
249            }
250        }
251    }
252
253    /// Construct manager-specific arguments for removing one package from a
254    /// managed npm root. The ordering and flags match native Pi's
255    /// `uninstallNpm` implementation.
256    pub fn uninstall_args(&self, package_name: &str, install_root: &Path) -> Vec<String> {
257        let root = install_root.to_string_lossy().into_owned();
258        match self.manager_kind {
259            NpmManagerKind::Bun => vec![
260                "uninstall".to_string(),
261                package_name.to_string(),
262                "--cwd".to_string(),
263                root,
264            ],
265            NpmManagerKind::Pnpm => vec![
266                "uninstall".to_string(),
267                package_name.to_string(),
268                "--prefix".to_string(),
269                root,
270            ],
271            NpmManagerKind::Npm | NpmManagerKind::Other => vec![
272                "uninstall".to_string(),
273                package_name.to_string(),
274                "--prefix".to_string(),
275                root,
276                "--legacy-peer-deps".to_string(),
277            ],
278        }
279    }
280
281    /// Resolve the package-manager's legacy global install root(s).
282    ///
283    /// Native Pi uses the configured `npmCommand` for this lookup. The result
284    /// is intentionally read-only metadata: callers may use it to discover a
285    /// package that predates Pi's managed store, but must not use it as an
286    /// rpi-owned install or removal root. Every returned path is absolute,
287    /// canonical, an existing directory, and ends in `node_modules`.
288    pub fn global_package_roots(&self) -> Result<Vec<PathBuf>, String> {
289        match self.manager_kind {
290            // Bun exposes the global binary directory rather than a node_modules
291            // root. This is the same derivation used by native Pi.
292            NpmManagerKind::Bun => {
293                let output = self.capture_output(&["pm", "bin", "-g"])?;
294                let bin_dir = parse_single_absolute_line(&output).ok_or_else(|| {
295                    format!(
296                        "{} pm bin -g returned an empty or non-absolute path",
297                        self.program
298                    )
299                })?;
300                let parent = bin_dir.parent().ok_or_else(|| {
301                    format!(
302                        "{} pm bin -g returned a path without a parent",
303                        self.program
304                    )
305                })?;
306                let candidate = parent.join("install").join("global").join("node_modules");
307                validate_global_root(&candidate)
308                    .map(|root| vec![root])
309                    .ok_or_else(|| {
310                        format!(
311                            "{} pm bin -g did not resolve to a valid global node_modules path: {}",
312                            self.program,
313                            candidate.display()
314                        )
315                    })
316            }
317            // npm, pnpm, and compatible wrappers expose `root -g`. For pnpm
318            // package paths themselves we additionally consult `list -g`
319            // below, because pnpm's virtual store is nested below this root.
320            NpmManagerKind::Npm | NpmManagerKind::Pnpm | NpmManagerKind::Other => {
321                let output = self.capture_output(&["root", "-g"])?;
322                let line = parse_single_absolute_line(&output).ok_or_else(|| {
323                    format!(
324                        "{} root -g returned an empty or non-absolute path",
325                        self.program
326                    )
327                })?;
328                validate_global_root(&line)
329                    .map(|root| vec![root])
330                    .ok_or_else(|| {
331                        format!(
332                            "{} root -g returned an invalid global node_modules path: {}",
333                            self.program,
334                            line.display()
335                        )
336                    })
337            }
338        }
339    }
340
341    /// Locate packages in a legacy global install with one package-manager
342    /// query for the whole batch.
343    ///
344    /// npm/bun use `<global root>/<package name>`. pnpm reports the concrete
345    /// virtual-store path through `list -g --depth 0 --json`, so that output
346    /// is parsed instead of guessing a symlink layout. Returned paths are
347    /// canonical read-only discovery paths; package updates must migrate into
348    /// the rpi/native managed root before writing anything.
349    pub fn global_package_paths(
350        &self,
351        package_names: &[String],
352    ) -> Result<std::collections::HashMap<String, PathBuf>, String> {
353        for package_name in package_names {
354            if !valid_package_name(package_name) {
355                return Err(format!(
356                    "invalid npm package name for global lookup: {package_name}"
357                ));
358            }
359        }
360        if package_names.is_empty() {
361            return Ok(std::collections::HashMap::new());
362        }
363
364        if self.manager_kind == NpmManagerKind::Pnpm {
365            let output = self.capture_output(&["list", "-g", "--depth", "0", "--json"])?;
366            return Ok(parse_pnpm_global_package_paths(&output, package_names));
367        }
368
369        let roots = self.global_package_roots()?;
370        Ok(package_names
371            .iter()
372            .filter_map(|package_name| {
373                roots
374                    .iter()
375                    .find_map(|root| validate_global_package_path(root, package_name))
376                    .map(|path| (package_name.clone(), path))
377            })
378            .collect())
379    }
380
381    /// Locate one package using the same validation and bounded lookup as the
382    /// batch API.
383    pub fn global_package_path(&self, package_name: &str) -> Result<Option<PathBuf>, String> {
384        let package_name = package_name.to_string();
385        Ok(self
386            .global_package_paths(std::slice::from_ref(&package_name))?
387            .remove(&package_name))
388    }
389
390    /// Validate a command output path without executing a package manager.
391    /// This is useful to callers that cache global-root discovery and to tests;
392    /// it does not grant write or delete authority.
393    pub fn validate_global_package_path(root: &Path, package_name: &str) -> Option<PathBuf> {
394        validate_global_package_path(root, package_name)
395    }
396
397    /// Execute one npm operation without user-controlled shell parsing, with a
398    /// hard deadline and a combined stdout/stderr byte budget. Cancellation
399    /// always terminates and waits for the child tree before returning.
400    pub(crate) async fn run_bounded(
401        &self,
402        args: &[String],
403        cwd: NpmProcessCwd,
404    ) -> Result<NpmProcessOutput, NpmProcessError> {
405        run_bounded_process(&self.program, args, cwd, NpmProcessLimits::default()).await
406    }
407
408    /// Run an npm operation synchronously during startup package remediation.
409    /// This uses a dedicated runtime thread because startup resolution is a
410    /// synchronous compatibility boundary that can be called from Tokio. The
411    /// longer install budget remains finite, captures bounded diagnostics, and
412    /// reuses the same process-group/Job Object teardown as registry lookups.
413    pub(crate) fn run_startup_remediation(
414        &self,
415        operation_args: &[String],
416        cwd: &Path,
417    ) -> Result<(), String> {
418        let output = self
419            .run_startup_remediation_with_limits(
420                operation_args,
421                cwd,
422                NpmProcessLimits::startup_remediation(),
423            )
424            .map_err(|error| {
425                format!("{} startup package operation failed: {error}", self.program)
426            })?;
427        if output.status.success() {
428            return Ok(());
429        }
430        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
431        let detail = if stderr.is_empty() {
432            format!("exited with {}", output.status)
433        } else {
434            format!(
435                "exited with {}: {}",
436                output.status,
437                truncate_output(&stderr)
438            )
439        };
440        Err(format!("{} {detail}", self.program))
441    }
442
443    fn run_startup_remediation_with_limits(
444        &self,
445        operation_args: &[String],
446        cwd: &Path,
447        limits: NpmProcessLimits,
448    ) -> Result<NpmProcessOutput, NpmProcessError> {
449        let args = self.combined_args(operation_args);
450        run_bounded_process_blocking(
451            &self.program,
452            &args,
453            NpmProcessCwd::Trusted(cwd.to_path_buf()),
454            limits,
455            "rpi-npm-startup-remediation",
456        )
457    }
458
459    fn capture_output(&self, operation_args: &[&str]) -> Result<String, String> {
460        let operation_args = operation_args
461            .iter()
462            .map(|arg| (*arg).to_string())
463            .collect::<Vec<_>>();
464        let args = self.combined_args(&operation_args);
465        let output = run_bounded_process_blocking(
466            &self.program,
467            &args,
468            NpmProcessCwd::Isolated,
469            NpmProcessLimits::default(),
470            "rpi-npm-global-lookup",
471        )
472        .map_err(|error| {
473            format!(
474                "could not execute {} for global package lookup: {error}",
475                self.program
476            )
477        })?;
478        if !output.status.success() {
479            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
480            let detail = if stderr.is_empty() {
481                format!("exited with {}", output.status)
482            } else {
483                // Keep diagnostics bounded; package-manager failures can dump
484                // a very large log and this path is only a best-effort lookup.
485                format!(
486                    "exited with {}: {}",
487                    output.status,
488                    truncate_output(&stderr)
489                )
490            };
491            return Err(format!("{} {}", self.program, detail));
492        }
493        String::from_utf8(output.stdout).map_err(|error| {
494            format!(
495                "{} global package lookup returned non-UTF-8 output: {error}",
496                self.program
497            )
498        })
499    }
500}
501
502fn run_bounded_process_blocking(
503    program: &str,
504    args: &[String],
505    cwd: NpmProcessCwd,
506    limits: NpmProcessLimits,
507    thread_name: &str,
508) -> Result<NpmProcessOutput, NpmProcessError> {
509    run_bounded_process_blocking_with_environment(
510        program,
511        args,
512        cwd,
513        limits,
514        Vec::new(),
515        thread_name,
516    )
517}
518
519/// Synchronous counterpart to [`run_bounded_command`]. The process runs on a
520/// dedicated Tokio runtime thread, so callers may safely use this at a sync
521/// startup boundary even when that boundary is entered from a Tokio runtime.
522pub(crate) fn run_bounded_command_blocking(
523    program: &str,
524    args: &[String],
525    cwd: &Path,
526    environment: &[(OsString, Option<OsString>)],
527    timeout: Duration,
528    max_output_bytes: usize,
529) -> Result<NpmProcessOutput, NpmProcessError> {
530    run_bounded_process_blocking_with_environment(
531        program,
532        args,
533        NpmProcessCwd::Trusted(cwd.to_path_buf()),
534        NpmProcessLimits {
535            timeout,
536            max_output_bytes,
537        },
538        environment.to_vec(),
539        "rpi-bounded-startup-command",
540    )
541}
542
543fn run_bounded_process_blocking_with_environment(
544    program: &str,
545    args: &[String],
546    cwd: NpmProcessCwd,
547    limits: NpmProcessLimits,
548    environment: Vec<(OsString, Option<OsString>)>,
549    thread_name: &str,
550) -> Result<NpmProcessOutput, NpmProcessError> {
551    let program = program.to_string();
552    let args = args.to_vec();
553    std::thread::Builder::new()
554        .name(thread_name.to_string())
555        .spawn(move || {
556            let runtime = tokio::runtime::Builder::new_current_thread()
557                .enable_all()
558                .build()
559                .map_err(|error| NpmProcessError::Setup(error.to_string()))?;
560            runtime.block_on(run_bounded_process_inner(
561                &program,
562                &args,
563                cwd,
564                limits,
565                environment,
566                None,
567            ))
568        })
569        .map_err(|error| NpmProcessError::Setup(error.to_string()))?
570        .join()
571        .map_err(|_| NpmProcessError::Setup("npm process worker panicked".to_string()))?
572}
573
574async fn run_bounded_process(
575    program: &str,
576    args: &[String],
577    cwd: NpmProcessCwd,
578    limits: NpmProcessLimits,
579) -> Result<NpmProcessOutput, NpmProcessError> {
580    run_bounded_process_with_environment(program, args, cwd, limits, Vec::new()).await
581}
582
583/// Run a command with the same bounded output and whole-process-tree cleanup
584/// used by npm startup work. Environment entries with `Some(value)` are set;
585/// entries with `None` are removed from the inherited environment.
586pub(crate) async fn run_bounded_command(
587    program: &str,
588    args: &[String],
589    cwd: &Path,
590    environment: &[(OsString, Option<OsString>)],
591    timeout: Duration,
592    max_output_bytes: usize,
593) -> Result<NpmProcessOutput, NpmProcessError> {
594    run_bounded_process_with_environment(
595        program,
596        args,
597        NpmProcessCwd::Trusted(cwd.to_path_buf()),
598        NpmProcessLimits {
599            timeout,
600            max_output_bytes,
601        },
602        environment.to_vec(),
603    )
604    .await
605}
606
607async fn run_bounded_process_with_environment(
608    program: &str,
609    args: &[String],
610    cwd: NpmProcessCwd,
611    limits: NpmProcessLimits,
612    environment: Vec<(OsString, Option<OsString>)>,
613) -> Result<NpmProcessOutput, NpmProcessError> {
614    let (cancel_tx, cancel_rx) = std::sync::mpsc::channel();
615    let (result_tx, result_rx) = tokio::sync::oneshot::channel();
616    let program = program.to_string();
617    let args = args.to_vec();
618    let supervisor = std::thread::Builder::new()
619        .name("rpi-npm-process".to_string())
620        .spawn(move || {
621            let result = tokio::runtime::Builder::new_current_thread()
622                .enable_all()
623                .build()
624                .map_err(|error| NpmProcessError::Setup(error.to_string()))
625                .and_then(|runtime| {
626                    runtime.block_on(run_bounded_process_inner(
627                        &program,
628                        &args,
629                        cwd,
630                        limits,
631                        environment,
632                        Some(cancel_rx),
633                    ))
634                });
635            let _ = result_tx.send(result);
636        })
637        .map_err(|error| NpmProcessError::Setup(error.to_string()))?;
638    let mut supervisor = NpmProcessSupervisor {
639        cancel: Some(cancel_tx),
640        thread: Some(supervisor),
641    };
642
643    let result = result_rx.await.map_err(|_| {
644        NpmProcessError::Setup("npm process supervisor stopped unexpectedly".to_string())
645    })?;
646    supervisor.finish();
647    result
648}
649
650struct NpmProcessSupervisor {
651    cancel: Option<std::sync::mpsc::Sender<()>>,
652    thread: Option<std::thread::JoinHandle<()>>,
653}
654
655impl NpmProcessSupervisor {
656    fn finish(&mut self) {
657        self.cancel.take();
658        if let Some(thread) = self.thread.take() {
659            let _ = thread.join();
660        }
661    }
662}
663
664impl Drop for NpmProcessSupervisor {
665    fn drop(&mut self) {
666        // Dropping the async caller signals the independent supervisor and
667        // synchronously joins it, so task cancellation cannot skip tree
668        // termination or leave the child unreaped.
669        self.finish();
670    }
671}
672
673async fn run_bounded_process_inner(
674    program: &str,
675    args: &[String],
676    cwd: NpmProcessCwd,
677    limits: NpmProcessLimits,
678    environment: Vec<(OsString, Option<OsString>)>,
679    cancellation: Option<std::sync::mpsc::Receiver<()>>,
680) -> Result<NpmProcessOutput, NpmProcessError> {
681    let isolated_dir = match &cwd {
682        NpmProcessCwd::Isolated => {
683            Some(tempfile::tempdir().map_err(|error| NpmProcessError::Setup(error.to_string()))?)
684        }
685        NpmProcessCwd::Trusted(_) => None,
686    };
687    let cwd = match &cwd {
688        NpmProcessCwd::Isolated => isolated_dir
689            .as_ref()
690            .map(tempfile::TempDir::path)
691            .expect("isolated npm directory was just created"),
692        NpmProcessCwd::Trusted(path) => {
693            let metadata = std::fs::metadata(&path)
694                .map_err(|error| NpmProcessError::Setup(error.to_string()))?;
695            if !metadata.is_dir() {
696                return Err(NpmProcessError::Setup(format!(
697                    "trusted npm cwd is not a directory: {}",
698                    path.display()
699                )));
700            }
701            path.as_path()
702        }
703    };
704
705    let mut child = ManagedNpmChild::spawn(program, args, cwd, &environment)?;
706    let mut stdout = child
707        .take_stdout()
708        .ok_or_else(|| NpmProcessError::Setup("npm stdout was not piped".to_string()))?;
709    let mut stderr = child
710        .take_stderr()
711        .ok_or_else(|| NpmProcessError::Setup("npm stderr was not piped".to_string()))?;
712    let mut stdout_bytes = Vec::new();
713    let mut stderr_bytes = Vec::new();
714    let mut stdout_chunk = [0u8; 8192];
715    let mut stderr_chunk = [0u8; 8192];
716    let mut stdout_done = false;
717    let mut stderr_done = false;
718    let mut status = None;
719    let deadline = tokio::time::Instant::now() + limits.timeout;
720    let deadline_sleep = tokio::time::sleep_until(deadline);
721    tokio::pin!(deadline_sleep);
722
723    loop {
724        let cancelled = cancellation.as_ref().is_some_and(|receiver| {
725            !matches!(
726                receiver.try_recv(),
727                Err(std::sync::mpsc::TryRecvError::Empty)
728            )
729        });
730        if cancelled {
731            child.terminate().await;
732            return Err(NpmProcessError::Cancelled);
733        }
734        if status.is_none() {
735            status = match child.try_wait() {
736                Ok(status) => status,
737                Err(error) => {
738                    child.terminate().await;
739                    return Err(NpmProcessError::Wait(error.to_string()));
740                }
741            };
742        }
743        if status.is_some() {
744            if stdout_done && stderr_done {
745                let status = child
746                    .wait()
747                    .await
748                    .map_err(|error| NpmProcessError::Wait(error.to_string()))?;
749                return Ok(NpmProcessOutput {
750                    status,
751                    stdout: stdout_bytes,
752                    stderr: stderr_bytes,
753                });
754            }
755        }
756
757        tokio::select! {
758            biased;
759
760            _ = &mut deadline_sleep => {
761                child.terminate().await;
762                return Err(NpmProcessError::TimedOut);
763            }
764            read = stdout.read(&mut stdout_chunk), if !stdout_done => {
765                let read = match read {
766                    Ok(read) => read,
767                    Err(error) => {
768                        child.terminate().await;
769                        return Err(NpmProcessError::Read(error.to_string()));
770                    }
771                };
772                if read == 0 {
773                    stdout_done = true;
774                } else if !append_bounded(
775                    &mut stdout_bytes,
776                    &stdout_chunk[..read],
777                    stderr_bytes.len(),
778                    limits.max_output_bytes,
779                ) {
780                    child.terminate().await;
781                    return Err(NpmProcessError::OutputLimitExceeded {
782                        limit: limits.max_output_bytes,
783                    });
784                }
785            }
786            read = stderr.read(&mut stderr_chunk), if !stderr_done => {
787                let read = match read {
788                    Ok(read) => read,
789                    Err(error) => {
790                        child.terminate().await;
791                        return Err(NpmProcessError::Read(error.to_string()));
792                    }
793                };
794                if read == 0 {
795                    stderr_done = true;
796                } else if !append_bounded(
797                    &mut stderr_bytes,
798                    &stderr_chunk[..read],
799                    stdout_bytes.len(),
800                    limits.max_output_bytes,
801                ) {
802                    child.terminate().await;
803                    return Err(NpmProcessError::OutputLimitExceeded {
804                        limit: limits.max_output_bytes,
805                    });
806                }
807            }
808            _ = tokio::time::sleep(NPM_PROCESS_POLL_INTERVAL) => {}
809        }
810    }
811}
812
813fn append_bounded(
814    destination: &mut Vec<u8>,
815    bytes: &[u8],
816    other_stream_len: usize,
817    max_output_bytes: usize,
818) -> bool {
819    let remaining =
820        max_output_bytes.saturating_sub(destination.len().saturating_add(other_stream_len));
821    let accepted = remaining.min(bytes.len());
822    destination.extend_from_slice(&bytes[..accepted]);
823    accepted == bytes.len()
824}
825
826#[cfg(unix)]
827struct ManagedNpmChild {
828    child: tokio::process::Child,
829}
830
831#[cfg(unix)]
832impl ManagedNpmChild {
833    fn spawn(
834        program: &str,
835        args: &[String],
836        cwd: &Path,
837        environment: &[(OsString, Option<OsString>)],
838    ) -> Result<Self, NpmProcessError> {
839        use std::os::unix::process::CommandExt;
840
841        let mut command = tokio::process::Command::new(program);
842        command
843            .args(args)
844            .stdin(Stdio::null())
845            .stdout(Stdio::piped())
846            .stderr(Stdio::piped())
847            .kill_on_drop(true)
848            .current_dir(cwd);
849        for (name, value) in environment {
850            match value {
851                Some(value) => {
852                    command.env(name, value);
853                }
854                None => {
855                    command.env_remove(name);
856                }
857            }
858        }
859        command.as_std_mut().process_group(0);
860        command
861            .spawn()
862            .map(|child| Self { child })
863            .map_err(|error| NpmProcessError::Spawn(error.to_string()))
864    }
865
866    fn take_stdout(&mut self) -> Option<tokio::process::ChildStdout> {
867        self.child.stdout.take()
868    }
869
870    fn take_stderr(&mut self) -> Option<tokio::process::ChildStderr> {
871        self.child.stderr.take()
872    }
873
874    fn try_wait(&mut self) -> std::io::Result<Option<ExitStatus>> {
875        self.child.try_wait()
876    }
877
878    async fn wait(&mut self) -> std::io::Result<ExitStatus> {
879        self.child.wait().await
880    }
881
882    async fn terminate(&mut self) {
883        if let Some(pid) = self.child.id().and_then(|pid| i32::try_from(pid).ok()) {
884            unsafe {
885                unix_kill(-pid, 9);
886            }
887        }
888        let _ = self.child.start_kill();
889        let _ = tokio::time::timeout(NPM_TERMINATION_TIMEOUT, self.child.wait()).await;
890    }
891}
892
893#[cfg(unix)]
894extern "C" {
895    #[link_name = "kill"]
896    fn unix_kill(pid: i32, signal: i32) -> i32;
897}
898
899#[cfg(windows)]
900use windows_process::ManagedNpmChild;
901
902#[cfg(windows)]
903mod windows_process {
904    use std::ffi::{OsStr, OsString};
905    use std::io;
906    use std::mem::{size_of, zeroed};
907    use std::os::windows::ffi::{OsStrExt, OsStringExt};
908    use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
909    use std::os::windows::process::ExitStatusExt;
910    use std::path::{Component, Path, PathBuf};
911    use std::process::ExitStatus;
912    use std::ptr::{null, null_mut};
913    use std::time::Duration;
914
915    use windows_sys::Win32::Foundation::{
916        HANDLE, HANDLE_FLAG_INHERIT, INVALID_HANDLE_VALUE, TRUE, WAIT_FAILED, WAIT_OBJECT_0,
917        WAIT_TIMEOUT,
918    };
919    use windows_sys::Win32::Security::SECURITY_ATTRIBUTES;
920    use windows_sys::Win32::System::JobObjects::{
921        AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation,
922        SetInformationJobObject, TerminateJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
923        JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
924    };
925    use windows_sys::Win32::System::Pipes::CreatePipe;
926    use windows_sys::Win32::System::SystemInformation::{
927        GetSystemDirectoryW, GetWindowsDirectoryW,
928    };
929    use windows_sys::Win32::System::Threading::{
930        CreateProcessW, DeleteProcThreadAttributeList, GetExitCodeProcess,
931        InitializeProcThreadAttributeList, ResumeThread, TerminateProcess,
932        UpdateProcThreadAttribute, WaitForSingleObject, CREATE_NO_WINDOW, CREATE_SUSPENDED,
933        CREATE_UNICODE_ENVIRONMENT, EXTENDED_STARTUPINFO_PRESENT, LPPROC_THREAD_ATTRIBUTE_LIST,
934        PROCESS_INFORMATION, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, STARTF_USESTDHANDLES,
935        STARTUPINFOEXW,
936    };
937
938    use super::{NpmProcessError, NPM_PROCESS_POLL_INTERVAL, NPM_TERMINATION_TIMEOUT};
939
940    pub(super) struct ManagedNpmChild {
941        process: OwnedHandle,
942        job: Option<OwnedHandle>,
943        stdout: Option<tokio::fs::File>,
944        stderr: Option<tokio::fs::File>,
945    }
946
947    impl ManagedNpmChild {
948        pub(super) fn spawn(
949            program: &str,
950            args: &[String],
951            cwd: &Path,
952            environment: &[(OsString, Option<OsString>)],
953        ) -> Result<Self, NpmProcessError> {
954            Self::spawn_inner(program, args, cwd, environment)
955                .map_err(|error| NpmProcessError::Spawn(error.to_string()))
956        }
957
958        fn spawn_inner(
959            program: &str,
960            args: &[String],
961            cwd: &Path,
962            environment: &[(OsString, Option<OsString>)],
963        ) -> io::Result<Self> {
964            let resolved_program = resolve_executable(program)?;
965            let is_batch = resolved_program
966                .extension()
967                .and_then(OsStr::to_str)
968                .is_some_and(|extension| {
969                    extension.eq_ignore_ascii_case("cmd") || extension.eq_ignore_ascii_case("bat")
970                });
971            let (application, mut command_line) = if is_batch {
972                (
973                    system_directory()?.join("cmd.exe"),
974                    make_batch_command_line(&resolved_program, args)?,
975                )
976            } else {
977                (resolved_program, make_command_line(program, args)?)
978            };
979            command_line.push(0);
980            let application = encode_path_nul(&application)?;
981            let cwd = encode_path_nul(cwd)?;
982            let mut environment = make_environment_block(environment)?;
983
984            let job = create_kill_on_close_job()?;
985            let stdin = create_eof_stdin()?;
986            let (stdout, child_stdout) = create_output_pipe()?;
987            let (stderr, child_stderr) = create_output_pipe()?;
988            let inherited_handles = [
989                raw_handle(&stdin),
990                raw_handle(&child_stdout),
991                raw_handle(&child_stderr),
992            ];
993            let mut attributes = ProcThreadAttributeList::new(1)?;
994            attributes.set_handle_list(&inherited_handles)?;
995
996            let mut startup: STARTUPINFOEXW = unsafe { zeroed() };
997            startup.StartupInfo.cb = u32::try_from(size_of::<STARTUPINFOEXW>())
998                .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
999            startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES;
1000            startup.StartupInfo.hStdInput = inherited_handles[0];
1001            startup.StartupInfo.hStdOutput = inherited_handles[1];
1002            startup.StartupInfo.hStdError = inherited_handles[2];
1003            startup.lpAttributeList = attributes.as_mut_ptr();
1004
1005            let mut process_info: PROCESS_INFORMATION = unsafe { zeroed() };
1006            let flags = CREATE_NO_WINDOW
1007                | CREATE_SUSPENDED
1008                | CREATE_UNICODE_ENVIRONMENT
1009                | EXTENDED_STARTUPINFO_PRESENT;
1010            let created = unsafe {
1011                CreateProcessW(
1012                    application.as_ptr(),
1013                    command_line.as_mut_ptr(),
1014                    null(),
1015                    null(),
1016                    TRUE,
1017                    flags,
1018                    environment.as_mut_ptr().cast(),
1019                    cwd.as_ptr(),
1020                    (&startup as *const STARTUPINFOEXW).cast(),
1021                    &mut process_info,
1022                )
1023            };
1024            if created == 0 {
1025                return Err(io::Error::last_os_error());
1026            }
1027
1028            let process = unsafe { OwnedHandle::from_raw_handle(process_info.hProcess.cast()) };
1029            let thread = unsafe { OwnedHandle::from_raw_handle(process_info.hThread.cast()) };
1030            let assigned =
1031                unsafe { AssignProcessToJobObject(raw_handle(&job), raw_handle(&process)) };
1032            if assigned == 0 {
1033                let error = io::Error::last_os_error();
1034                terminate_suspended_process(&process, None);
1035                return Err(io::Error::new(
1036                    error.kind(),
1037                    format!("could not assign npm process to its Windows Job Object: {error}"),
1038                ));
1039            }
1040
1041            let resumed = unsafe { ResumeThread(raw_handle(&thread)) };
1042            if resumed == u32::MAX {
1043                let error = io::Error::last_os_error();
1044                terminate_suspended_process(&process, Some(&job));
1045                return Err(io::Error::new(
1046                    error.kind(),
1047                    format!("could not resume job-bound npm process: {error}"),
1048                ));
1049            }
1050            drop(thread);
1051            drop(stdin);
1052            drop(child_stdout);
1053            drop(child_stderr);
1054
1055            let stdout = tokio::fs::File::from_std(std::fs::File::from(stdout));
1056            let stderr = tokio::fs::File::from_std(std::fs::File::from(stderr));
1057            Ok(Self {
1058                process,
1059                job: Some(job),
1060                stdout: Some(stdout),
1061                stderr: Some(stderr),
1062            })
1063        }
1064
1065        pub(super) fn take_stdout(&mut self) -> Option<tokio::fs::File> {
1066            self.stdout.take()
1067        }
1068
1069        pub(super) fn take_stderr(&mut self) -> Option<tokio::fs::File> {
1070            self.stderr.take()
1071        }
1072
1073        pub(super) fn try_wait(&mut self) -> io::Result<Option<ExitStatus>> {
1074            match unsafe { WaitForSingleObject(raw_handle(&self.process), 0) } {
1075                WAIT_TIMEOUT => Ok(None),
1076                WAIT_OBJECT_0 => {
1077                    let mut exit_code = 0;
1078                    if unsafe { GetExitCodeProcess(raw_handle(&self.process), &mut exit_code) } == 0
1079                    {
1080                        Err(io::Error::last_os_error())
1081                    } else {
1082                        Ok(Some(ExitStatus::from_raw(exit_code)))
1083                    }
1084                }
1085                WAIT_FAILED => Err(io::Error::last_os_error()),
1086                result => Err(io::Error::new(
1087                    io::ErrorKind::Other,
1088                    format!("unexpected Windows process wait result: {result}"),
1089                )),
1090            }
1091        }
1092
1093        pub(super) async fn wait(&mut self) -> io::Result<ExitStatus> {
1094            loop {
1095                if let Some(status) = self.try_wait()? {
1096                    return Ok(status);
1097                }
1098                tokio::time::sleep(NPM_PROCESS_POLL_INTERVAL).await;
1099            }
1100        }
1101
1102        pub(super) async fn terminate(&mut self) {
1103            self.terminate_job();
1104            let _ = tokio::time::timeout(NPM_TERMINATION_TIMEOUT, self.wait()).await;
1105        }
1106
1107        fn terminate_job(&mut self) {
1108            if let Some(job) = self.job.take() {
1109                unsafe {
1110                    TerminateJobObject(raw_handle(&job), 1);
1111                    // A job is signalled only after its active process count
1112                    // reaches zero. Keep the handle open while waiting so
1113                    // cancellation does not merely enqueue termination and
1114                    // return while descendants are still alive.
1115                    WaitForSingleObject(raw_handle(&job), duration_millis(NPM_TERMINATION_TIMEOUT));
1116                }
1117                // KILL_ON_JOB_CLOSE is the fail-safe if explicit termination
1118                // races with process teardown or returns an error.
1119                drop(job);
1120            }
1121            unsafe {
1122                TerminateProcess(raw_handle(&self.process), 1);
1123            }
1124        }
1125    }
1126
1127    impl Drop for ManagedNpmChild {
1128        fn drop(&mut self) {
1129            self.terminate_job();
1130        }
1131    }
1132
1133    struct ProcThreadAttributeList {
1134        storage: Vec<usize>,
1135        initialized: bool,
1136    }
1137
1138    impl ProcThreadAttributeList {
1139        fn new(attribute_count: u32) -> io::Result<Self> {
1140            let mut required_bytes = 0usize;
1141            unsafe {
1142                InitializeProcThreadAttributeList(
1143                    null_mut(),
1144                    attribute_count,
1145                    0,
1146                    &mut required_bytes,
1147                );
1148            }
1149            if required_bytes == 0 {
1150                return Err(io::Error::last_os_error());
1151            }
1152            let words = required_bytes
1153                .checked_add(size_of::<usize>() - 1)
1154                .and_then(|bytes| bytes.checked_div(size_of::<usize>()))
1155                .ok_or_else(|| io::Error::new(io::ErrorKind::Other, "attribute list too large"))?;
1156            let mut list = Self {
1157                storage: vec![0usize; words],
1158                initialized: false,
1159            };
1160            if unsafe {
1161                InitializeProcThreadAttributeList(
1162                    list.as_mut_ptr(),
1163                    attribute_count,
1164                    0,
1165                    &mut required_bytes,
1166                )
1167            } == 0
1168            {
1169                return Err(io::Error::last_os_error());
1170            }
1171            list.initialized = true;
1172            Ok(list)
1173        }
1174
1175        fn as_mut_ptr(&mut self) -> LPPROC_THREAD_ATTRIBUTE_LIST {
1176            self.storage.as_mut_ptr().cast()
1177        }
1178
1179        fn set_handle_list(&mut self, handles: &[HANDLE]) -> io::Result<()> {
1180            if unsafe {
1181                UpdateProcThreadAttribute(
1182                    self.as_mut_ptr(),
1183                    0,
1184                    PROC_THREAD_ATTRIBUTE_HANDLE_LIST as usize,
1185                    handles.as_ptr().cast(),
1186                    std::mem::size_of_val(handles),
1187                    null_mut(),
1188                    null(),
1189                )
1190            } == 0
1191            {
1192                Err(io::Error::last_os_error())
1193            } else {
1194                Ok(())
1195            }
1196        }
1197    }
1198
1199    impl Drop for ProcThreadAttributeList {
1200        fn drop(&mut self) {
1201            if self.initialized {
1202                unsafe {
1203                    DeleteProcThreadAttributeList(self.as_mut_ptr());
1204                }
1205            }
1206        }
1207    }
1208
1209    fn create_kill_on_close_job() -> io::Result<OwnedHandle> {
1210        let raw_job = unsafe { CreateJobObjectW(null(), null()) };
1211        let job = owned_handle(raw_job)?;
1212        let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { zeroed() };
1213        limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
1214        let size = u32::try_from(size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>())
1215            .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
1216        if unsafe {
1217            SetInformationJobObject(
1218                raw_handle(&job),
1219                JobObjectExtendedLimitInformation,
1220                (&limits as *const JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast(),
1221                size,
1222            )
1223        } == 0
1224        {
1225            return Err(io::Error::last_os_error());
1226        }
1227        Ok(job)
1228    }
1229
1230    fn create_output_pipe() -> io::Result<(OwnedHandle, OwnedHandle)> {
1231        let (read, write) = create_inheritable_pipe()?;
1232        if unsafe {
1233            windows_sys::Win32::Foundation::SetHandleInformation(
1234                raw_handle(&read),
1235                HANDLE_FLAG_INHERIT,
1236                0,
1237            )
1238        } == 0
1239        {
1240            return Err(io::Error::last_os_error());
1241        }
1242        Ok((read, write))
1243    }
1244
1245    fn create_eof_stdin() -> io::Result<OwnedHandle> {
1246        let (read, write) = create_inheritable_pipe()?;
1247        drop(write);
1248        Ok(read)
1249    }
1250
1251    fn create_inheritable_pipe() -> io::Result<(OwnedHandle, OwnedHandle)> {
1252        let attributes = SECURITY_ATTRIBUTES {
1253            nLength: u32::try_from(size_of::<SECURITY_ATTRIBUTES>())
1254                .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?,
1255            lpSecurityDescriptor: null_mut(),
1256            bInheritHandle: TRUE,
1257        };
1258        let mut read = null_mut();
1259        let mut write = null_mut();
1260        if unsafe { CreatePipe(&mut read, &mut write, &attributes, 0) } == 0 {
1261            return Err(io::Error::last_os_error());
1262        }
1263        Ok((owned_handle(read)?, owned_handle(write)?))
1264    }
1265
1266    fn owned_handle(handle: HANDLE) -> io::Result<OwnedHandle> {
1267        if handle.is_null() || handle == INVALID_HANDLE_VALUE {
1268            Err(io::Error::last_os_error())
1269        } else {
1270            Ok(unsafe { OwnedHandle::from_raw_handle(handle.cast()) })
1271        }
1272    }
1273
1274    fn raw_handle(handle: &OwnedHandle) -> HANDLE {
1275        handle.as_raw_handle().cast()
1276    }
1277
1278    fn terminate_suspended_process(process: &OwnedHandle, job: Option<&OwnedHandle>) {
1279        if let Some(job) = job {
1280            unsafe {
1281                TerminateJobObject(raw_handle(job), 1);
1282            }
1283        }
1284        unsafe {
1285            TerminateProcess(raw_handle(process), 1);
1286            WaitForSingleObject(
1287                raw_handle(process),
1288                duration_millis(NPM_TERMINATION_TIMEOUT),
1289            );
1290        }
1291    }
1292
1293    fn duration_millis(duration: Duration) -> u32 {
1294        u32::try_from(duration.as_millis()).unwrap_or(u32::MAX - 1)
1295    }
1296
1297    fn resolve_executable(program: &str) -> io::Result<PathBuf> {
1298        ensure_no_nul(program)?;
1299        let path = Path::new(program);
1300        if program.is_empty() || path.file_name().is_none() {
1301            return Err(io::Error::new(
1302                io::ErrorKind::InvalidInput,
1303                "program path has no file name",
1304            ));
1305        }
1306        let is_file_name = matches!(
1307            path.components().collect::<Vec<_>>().as_slice(),
1308            [Component::Normal(_)]
1309        );
1310        if !is_file_name {
1311            if has_ascii_extension(path, "exe") {
1312                return Ok(path.to_path_buf());
1313            }
1314            let executable = append_suffix(path, ".exe");
1315            return Ok(if program_exists(&executable) {
1316                executable
1317            } else {
1318                path.to_path_buf()
1319            });
1320        }
1321
1322        let has_extension = program.as_bytes().contains(&b'.');
1323        let file_name = if has_extension {
1324            OsString::from(program)
1325        } else {
1326            OsString::from(format!("{program}.exe"))
1327        };
1328        let mut directories = Vec::new();
1329        if let Ok(mut current_exe) = std::env::current_exe() {
1330            current_exe.pop();
1331            directories.push(current_exe);
1332        }
1333        directories.push(system_directory()?);
1334        directories.push(windows_directory()?);
1335        if let Some(path) = std::env::var_os("PATH") {
1336            directories
1337                .extend(std::env::split_paths(&path).filter(|path| !path.as_os_str().is_empty()));
1338        }
1339        directories
1340            .into_iter()
1341            .map(|directory| directory.join(&file_name))
1342            .find(|candidate| program_exists(candidate))
1343            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "program not found"))
1344    }
1345
1346    fn append_suffix(path: &Path, suffix: &str) -> PathBuf {
1347        let mut value = path.as_os_str().to_os_string();
1348        value.push(suffix);
1349        PathBuf::from(value)
1350    }
1351
1352    fn has_ascii_extension(path: &Path, expected: &str) -> bool {
1353        path.extension()
1354            .and_then(OsStr::to_str)
1355            .is_some_and(|extension| extension.eq_ignore_ascii_case(expected))
1356    }
1357
1358    fn program_exists(path: &Path) -> bool {
1359        std::fs::metadata(path)
1360            .map(|metadata| metadata.is_file())
1361            .unwrap_or(false)
1362    }
1363
1364    fn make_command_line(program: &str, args: &[String]) -> io::Result<Vec<u16>> {
1365        ensure_no_nul(program)?;
1366        if program.contains('"') {
1367            return Err(io::Error::new(
1368                io::ErrorKind::InvalidInput,
1369                "program paths may not contain quotes",
1370            ));
1371        }
1372        let mut command_line = vec![b'"' as u16];
1373        command_line.extend(program.encode_utf16());
1374        command_line.push(b'"' as u16);
1375        for arg in args {
1376            command_line.push(b' ' as u16);
1377            append_command_arg(&mut command_line, arg)?;
1378        }
1379        Ok(command_line)
1380    }
1381
1382    fn append_command_arg(command_line: &mut Vec<u16>, arg: &str) -> io::Result<()> {
1383        ensure_no_nul(arg)?;
1384        let quote = arg.is_empty()
1385            || arg
1386                .as_bytes()
1387                .iter()
1388                .any(|byte| matches!(byte, b' ' | b'\t'));
1389        if quote {
1390            command_line.push(b'"' as u16);
1391        }
1392        let mut backslashes = 0usize;
1393        for unit in arg.encode_utf16() {
1394            if unit == b'\\' as u16 {
1395                backslashes += 1;
1396            } else {
1397                if unit == b'"' as u16 {
1398                    command_line.extend((0..=backslashes).map(|_| b'\\' as u16));
1399                }
1400                backslashes = 0;
1401            }
1402            command_line.push(unit);
1403        }
1404        if quote {
1405            command_line.extend((0..backslashes).map(|_| b'\\' as u16));
1406            command_line.push(b'"' as u16);
1407        }
1408        Ok(())
1409    }
1410
1411    fn make_batch_command_line(script: &Path, args: &[String]) -> io::Result<Vec<u16>> {
1412        // This is the hardened encoding used by Rust 1.78's
1413        // `std::process::Command` after CVE-2024-24576. Batch shims inherently
1414        // require cmd.exe, so every argument must use cmd-aware escaping.
1415        let script = encode_path(script)?;
1416        if script.contains(&(b'"' as u16)) || script.last() == Some(&(b'\\' as u16)) {
1417            return Err(io::Error::new(
1418                io::ErrorKind::InvalidInput,
1419                "Windows file names may not contain quotes or end with a backslash",
1420            ));
1421        }
1422        let mut command_line: Vec<u16> = "cmd.exe /e:ON /v:OFF /d /c \"".encode_utf16().collect();
1423        command_line.push(b'"' as u16);
1424        command_line.extend(script);
1425        command_line.push(b'"' as u16);
1426        for arg in args {
1427            if arg.contains(['\r', '\n', '"']) || arg.ends_with('\\') {
1428                return Err(io::Error::new(
1429                    io::ErrorKind::InvalidInput,
1430                    "batch file argument cannot be represented safely and exactly",
1431                ));
1432            }
1433            command_line.push(b' ' as u16);
1434            append_batch_arg(&mut command_line, arg)?;
1435        }
1436        command_line.push(b'"' as u16);
1437        Ok(command_line)
1438    }
1439
1440    fn append_batch_arg(command_line: &mut Vec<u16>, arg: &str) -> io::Result<()> {
1441        ensure_no_nul(arg)?;
1442        const UNQUOTED: &str = r"#$*+-./:?@\_";
1443        let mut quote = arg.is_empty() || arg.as_bytes().last() == Some(&b'\\');
1444        quote |= arg.chars().any(|character| {
1445            (character.is_ascii()
1446                && !(character.is_ascii_alphanumeric() || UNQUOTED.contains(character)))
1447                || character.is_control()
1448        });
1449        if quote {
1450            command_line.push(b'"' as u16);
1451        }
1452        let mut backslashes = 0usize;
1453        for unit in arg.encode_utf16() {
1454            if unit == b'\\' as u16 {
1455                backslashes += 1;
1456            } else {
1457                if unit == b'"' as u16 {
1458                    command_line.extend((0..backslashes).map(|_| b'\\' as u16));
1459                    command_line.push(b'"' as u16);
1460                } else if unit == b'%' as u16 {
1461                    command_line.extend("%%cd:~,".encode_utf16());
1462                }
1463                backslashes = 0;
1464            }
1465            command_line.push(unit);
1466        }
1467        if quote {
1468            command_line.extend((0..backslashes).map(|_| b'\\' as u16));
1469            command_line.push(b'"' as u16);
1470        }
1471        Ok(())
1472    }
1473
1474    fn ensure_no_nul(value: &str) -> io::Result<()> {
1475        if value.contains('\0') {
1476            Err(io::Error::new(
1477                io::ErrorKind::InvalidInput,
1478                "nul byte found in provided data",
1479            ))
1480        } else {
1481            Ok(())
1482        }
1483    }
1484
1485    fn make_environment_block(changes: &[(OsString, Option<OsString>)]) -> io::Result<Vec<u16>> {
1486        let mut entries = std::env::vars_os().collect::<Vec<_>>();
1487        for (name, value) in changes {
1488            validate_environment_change_name(name)?;
1489            entries.retain(|(existing, _)| !environment_names_equal(existing, name));
1490            if let Some(value) = value {
1491                ensure_os_string_has_no_nul(value, "environment variable value")?;
1492                entries.push((name.clone(), value.clone()));
1493            }
1494        }
1495        entries.sort_by_key(|(name, _)| name.to_string_lossy().to_uppercase());
1496
1497        let mut block = Vec::new();
1498        for (name, value) in entries {
1499            append_environment_entry(&mut block, &name, &value)?;
1500        }
1501        // CreateProcessW requires a double-NUL-terminated Unicode block,
1502        // including when the inherited environment happens to be empty.
1503        block.push(0);
1504        if block.len() == 1 {
1505            block.push(0);
1506        }
1507        Ok(block)
1508    }
1509
1510    fn validate_environment_change_name(name: &OsStr) -> io::Result<()> {
1511        ensure_os_string_has_no_nul(name, "environment variable name")?;
1512        let encoded = name.encode_wide().collect::<Vec<_>>();
1513        if encoded.is_empty() || encoded.contains(&(b'=' as u16)) {
1514            return Err(io::Error::new(
1515                io::ErrorKind::InvalidInput,
1516                "environment variable name must be non-empty and contain no equals sign",
1517            ));
1518        }
1519        Ok(())
1520    }
1521
1522    fn append_environment_entry(
1523        block: &mut Vec<u16>,
1524        name: &OsStr,
1525        value: &OsStr,
1526    ) -> io::Result<()> {
1527        ensure_os_string_has_no_nul(name, "environment variable name")?;
1528        ensure_os_string_has_no_nul(value, "environment variable value")?;
1529        let encoded_name = name.encode_wide().collect::<Vec<_>>();
1530        if encoded_name.is_empty()
1531            || encoded_name
1532                .iter()
1533                .skip(usize::from(encoded_name.first() == Some(&(b'=' as u16))))
1534                .any(|unit| *unit == b'=' as u16)
1535        {
1536            return Err(io::Error::new(
1537                io::ErrorKind::InvalidInput,
1538                "inherited environment contained an invalid variable name",
1539            ));
1540        }
1541        block.extend(encoded_name);
1542        block.push(b'=' as u16);
1543        block.extend(value.encode_wide());
1544        block.push(0);
1545        Ok(())
1546    }
1547
1548    fn ensure_os_string_has_no_nul(value: &OsStr, kind: &str) -> io::Result<()> {
1549        if value.encode_wide().any(|unit| unit == 0) {
1550            Err(io::Error::new(
1551                io::ErrorKind::InvalidInput,
1552                format!("nul byte found in {kind}"),
1553            ))
1554        } else {
1555            Ok(())
1556        }
1557    }
1558
1559    fn environment_names_equal(left: &OsStr, right: &OsStr) -> bool {
1560        left.to_string_lossy()
1561            .eq_ignore_ascii_case(&right.to_string_lossy())
1562    }
1563
1564    fn encode_path_nul(path: &Path) -> io::Result<Vec<u16>> {
1565        let mut encoded = encode_path(path)?;
1566        encoded.push(0);
1567        Ok(encoded)
1568    }
1569
1570    fn encode_path(path: &Path) -> io::Result<Vec<u16>> {
1571        let mut encoded = path.as_os_str().encode_wide().collect::<Vec<_>>();
1572        if encoded.contains(&0) {
1573            return Err(io::Error::new(
1574                io::ErrorKind::InvalidInput,
1575                "nul byte found in provided path",
1576            ));
1577        }
1578        const VERBATIM: &[u16] = &[b'\\' as u16, b'\\' as u16, b'?' as u16, b'\\' as u16];
1579        const UNC: &[u16] = &[b'U' as u16, b'N' as u16, b'C' as u16, b'\\' as u16];
1580        if encoded.starts_with(VERBATIM) {
1581            encoded.drain(..VERBATIM.len());
1582            if encoded.starts_with(UNC) {
1583                encoded.drain(..UNC.len());
1584                encoded.splice(0..0, [b'\\' as u16, b'\\' as u16]);
1585            }
1586        }
1587        Ok(encoded)
1588    }
1589
1590    fn system_directory() -> io::Result<PathBuf> {
1591        windows_directory_from(GetSystemDirectoryW)
1592    }
1593
1594    fn windows_directory() -> io::Result<PathBuf> {
1595        windows_directory_from(GetWindowsDirectoryW)
1596    }
1597
1598    fn windows_directory_from(
1599        api: unsafe extern "system" fn(*mut u16, u32) -> u32,
1600    ) -> io::Result<PathBuf> {
1601        let mut buffer = vec![0u16; 260];
1602        loop {
1603            let length = unsafe {
1604                api(
1605                    buffer.as_mut_ptr(),
1606                    u32::try_from(buffer.len()).unwrap_or(u32::MAX),
1607                )
1608            };
1609            if length == 0 {
1610                return Err(io::Error::last_os_error());
1611            }
1612            let length = usize::try_from(length)
1613                .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
1614            if length < buffer.len() {
1615                buffer.truncate(length);
1616                return Ok(PathBuf::from(OsString::from_wide(&buffer)));
1617            }
1618            buffer.resize(length.saturating_add(1), 0);
1619        }
1620    }
1621}
1622
1623fn parse_single_absolute_line(output: &str) -> Option<PathBuf> {
1624    let mut lines = output.lines().filter(|line| !line.trim().is_empty());
1625    let trimmed = lines.next()?.trim();
1626    if lines.next().is_some()
1627        || trimmed.is_empty()
1628        || trimmed.chars().any(|character| character.is_control())
1629    {
1630        return None;
1631    }
1632    let path = PathBuf::from(trimmed);
1633    path.is_absolute().then_some(path)
1634}
1635
1636fn validate_global_root(root: &Path) -> Option<PathBuf> {
1637    if !root.is_absolute() {
1638        return None;
1639    }
1640    let canonical = std::fs::canonicalize(root).ok()?;
1641    if !canonical.is_absolute()
1642        || !canonical.is_dir()
1643        || canonical.file_name().and_then(|name| name.to_str()) != Some("node_modules")
1644    {
1645        return None;
1646    }
1647    Some(canonical)
1648}
1649
1650/// Return a canonical package directory only when it is exactly one npm
1651/// package below a canonical global `node_modules` root. In particular, a
1652/// package symlink resolving outside that root is rejected.
1653fn validate_global_package_path(root: &Path, package_name: &str) -> Option<PathBuf> {
1654    let canonical_root = validate_global_root(root)?;
1655    let relative = package_relative_path(package_name)?;
1656    let lexical = canonical_root.join(&relative);
1657    if !lexical.is_dir() || !is_regular_file(&lexical.join("package.json")) {
1658        return None;
1659    }
1660    let canonical_package = std::fs::canonicalize(&lexical).ok()?;
1661    if !canonical_package.is_dir() || !is_regular_file(&canonical_package.join("package.json")) {
1662        return None;
1663    }
1664    let actual_relative = canonical_package.strip_prefix(&canonical_root).ok()?;
1665    same_components(actual_relative, &relative).then_some(canonical_package)
1666}
1667
1668#[cfg(test)]
1669fn parse_pnpm_global_package_path(output: &str, package_name: &str) -> Option<PathBuf> {
1670    let package_names = [package_name.to_string()];
1671    parse_pnpm_global_package_paths(output, &package_names).remove(package_name)
1672}
1673
1674fn parse_pnpm_global_package_paths(
1675    output: &str,
1676    package_names: &[String],
1677) -> std::collections::HashMap<String, PathBuf> {
1678    let Ok(value) = serde_json::from_str::<serde_json::Value>(output) else {
1679        return std::collections::HashMap::new();
1680    };
1681    let entries = value
1682        .as_array()
1683        .map(|entries| entries.as_slice())
1684        .unwrap_or_else(|| std::slice::from_ref(&value));
1685    let mut packages = std::collections::HashMap::new();
1686    for entry in entries {
1687        let Some(root_hint) = entry
1688            .get("path")
1689            .and_then(serde_json::Value::as_str)
1690            .and_then(|path| validate_absolute_directory(Path::new(path)))
1691        else {
1692            continue;
1693        };
1694        let Some(dependencies) = entry
1695            .get("dependencies")
1696            .and_then(serde_json::Value::as_object)
1697        else {
1698            continue;
1699        };
1700        for package_name in package_names {
1701            if packages.contains_key(package_name) {
1702                continue;
1703            }
1704            let Some(path) = dependencies
1705                .get(package_name)
1706                .and_then(|dependency| dependency.get("path"))
1707                .and_then(serde_json::Value::as_str)
1708                .map(Path::new)
1709                .and_then(|path| validate_pnpm_package_path(path, package_name, &root_hint))
1710            else {
1711                continue;
1712            };
1713            packages.insert(package_name.clone(), path);
1714        }
1715    }
1716    packages
1717}
1718
1719fn validate_pnpm_package_path(
1720    path: &Path,
1721    package_name: &str,
1722    root_hint: &Path,
1723) -> Option<PathBuf> {
1724    if !path.is_absolute() || !path.is_dir() || !is_regular_file(&path.join("package.json")) {
1725        return None;
1726    }
1727    let canonical = std::fs::canonicalize(path).ok()?;
1728    if !canonical.is_dir() || !is_regular_file(&canonical.join("package.json")) {
1729        return None;
1730    }
1731    if canonical == root_hint || !canonical.starts_with(root_hint) {
1732        return None;
1733    }
1734    let (_node_modules, relative) = nearest_node_modules(&canonical)?;
1735    let expected = package_relative_path(package_name)?;
1736    same_components(relative, &expected).then_some(canonical)
1737}
1738
1739fn validate_absolute_directory(path: &Path) -> Option<PathBuf> {
1740    (path.is_absolute() && path.is_dir())
1741        .then(|| std::fs::canonicalize(path).ok())
1742        .flatten()
1743        .filter(|path| path.is_absolute() && path.is_dir())
1744}
1745
1746fn is_regular_file(path: &Path) -> bool {
1747    std::fs::symlink_metadata(path)
1748        .map(|metadata| metadata.is_file() && !metadata.file_type().is_symlink())
1749        .unwrap_or(false)
1750}
1751
1752fn nearest_node_modules(path: &Path) -> Option<(&Path, &Path)> {
1753    let mut current = path;
1754    while let Some(parent) = current.parent() {
1755        if current.file_name().and_then(|name| name.to_str()) == Some("node_modules") {
1756            return Some((current, path.strip_prefix(current).ok()?));
1757        }
1758        current = parent;
1759    }
1760    None
1761}
1762
1763fn package_relative_path(package_name: &str) -> Option<PathBuf> {
1764    if !valid_package_name(package_name) {
1765        return None;
1766    }
1767    let path = PathBuf::from(package_name);
1768    let components = path.components().collect::<Vec<_>>();
1769    match components.as_slice() {
1770        [Component::Normal(scope), Component::Normal(name)]
1771            if package_name.starts_with('@')
1772                && scope.to_string_lossy().starts_with('@')
1773                && !name.is_empty() =>
1774        {
1775            Some(path)
1776        }
1777        [Component::Normal(name)] if !package_name.starts_with('@') && !name.is_empty() => {
1778            Some(path)
1779        }
1780        _ => None,
1781    }
1782}
1783
1784fn valid_package_name(package_name: &str) -> bool {
1785    if package_name.is_empty()
1786        || package_name.chars().any(|character| character.is_control())
1787        || package_name.contains('\\')
1788    {
1789        return false;
1790    }
1791    if let Some(rest) = package_name.strip_prefix('@') {
1792        let Some((scope, name)) = rest.split_once('/') else {
1793            return false;
1794        };
1795        !scope.is_empty()
1796            && !name.is_empty()
1797            && !name.contains('/')
1798            && scope != "."
1799            && scope != ".."
1800            && name != "."
1801            && name != ".."
1802    } else {
1803        !package_name.contains('/') && package_name != "." && package_name != ".."
1804    }
1805}
1806
1807fn same_components(actual: &Path, expected: &Path) -> bool {
1808    let actual = actual.components().collect::<Vec<_>>();
1809    let expected = expected.components().collect::<Vec<_>>();
1810    actual == expected
1811}
1812
1813fn truncate_output(value: &str) -> String {
1814    const MAX: usize = 512;
1815    if value.len() <= MAX {
1816        return value.to_string();
1817    }
1818    let end = value
1819        .char_indices()
1820        .map(|(index, _)| index)
1821        .take_while(|index| *index <= MAX)
1822        .last()
1823        .unwrap_or(0);
1824    format!("{}...", &value[..end])
1825}
1826
1827fn select_argv<'a>(
1828    project_settings: &'a [Settings],
1829    global_settings: &'a Settings,
1830    project_trusted: bool,
1831) -> Option<&'a [String]> {
1832    if project_trusted {
1833        // `load_project_settings` returns `.rpi` before `.pi`. An explicitly
1834        // empty command is still a value: it selects default npm and masks the
1835        // lower-precedence settings, matching Pi's merged settings behavior.
1836        for settings in project_settings {
1837            if let Some(command) = settings.npm_command.as_deref() {
1838                return Some(command);
1839            }
1840        }
1841    }
1842    global_settings.npm_command.as_deref()
1843}
1844
1845fn manager_kind(program: &str, prefix_args: &[String]) -> NpmManagerKind {
1846    let mut command_parts = Vec::with_capacity(prefix_args.len() + 1);
1847    command_parts.push(program);
1848    command_parts.extend(prefix_args.iter().map(String::as_str));
1849    let manager_command = match command_parts.iter().rposition(|part| *part == "--") {
1850        Some(index) => command_parts.get(index + 1).copied().unwrap_or(""),
1851        None => program,
1852    };
1853    let file_name = manager_command
1854        .rsplit(['/', '\\'])
1855        .next()
1856        .unwrap_or(manager_command);
1857    let normalized = file_name.to_ascii_lowercase();
1858    let normalized = normalized
1859        .strip_suffix(".cmd")
1860        .or_else(|| normalized.strip_suffix(".exe"))
1861        .unwrap_or(&normalized);
1862    match normalized {
1863        "npm" => NpmManagerKind::Npm,
1864        "pnpm" => NpmManagerKind::Pnpm,
1865        "bun" => NpmManagerKind::Bun,
1866        _ => NpmManagerKind::Other,
1867    }
1868}
1869
1870fn default_npm_program() -> &'static str {
1871    if cfg!(windows) {
1872        "npm.cmd"
1873    } else {
1874        "npm"
1875    }
1876}
1877
1878fn windows_command_program(program: &str) -> String {
1879    if cfg!(windows)
1880        && !program.contains(['/', '\\'])
1881        && matches!(
1882            program.to_ascii_lowercase().as_str(),
1883            "npm" | "pnpm" | "yarn" | "npx" | "corepack"
1884        )
1885    {
1886        format!("{program}.cmd")
1887    } else {
1888        program.to_string()
1889    }
1890}
1891
1892#[cfg(test)]
1893mod tests {
1894    use std::ffi::OsString;
1895    use std::path::{Path, PathBuf};
1896    use std::time::{Duration, Instant};
1897
1898    use super::{
1899        parse_pnpm_global_package_path, parse_pnpm_global_package_paths,
1900        parse_single_absolute_line, run_bounded_command, run_bounded_process, select_argv,
1901        validate_global_package_path, NpmCommand, NpmManagerKind, NpmProcessCwd, NpmProcessError,
1902        NpmProcessLimits,
1903    };
1904    use crate::settings::Settings;
1905
1906    fn strings(values: &[&str]) -> Vec<String> {
1907        values.iter().map(|value| (*value).to_string()).collect()
1908    }
1909
1910    #[cfg(windows)]
1911    fn powershell_program() -> String {
1912        PathBuf::from(std::env::var_os("SystemRoot").unwrap())
1913            .join("System32/WindowsPowerShell/v1.0/powershell.exe")
1914            .to_string_lossy()
1915            .into_owned()
1916    }
1917
1918    #[cfg(windows)]
1919    fn script_command(script: String) -> (String, Vec<String>) {
1920        (
1921            powershell_program(),
1922            vec![
1923                "-NoLogo".to_string(),
1924                "-NoProfile".to_string(),
1925                "-NonInteractive".to_string(),
1926                "-Command".to_string(),
1927                script,
1928            ],
1929        )
1930    }
1931
1932    #[cfg(unix)]
1933    fn script_command(script: String) -> (String, Vec<String>) {
1934        ("/bin/sh".to_string(), vec!["-c".to_string(), script])
1935    }
1936
1937    fn test_limits(timeout: Duration, max_output_bytes: usize) -> NpmProcessLimits {
1938        NpmProcessLimits {
1939            timeout,
1940            max_output_bytes,
1941        }
1942    }
1943
1944    #[cfg(windows)]
1945    fn descendant_script(launched: &Path, survivor: &Path) -> String {
1946        use base64::Engine;
1947
1948        // The wrapper deliberately exits immediately. `-NoNewWindow` makes
1949        // the descendant inherit its stdout/stderr pipe handles, reproducing
1950        // the case where a PID-based tree walk can no longer find it.
1951        let survivor = survivor.to_string_lossy().replace('\'', "''");
1952        let inner =
1953            format!("Start-Sleep -Seconds 4; [IO.File]::WriteAllText('{survivor}', 'alive')");
1954        let encoded_bytes = inner
1955            .encode_utf16()
1956            .flat_map(u16::to_le_bytes)
1957            .collect::<Vec<_>>();
1958        let encoded = base64::engine::general_purpose::STANDARD.encode(encoded_bytes);
1959        let powershell = powershell_program().replace('\'', "''");
1960        let launched = launched.to_string_lossy().replace('\'', "''");
1961        format!(
1962            "$null = Start-Process -NoNewWindow -FilePath '{powershell}' \
1963             -ArgumentList @('-NoLogo','-NoProfile','-NonInteractive','-EncodedCommand','{encoded}'); \
1964             [IO.File]::WriteAllText('{launched}', 'launched')"
1965        )
1966    }
1967
1968    #[cfg(unix)]
1969    fn descendant_script(launched: &Path, survivor: &Path) -> String {
1970        let launched = launched.to_string_lossy().replace('\'', "'\"'\"'");
1971        let survivor = survivor.to_string_lossy().replace('\'', "'\"'\"'");
1972        format!("(sleep 3; printf alive > '{survivor}') & printf launched > '{launched}'; sleep 30")
1973    }
1974
1975    #[test]
1976    fn configured_argv_is_never_shell_parsed() {
1977        let argv = strings(&["mise", "exec", "node@20", "--", "npm"]);
1978        let command = NpmCommand::from_argv(Some(&argv)).unwrap();
1979        assert_eq!(command.program(), "mise");
1980        assert_eq!(
1981            command.combined_args(&strings(&["view", "demo", "version", "--json"])),
1982            strings(&["exec", "node@20", "--", "npm", "view", "demo", "version", "--json"])
1983        );
1984        assert_eq!(command.manager_kind(), NpmManagerKind::Npm);
1985        assert!(command.is_configured());
1986    }
1987
1988    #[tokio::test]
1989    async fn bounded_runner_isolates_untrusted_cwd_and_honors_trusted_cwd() {
1990        #[cfg(windows)]
1991        let script = "[Console]::Out.Write((Get-Location).Path)".to_string();
1992        #[cfg(unix)]
1993        let script = "printf %s \"$PWD\"".to_string();
1994        let (program, args) = script_command(script.clone());
1995        let output = run_bounded_process(
1996            &program,
1997            &args,
1998            NpmProcessCwd::Isolated,
1999            test_limits(Duration::from_secs(5), 4096),
2000        )
2001        .await
2002        .unwrap();
2003        assert!(output.status.success());
2004        let isolated_cwd = PathBuf::from(String::from_utf8(output.stdout).unwrap());
2005        assert!(isolated_cwd.is_absolute());
2006        assert_ne!(isolated_cwd, std::env::current_dir().unwrap());
2007        assert!(
2008            !isolated_cwd.exists(),
2009            "isolated cwd should be removed after the npm lookup"
2010        );
2011
2012        let trusted = tempfile::tempdir().unwrap();
2013        let (program, args) = script_command(script);
2014        let output = run_bounded_process(
2015            &program,
2016            &args,
2017            NpmProcessCwd::Trusted(trusted.path().to_path_buf()),
2018            test_limits(Duration::from_secs(5), 4096),
2019        )
2020        .await
2021        .unwrap();
2022        assert!(output.status.success());
2023        let reported = PathBuf::from(String::from_utf8(output.stdout).unwrap());
2024        assert_eq!(
2025            std::fs::canonicalize(reported).unwrap(),
2026            std::fs::canonicalize(trusted.path()).unwrap()
2027        );
2028    }
2029
2030    #[tokio::test]
2031    async fn bounded_command_applies_environment_overrides() {
2032        let _guard = crate::config::test_support::env_lock().lock().unwrap();
2033        let set_name = format!("RPI_BOUNDED_SET_{}", std::process::id());
2034        let remove_name = format!("RPI_BOUNDED_REMOVE_{}", std::process::id());
2035        let previous_set = std::env::var_os(&set_name);
2036        let previous_remove = std::env::var_os(&remove_name);
2037        std::env::set_var(&set_name, "parent");
2038        std::env::set_var(&remove_name, "parent");
2039
2040        #[cfg(windows)]
2041        let script = format!(
2042            "$removed = [Environment]::GetEnvironmentVariable('{remove_name}', 'Process'); \
2043             if ($null -eq $removed) {{ $removed = '<missing>' }}; \
2044             [Console]::Out.Write([Environment]::GetEnvironmentVariable('{set_name}', 'Process') + '|' + $removed)"
2045        );
2046        #[cfg(unix)]
2047        let script = format!(
2048            "printf '%s|%s' \"${{{set_name}-<missing>}}\" \"${{{remove_name}-<missing>}}\""
2049        );
2050        let (program, args) = script_command(script);
2051        let cwd = tempfile::tempdir().unwrap();
2052        let environment = vec![
2053            (OsString::from(&set_name), Some(OsString::from("child"))),
2054            (OsString::from(&remove_name), None),
2055        ];
2056
2057        let result = run_bounded_command(
2058            &program,
2059            &args,
2060            cwd.path(),
2061            &environment,
2062            Duration::from_secs(5),
2063            4096,
2064        )
2065        .await;
2066
2067        match previous_set {
2068            Some(value) => std::env::set_var(&set_name, value),
2069            None => std::env::remove_var(&set_name),
2070        }
2071        match previous_remove {
2072            Some(value) => std::env::set_var(&remove_name, value),
2073            None => std::env::remove_var(&remove_name),
2074        }
2075        let output = result.unwrap();
2076        assert!(output.status.success());
2077        assert_eq!(String::from_utf8(output.stdout).unwrap(), "child|<missing>");
2078    }
2079
2080    #[tokio::test(flavor = "current_thread")]
2081    async fn startup_remediation_blocking_runner_is_safe_inside_tokio() {
2082        #[cfg(windows)]
2083        let script = "[Console]::Out.Write((Get-Location).Path)".to_string();
2084        #[cfg(unix)]
2085        let script = "printf %s \"$PWD\"".to_string();
2086        let (program, prefix_args) = script_command(script);
2087        let mut argv = vec![program];
2088        argv.extend(prefix_args);
2089        let command = NpmCommand::from_argv(Some(&argv)).unwrap();
2090        let trusted = tempfile::tempdir().unwrap();
2091
2092        let output = command
2093            .run_startup_remediation_with_limits(
2094                &[],
2095                trusted.path(),
2096                test_limits(Duration::from_secs(5), 4096),
2097            )
2098            .unwrap();
2099
2100        assert!(output.status.success());
2101        let reported = PathBuf::from(String::from_utf8(output.stdout).unwrap());
2102        assert_eq!(
2103            std::fs::canonicalize(reported).unwrap(),
2104            std::fs::canonicalize(trusted.path()).unwrap()
2105        );
2106    }
2107
2108    #[test]
2109    fn startup_remediation_blocking_runner_enforces_timeout() {
2110        #[cfg(windows)]
2111        let script = "Start-Sleep -Seconds 30".to_string();
2112        #[cfg(unix)]
2113        let script = "sleep 30".to_string();
2114        let (program, prefix_args) = script_command(script);
2115        let mut argv = vec![program];
2116        argv.extend(prefix_args);
2117        let command = NpmCommand::from_argv(Some(&argv)).unwrap();
2118        let trusted = tempfile::tempdir().unwrap();
2119        let started = Instant::now();
2120
2121        let error = command
2122            .run_startup_remediation_with_limits(
2123                &[],
2124                trusted.path(),
2125                test_limits(Duration::from_secs(1), 4096),
2126            )
2127            .unwrap_err();
2128
2129        assert_eq!(error, NpmProcessError::TimedOut);
2130        assert!(
2131            started.elapsed() < Duration::from_secs(5),
2132            "blocking startup timeout did not terminate promptly: {:?}",
2133            started.elapsed()
2134        );
2135    }
2136
2137    #[test]
2138    fn output_limit_error_reports_the_effective_budget() {
2139        assert_eq!(
2140            NpmProcessError::OutputLimitExceeded { limit: 1024 * 1024 }.to_string(),
2141            "npm process exceeded the 1048576 byte output limit"
2142        );
2143    }
2144
2145    #[tokio::test]
2146    async fn bounded_runner_enforces_combined_stdout_stderr_limit() {
2147        #[cfg(windows)]
2148        let script = concat!(
2149            "[Console]::Out.Write(('o' * 700)); ",
2150            "[Console]::Error.Write(('e' * 700)); ",
2151            "Start-Sleep -Seconds 30"
2152        )
2153        .to_string();
2154        #[cfg(unix)]
2155        let script = concat!(
2156            "i=0; while [ $i -lt 700 ]; do printf o; i=$((i+1)); done; ",
2157            "i=0; while [ $i -lt 700 ]; do printf e >&2; i=$((i+1)); done; ",
2158            "sleep 30"
2159        )
2160        .to_string();
2161        let (program, args) = script_command(script);
2162        let started = Instant::now();
2163
2164        let error = run_bounded_process(
2165            &program,
2166            &args,
2167            NpmProcessCwd::Isolated,
2168            test_limits(Duration::from_secs(10), 1024),
2169        )
2170        .await
2171        .unwrap_err();
2172
2173        assert_eq!(error, NpmProcessError::OutputLimitExceeded { limit: 1024 });
2174        assert!(
2175            started.elapsed() < Duration::from_secs(5),
2176            "output overflow did not terminate promptly: {:?}",
2177            started.elapsed()
2178        );
2179    }
2180
2181    #[tokio::test]
2182    async fn bounded_runner_timeout_terminates_descendants() {
2183        let temp = tempfile::tempdir().unwrap();
2184        let launched = temp.path().join("child-launched");
2185        let survivor = temp.path().join("child-survived");
2186        let script = descendant_script(&launched, &survivor);
2187        let (program, args) = script_command(script);
2188        let started = Instant::now();
2189        #[cfg(windows)]
2190        let timeout = Duration::from_secs(3);
2191        #[cfg(unix)]
2192        let timeout = Duration::from_millis(1500);
2193
2194        let error = run_bounded_process(
2195            &program,
2196            &args,
2197            NpmProcessCwd::Isolated,
2198            test_limits(timeout, 4096),
2199        )
2200        .await
2201        .unwrap_err();
2202
2203        assert_eq!(error, NpmProcessError::TimedOut);
2204        assert!(launched.exists(), "test descendant was not launched");
2205        assert!(
2206            started.elapsed() < Duration::from_secs(5),
2207            "timeout cleanup did not finish promptly: {:?}",
2208            started.elapsed()
2209        );
2210        #[cfg(windows)]
2211        let survivor_check_delay = Duration::from_secs(5);
2212        #[cfg(unix)]
2213        let survivor_check_delay = Duration::from_secs(2);
2214        tokio::time::sleep(survivor_check_delay).await;
2215        assert!(
2216            !survivor.exists(),
2217            "npm descendant remained alive after timeout"
2218        );
2219    }
2220
2221    #[tokio::test]
2222    async fn cancelling_runner_still_terminates_and_reaps_descendants() {
2223        let temp = tempfile::tempdir().unwrap();
2224        let launched = temp.path().join("cancel-child-launched");
2225        let survivor = temp.path().join("cancel-child-survived");
2226        let (program, args) = script_command(descendant_script(&launched, &survivor));
2227        let handle = tokio::spawn(async move {
2228            run_bounded_process(
2229                &program,
2230                &args,
2231                NpmProcessCwd::Isolated,
2232                test_limits(Duration::from_secs(30), 4096),
2233            )
2234            .await
2235        });
2236
2237        let launch_deadline = Instant::now() + Duration::from_secs(3);
2238        while !launched.exists() && Instant::now() < launch_deadline {
2239            tokio::time::sleep(Duration::from_millis(25)).await;
2240        }
2241        assert!(launched.exists(), "test descendant was not launched");
2242        let cancelled_at = Instant::now();
2243        handle.abort();
2244        assert!(handle.await.unwrap_err().is_cancelled());
2245        assert!(
2246            cancelled_at.elapsed() < Duration::from_secs(5),
2247            "cancellation cleanup did not finish promptly: {:?}",
2248            cancelled_at.elapsed()
2249        );
2250
2251        #[cfg(windows)]
2252        let survivor_check_delay = Duration::from_secs(5);
2253        #[cfg(unix)]
2254        let survivor_check_delay = Duration::from_secs(3);
2255        tokio::time::sleep(survivor_check_delay).await;
2256        assert!(
2257            !survivor.exists(),
2258            "npm descendant remained alive after caller cancellation"
2259        );
2260    }
2261
2262    #[test]
2263    fn view_args_accept_native_and_prefixed_specs() {
2264        let command = NpmCommand::from_argv(Some(&strings(&["pnpm"]))).unwrap();
2265        assert_eq!(
2266            command.view_args("npm:@scope/demo@^1").unwrap(),
2267            strings(&["view", "@scope/demo@^1", "version", "--json"])
2268        );
2269        assert!(command.view_args("npm:   ").is_err());
2270        assert!(command.view_args("npm:--help").is_err());
2271    }
2272
2273    #[test]
2274    fn root_install_args_match_native_pi_for_each_manager() {
2275        let specs = strings(&["one@latest", "@scope/two@^2"]);
2276        let root = Path::new("install-root");
2277
2278        let npm = NpmCommand::from_argv(Some(&strings(&["npm"]))).unwrap();
2279        assert_eq!(
2280            npm.install_args(&specs, root),
2281            strings(&[
2282                "install",
2283                "one@latest",
2284                "@scope/two@^2",
2285                "--prefix",
2286                "install-root",
2287                "--legacy-peer-deps",
2288            ])
2289        );
2290
2291        let pnpm = NpmCommand::from_argv(Some(&strings(&["pnpm"]))).unwrap();
2292        assert_eq!(
2293            pnpm.install_args(&specs, root),
2294            strings(&[
2295                "install",
2296                "one@latest",
2297                "@scope/two@^2",
2298                "--prefix",
2299                "install-root",
2300                "--config.auto-install-peers=false",
2301                "--config.strict-peer-dependencies=false",
2302                "--config.strict-dep-builds=false",
2303            ])
2304        );
2305
2306        let bun = NpmCommand::from_argv(Some(&strings(&["bun"]))).unwrap();
2307        assert_eq!(
2308            bun.install_args(&specs, root),
2309            strings(&[
2310                "install",
2311                "one@latest",
2312                "@scope/two@^2",
2313                "--cwd",
2314                "install-root",
2315                "--omit=peer",
2316            ])
2317        );
2318    }
2319
2320    #[test]
2321    fn root_uninstall_args_match_native_pi_for_each_manager() {
2322        let root = Path::new("install-root");
2323
2324        let npm = NpmCommand::from_argv(Some(&strings(&["npm"]))).unwrap();
2325        assert_eq!(
2326            npm.uninstall_args("@scope/demo", root),
2327            strings(&[
2328                "uninstall",
2329                "@scope/demo",
2330                "--prefix",
2331                "install-root",
2332                "--legacy-peer-deps",
2333            ])
2334        );
2335
2336        let pnpm = NpmCommand::from_argv(Some(&strings(&["pnpm"]))).unwrap();
2337        assert_eq!(
2338            pnpm.uninstall_args("demo", root),
2339            strings(&["uninstall", "demo", "--prefix", "install-root"])
2340        );
2341
2342        let bun = NpmCommand::from_argv(Some(&strings(&["bun"]))).unwrap();
2343        assert_eq!(
2344            bun.uninstall_args("demo", root),
2345            strings(&["uninstall", "demo", "--cwd", "install-root"])
2346        );
2347    }
2348
2349    #[test]
2350    fn blank_program_fails_closed_while_empty_array_selects_default() {
2351        assert!(NpmCommand::from_argv(Some(&strings(&["   ", "install"]))).is_err());
2352        let empty = Vec::new();
2353        let default = NpmCommand::from_argv(Some(&empty)).unwrap();
2354        assert_eq!(default.manager_kind(), NpmManagerKind::Npm);
2355        assert!(!default.is_configured());
2356    }
2357
2358    #[cfg(windows)]
2359    #[test]
2360    fn windows_normalizes_bare_script_shims_but_not_wrappers() {
2361        let npm = NpmCommand::from_argv(Some(&strings(&["npm"]))).unwrap();
2362        let pnpm = NpmCommand::from_argv(Some(&strings(&["pnpm"]))).unwrap();
2363        let wrapper = NpmCommand::from_argv(Some(&strings(&["mise", "--", "npm"]))).unwrap();
2364        assert_eq!(npm.program(), "npm.cmd");
2365        assert_eq!(pnpm.program(), "pnpm.cmd");
2366        assert_eq!(wrapper.program(), "mise");
2367    }
2368
2369    #[cfg(windows)]
2370    #[tokio::test]
2371    async fn windows_batch_shim_round_trips_argv_without_cmd_injection() {
2372        let temp = tempfile::tempdir().unwrap();
2373        let recorder = temp.path().join("record-argv.vbs");
2374        let shim = temp.path().join("fake-npm.cmd");
2375        let launched = temp.path().join("shim-launched");
2376        let injected = temp.path().join("injected");
2377        std::fs::write(
2378            &recorder,
2379            concat!(
2380                "For i = 0 To WScript.Arguments.Count - 1\r\n",
2381                "  WScript.StdOut.Write WScript.Arguments(i)\r\n",
2382                "  WScript.StdOut.Write ChrW(0)\r\n",
2383                "Next\r\n",
2384            ),
2385        )
2386        .unwrap();
2387        std::fs::write(
2388            &shim,
2389            format!(
2390                "@echo off\r\nsetlocal DisableDelayedExpansion\r\n\
2391                 > \"{}\" echo launched\r\n\
2392                 cscript.exe //nologo //U \"{}\" %*\r\n",
2393                launched.display(),
2394                recorder.display(),
2395            ),
2396        )
2397        .unwrap();
2398
2399        let injection_arg = format!("& echo injected > {}", injected.display());
2400        let expected = vec![
2401            "".to_string(),
2402            "contains spaces".to_string(),
2403            "&|<>^()%PATH%!".to_string(),
2404            "'single quotes'".to_string(),
2405            "back\\slash".to_string(),
2406            "snow-\u{96ea}-fox-\u{72d0}".to_string(),
2407            injection_arg,
2408        ];
2409        let output = run_bounded_process(
2410            &shim.to_string_lossy(),
2411            &expected,
2412            NpmProcessCwd::Trusted(temp.path().to_path_buf()),
2413            test_limits(Duration::from_secs(5), 16 * 1024),
2414        )
2415        .await
2416        .unwrap();
2417
2418        assert!(output.status.success(), "shim stderr: {:?}", output.stderr);
2419        assert_eq!(output.stdout.len() % 2, 0, "cscript emitted partial UTF-16");
2420        let mut units = output
2421            .stdout
2422            .chunks_exact(2)
2423            .map(|bytes| u16::from_le_bytes([bytes[0], bytes[1]]))
2424            .collect::<Vec<_>>();
2425        if units.first() == Some(&0xfeff) {
2426            units.remove(0);
2427        }
2428        let mut actual = Vec::new();
2429        let mut start = 0;
2430        for (index, unit) in units.iter().enumerate() {
2431            if *unit == 0 {
2432                actual.push(String::from_utf16(&units[start..index]).unwrap());
2433                start = index + 1;
2434            }
2435        }
2436        assert_eq!(
2437            start,
2438            units.len(),
2439            "cscript argv output lacked a terminator"
2440        );
2441        assert_eq!(actual, expected);
2442        assert!(launched.exists(), "batch shim did not run");
2443        assert!(!injected.exists(), "batch argument executed as a command");
2444
2445        std::fs::remove_file(&launched).unwrap();
2446        for unsafe_arg in ["\"double quotes\"", "trailing\\"] {
2447            let error = run_bounded_process(
2448                &shim.to_string_lossy(),
2449                &[unsafe_arg.to_string()],
2450                NpmProcessCwd::Trusted(temp.path().to_path_buf()),
2451                test_limits(Duration::from_secs(5), 16 * 1024),
2452            )
2453            .await
2454            .unwrap_err();
2455            match error {
2456                NpmProcessError::Spawn(message) => {
2457                    assert!(message.contains("cannot be represented safely and exactly"));
2458                }
2459                error => panic!("unexpected error for unrepresentable batch argv: {error}"),
2460            }
2461            assert!(
2462                !launched.exists(),
2463                "unrepresentable batch argv unexpectedly launched the shim"
2464            );
2465        }
2466    }
2467
2468    #[test]
2469    fn trusted_project_command_precedes_pi_and_global_settings() {
2470        let rpi = Settings {
2471            npm_command: Some(strings(&["bun"])),
2472            ..Settings::default()
2473        };
2474        let pi = Settings {
2475            npm_command: Some(strings(&["pnpm"])),
2476            ..Settings::default()
2477        };
2478        let global = Settings {
2479            npm_command: Some(strings(&["npm", "--global-prefix"])),
2480            ..Settings::default()
2481        };
2482        assert_eq!(
2483            select_argv(&[rpi.clone(), pi.clone()], &global, true),
2484            rpi.npm_command.as_deref()
2485        );
2486        assert_eq!(
2487            select_argv(&[Settings::default(), pi.clone()], &global, true),
2488            pi.npm_command.as_deref()
2489        );
2490        assert_eq!(
2491            select_argv(&[rpi, pi], &global, false),
2492            global.npm_command.as_deref()
2493        );
2494    }
2495
2496    #[test]
2497    fn global_root_output_is_single_absolute_line() {
2498        let absolute = std::env::temp_dir().join("node_modules");
2499        let absolute_text = absolute.to_string_lossy().into_owned();
2500        assert_eq!(
2501            parse_single_absolute_line(&format!("{absolute_text}\n")).as_deref(),
2502            Some(absolute.as_path())
2503        );
2504        assert!(parse_single_absolute_line("node_modules").is_none());
2505        assert!(parse_single_absolute_line(&format!(
2506            "{absolute_text}\n{}",
2507            std::env::temp_dir().join("other").display()
2508        ))
2509        .is_none());
2510        assert!(parse_single_absolute_line(&format!("{absolute_text}\tother")).is_none());
2511    }
2512
2513    #[test]
2514    fn global_package_path_requires_direct_manifest_child() {
2515        let temp = tempfile::tempdir().unwrap();
2516        let root = temp.path().join("global/node_modules");
2517        let package = root.join("demo");
2518        let scoped = root.join("@scope/pkg");
2519        std::fs::create_dir_all(&package).unwrap();
2520        std::fs::create_dir_all(&scoped).unwrap();
2521        std::fs::write(package.join("package.json"), "{\"name\":\"demo\"}").unwrap();
2522        std::fs::write(scoped.join("package.json"), "{\"name\":\"@scope/pkg\"}").unwrap();
2523
2524        assert_eq!(
2525            validate_global_package_path(&root, "demo"),
2526            std::fs::canonicalize(&package).ok()
2527        );
2528        assert_eq!(
2529            validate_global_package_path(&root, "@scope/pkg"),
2530            std::fs::canonicalize(&scoped).ok()
2531        );
2532        assert!(validate_global_package_path(&root, "../outside").is_none());
2533        assert!(validate_global_package_path(&root, "demo/nested").is_none());
2534        assert!(validate_global_package_path(&root, "missing").is_none());
2535    }
2536
2537    #[test]
2538    fn pnpm_global_json_requires_root_containment_and_node_modules_shape() {
2539        let temp = tempfile::tempdir().unwrap();
2540        let global = temp.path().join("pnpm/global/v11");
2541        let package = global.join("20-hash/node_modules/demo");
2542        let outside = temp.path().join("outside/node_modules/demo");
2543        std::fs::create_dir_all(&package).unwrap();
2544        std::fs::create_dir_all(&outside).unwrap();
2545        std::fs::write(package.join("package.json"), "{\"name\":\"demo\"}").unwrap();
2546        std::fs::write(outside.join("package.json"), "{\"name\":\"demo\"}").unwrap();
2547
2548        let output = serde_json::json!([{
2549            "path": global,
2550            "dependencies": {
2551                "demo": {"path": package}
2552            }
2553        }])
2554        .to_string();
2555        assert_eq!(
2556            parse_pnpm_global_package_path(&output, "demo"),
2557            std::fs::canonicalize(&package).ok()
2558        );
2559
2560        let escaped = serde_json::json!([{
2561            "path": global,
2562            "dependencies": {
2563                "demo": {"path": outside}
2564            }
2565        }])
2566        .to_string();
2567        assert!(parse_pnpm_global_package_path(&escaped, "demo").is_none());
2568        let missing_root = serde_json::json!([{
2569            "dependencies": {
2570                "demo": {"path": package}
2571            }
2572        }])
2573        .to_string();
2574        assert!(parse_pnpm_global_package_path(&missing_root, "demo").is_none());
2575        assert!(parse_pnpm_global_package_path("not json", "demo").is_none());
2576    }
2577
2578    #[test]
2579    fn pnpm_global_batch_parses_one_document_and_preserves_path_validation() {
2580        let temp = tempfile::tempdir().unwrap();
2581        let global = temp.path().join("pnpm/global/v11");
2582        let demo = global.join("20-hash/node_modules/demo");
2583        let scoped = global.join("21-hash/node_modules/@scope/pkg");
2584        let outside = temp.path().join("outside/node_modules/escaped");
2585        for package in [&demo, &scoped, &outside] {
2586            std::fs::create_dir_all(package).unwrap();
2587            std::fs::write(package.join("package.json"), "{}").unwrap();
2588        }
2589
2590        let output = serde_json::json!([{
2591            "path": global,
2592            "dependencies": {
2593                "demo": {"path": demo},
2594                "@scope/pkg": {"path": scoped},
2595                "escaped": {"path": outside}
2596            }
2597        }])
2598        .to_string();
2599        let package_names = strings(&["demo", "@scope/pkg", "escaped", "missing"]);
2600        let packages = parse_pnpm_global_package_paths(&output, &package_names);
2601
2602        assert_eq!(packages.len(), 2);
2603        assert_eq!(
2604            packages.get("demo"),
2605            std::fs::canonicalize(&demo).ok().as_ref()
2606        );
2607        assert_eq!(
2608            packages.get("@scope/pkg"),
2609            std::fs::canonicalize(&scoped).ok().as_ref()
2610        );
2611        assert!(!packages.contains_key("escaped"));
2612        assert!(!packages.contains_key("missing"));
2613    }
2614
2615    #[test]
2616    fn pnpm_global_batch_executes_one_lookup_and_rejects_invalid_names_before_spawn() {
2617        let temp = tempfile::tempdir().unwrap();
2618        let global = temp.path().join("pnpm/global/v11");
2619        let demo = global.join("20-hash/node_modules/demo");
2620        let scoped = global.join("21-hash/node_modules/@scope/pkg");
2621        for package in [&demo, &scoped] {
2622            std::fs::create_dir_all(package).unwrap();
2623            std::fs::write(package.join("package.json"), "{}").unwrap();
2624        }
2625        let output = serde_json::json!([{
2626            "path": global,
2627            "dependencies": {
2628                "demo": {"path": demo},
2629                "@scope/pkg": {"path": scoped}
2630            }
2631        }])
2632        .to_string();
2633        let counter = temp.path().join("lookup-count");
2634
2635        #[cfg(windows)]
2636        let (program, prefix_args) = {
2637            let script = temp.path().join("fake-pnpm.ps1");
2638            std::fs::write(
2639                &script,
2640                format!(
2641                    "[IO.File]::AppendAllText('{}', 'x'); [Console]::Out.Write('{}')",
2642                    counter.to_string_lossy().replace('\'', "''"),
2643                    output.replace('\'', "''")
2644                ),
2645            )
2646            .unwrap();
2647            (
2648                powershell_program(),
2649                vec![
2650                    "-NoLogo".to_string(),
2651                    "-NoProfile".to_string(),
2652                    "-NonInteractive".to_string(),
2653                    "-File".to_string(),
2654                    script.to_string_lossy().into_owned(),
2655                ],
2656            )
2657        };
2658        #[cfg(unix)]
2659        let (program, prefix_args) = script_command(format!(
2660            "printf x >> '{}'; printf %s '{}'; exit 0",
2661            counter.to_string_lossy().replace('\'', "'\"'\"'"),
2662            output.replace('\'', "'\"'\"'")
2663        ));
2664        let command = NpmCommand {
2665            program,
2666            prefix_args,
2667            manager_kind: NpmManagerKind::Pnpm,
2668            configured: true,
2669        };
2670
2671        let packages = command
2672            .global_package_paths(&strings(&["demo", "@scope/pkg", "missing"]))
2673            .unwrap();
2674        assert_eq!(packages.len(), 2);
2675        assert_eq!(std::fs::read_to_string(&counter).unwrap(), "x");
2676
2677        std::fs::remove_file(&counter).unwrap();
2678        let error = command
2679            .global_package_paths(&strings(&["demo", "../escaped"]))
2680            .unwrap_err();
2681        assert!(error.contains("invalid npm package name"));
2682        assert!(!counter.exists(), "invalid batch unexpectedly ran pnpm");
2683    }
2684
2685    #[cfg(unix)]
2686    #[test]
2687    fn global_package_path_rejects_package_symlink_escape() {
2688        let temp = tempfile::tempdir().unwrap();
2689        let root = temp.path().join("global/node_modules");
2690        let outside = temp.path().join("outside/demo");
2691        std::fs::create_dir_all(&root).unwrap();
2692        std::fs::create_dir_all(&outside).unwrap();
2693        std::fs::write(outside.join("package.json"), "{\"name\":\"demo\"}").unwrap();
2694        std::os::unix::fs::symlink(&outside, root.join("demo")).unwrap();
2695        assert!(validate_global_package_path(&root, "demo").is_none());
2696    }
2697}