1use 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 Isolated,
33 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub enum NpmManagerKind {
109 Npm,
110 Pnpm,
111 Bun,
112 Other,
113}
114
115#[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 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 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 pub fn program(&self) -> &str {
174 &self.program
175 }
176
177 pub fn prefix_args(&self) -> &[String] {
179 &self.prefix_args
180 }
181
182 pub fn is_configured(&self) -> bool {
184 self.configured
185 }
186
187 pub fn manager_kind(&self) -> NpmManagerKind {
189 self.manager_kind
190 }
191
192 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 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 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 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 pub fn global_package_roots(&self) -> Result<Vec<PathBuf>, String> {
289 match self.manager_kind {
290 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 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 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 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 pub fn validate_global_package_path(root: &Path, package_name: &str) -> Option<PathBuf> {
394 validate_global_package_path(root, package_name)
395 }
396
397 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 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 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
519pub(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
583pub(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 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 WaitForSingleObject(raw_handle(&job), duration_millis(NPM_TERMINATION_TIMEOUT));
1116 }
1117 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 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 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
1650fn 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 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 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}