1use std::collections::BTreeSet;
8use std::fmt;
9use std::path::{Path, PathBuf};
10use std::str::FromStr;
11use std::time::{Duration, Instant};
12
13use futures_util::StreamExt;
14
15use crate::backend::Ctx;
16use crate::config::normalize_registry_url;
17use crate::error::{Error, Result};
18
19const NPMMIRROR: &str = "https://registry.npmmirror.com/";
20const NPMJS: &str = "https://registry.npmjs.org/";
21const REGISTRY_PROBE_ACCEPT: &str = "application/json";
22const MAX_PROBE_BODY: usize = 64 * 1024;
23const MAX_PROBE_REDIRECTS: usize = 3;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
26pub enum PackageManager {
27 Npm,
28 Pnpm,
29 YarnClassic,
30 YarnBerry,
31 Bun,
32 Deno,
33}
34
35impl fmt::Display for PackageManager {
36 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
37 formatter.write_str(match self {
38 Self::Npm => "npm",
39 Self::Pnpm => "pnpm",
40 Self::YarnClassic => "yarn-classic",
41 Self::YarnBerry => "yarn-berry",
42 Self::Bun => "bun",
43 Self::Deno => "deno",
44 })
45 }
46}
47
48impl FromStr for PackageManager {
49 type Err = Error;
50
51 fn from_str(value: &str) -> Result<Self> {
52 match value.trim().to_ascii_lowercase().as_str() {
53 "npm" | "npx" => Ok(Self::Npm),
54 "pnpm" | "pnpx" => Ok(Self::Pnpm),
55 "yarn-classic" | "yarn1" | "yarn@1" => Ok(Self::YarnClassic),
56 "yarn-berry" | "yarn2" | "yarn3" | "yarn4" | "yarn@2" | "yarn@3" | "yarn@4" => {
57 Ok(Self::YarnBerry)
58 }
59 "yarn" | "yarnpkg" => Err(Error::config(
60 "Yarn major is required; use yarn-classic or yarn-berry",
61 )),
62 "bun" | "bunx" => Ok(Self::Bun),
63 "deno" => Ok(Self::Deno),
64 other => Err(Error::config(format!("unknown package manager `{other}`"))),
65 }
66 }
67}
68
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct RegistryProbe {
71 pub url: String,
72 pub ok: bool,
73 pub latency_ms: Option<u64>,
74 pub error: Option<String>,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub enum RegistryPlan {
80 PassThrough {
81 reason: String,
82 },
83 Selected {
84 url: String,
85 probes: Vec<RegistryProbe>,
86 },
87 Unavailable {
88 probes: Vec<RegistryProbe>,
89 },
90}
91
92pub fn manager_for_command(command: &str, backend_version: Option<&str>) -> Option<PackageManager> {
96 let command = executable_name(command);
97 match command.as_str() {
98 "npm" | "npx" => Some(PackageManager::Npm),
99 "pnpm" | "pnpx" => Some(PackageManager::Pnpm),
100 "yarn" | "yarnpkg" => {
101 let major = backend_version.and_then(parse_major)?;
102 Some(if major == 1 {
103 PackageManager::YarnClassic
104 } else {
105 PackageManager::YarnBerry
106 })
107 }
108 "bun" | "bunx" => Some(PackageManager::Bun),
109 "deno" => Some(PackageManager::Deno),
110 _ => None,
111 }
112}
113
114fn parse_major(value: &str) -> Option<u64> {
115 value
116 .trim_start_matches(|character: char| !character.is_ascii_digit())
117 .split('.')
118 .next()?
119 .parse()
120 .ok()
121}
122
123fn executable_name(command: &str) -> String {
124 let basename = command
125 .rsplit(['/', '\\'])
126 .next()
127 .unwrap_or(command)
128 .to_ascii_lowercase();
129 for suffix in [".exe", ".cmd", ".bat"] {
130 if let Some(stem) = basename.strip_suffix(suffix) {
131 return stem.to_string();
132 }
133 }
134 basename
135}
136
137pub fn registry_env(manager: PackageManager) -> &'static str {
141 match manager {
142 PackageManager::Pnpm => "pnpm_config_registry",
143 PackageManager::YarnClassic => "YARN_REGISTRY",
144 PackageManager::YarnBerry => "YARN_NPM_REGISTRY_SERVER",
145 PackageManager::Bun => "BUN_CONFIG_REGISTRY",
146 PackageManager::Deno => "NPM_CONFIG_REGISTRY",
147 PackageManager::Npm => "npm_config_registry",
148 }
149}
150
151pub fn should_plan(manager: PackageManager, executable_alias: &str, args: &[String]) -> bool {
154 let invocation = analyze_invocation(manager, executable_alias, args);
155 if explicit_registry_arg(&invocation.options)
156 || explicit_config_context_arg(manager, &invocation.options)
157 || explicit_offline_arg(manager, invocation.command, &invocation.options)
158 || leading_introspection(&invocation.options)
159 {
160 return false;
161 }
162 let executable = executable_name(executable_alias);
163 let command = invocation.command;
164 match manager {
165 PackageManager::Npm if executable == "npx" => {
166 command.is_some() || npx_fetch_form(&invocation.options)
167 }
168 PackageManager::Npm => matches!(
169 command,
170 Some("install" | "i" | "ci" | "add" | "update" | "up" | "exec")
171 ),
172 PackageManager::Pnpm if executable == "pnpx" => command.is_some(),
173 PackageManager::Pnpm => matches!(
174 command,
175 Some("install" | "i" | "add" | "update" | "up" | "fetch" | "dlx" | "deploy")
176 ),
177 PackageManager::YarnClassic | PackageManager::YarnBerry => {
178 command.is_none()
179 || matches!(
180 command,
181 Some("install" | "add" | "upgrade" | "up" | "dlx" | "create")
182 )
183 }
184 PackageManager::Bun if executable == "bunx" => command.is_some(),
185 PackageManager::Bun => {
186 matches!(
187 command,
188 Some("install" | "i" | "ci" | "add" | "update" | "x")
189 )
190 }
191 PackageManager::Deno => matches!(
192 command,
193 Some(
194 "add"
195 | "bench"
196 | "cache"
197 | "check"
198 | "ci"
199 | "compile"
200 | "doc"
201 | "eval"
202 | "info"
203 | "install"
204 | "outdated"
205 | "run"
206 | "serve"
207 | "task"
208 | "test"
209 | "update"
210 )
211 ),
212 }
213}
214
215struct ManagerInvocation<'a> {
216 command: Option<&'a str>,
217 options: Vec<&'a str>,
218}
219
220fn is_launcher_alias(executable: &str) -> bool {
221 matches!(executable, "npx" | "pnpx" | "bunx")
222}
223
224fn analyze_invocation<'a>(
225 manager: PackageManager,
226 executable_alias: &str,
227 args: &'a [String],
228) -> ManagerInvocation<'a> {
229 let executable = executable_name(executable_alias);
230 let command = first_command(manager, &executable, args);
231 let scope = manager_option_scope(manager, &executable, args);
232 let launcher_alias = is_launcher_alias(&executable);
233 let command_index = if launcher_alias {
234 None
235 } else {
236 first_positional_index(manager, &executable, None, scope, 0)
237 };
238 let scoped_command = command_index.map(|index| scope[index].as_str());
239 let mut options = Vec::new();
240 let mut index = 0;
241 while index < scope.len() {
242 if command_index == Some(index) {
243 index += 1;
244 continue;
245 }
246 let argument = scope[index].as_str();
247 if argument.starts_with('-') {
248 options.push(argument);
249 if option_takes_separate_value(manager, &executable, scoped_command, argument) {
250 index += 2;
251 continue;
252 }
253 }
254 index += 1;
255 }
256 ManagerInvocation { command, options }
257}
258
259fn first_command<'a>(
260 manager: PackageManager,
261 executable: &str,
262 args: &'a [String],
263) -> Option<&'a str> {
264 let separator = args
265 .iter()
266 .position(|argument| argument == "--")
267 .unwrap_or(args.len());
268 first_positional_index(manager, executable, None, &args[..separator], 0)
269 .map(|index| args[index].as_str())
270 .or_else(|| {
271 (separator < args.len())
272 .then(|| args.get(separator + 1))
273 .flatten()
274 .map(String::as_str)
275 })
276}
277
278fn first_positional_index(
279 manager: PackageManager,
280 executable: &str,
281 command: Option<&str>,
282 args: &[String],
283 start: usize,
284) -> Option<usize> {
285 let mut skip_value = false;
286 for (index, argument) in args.iter().enumerate().skip(start) {
287 if skip_value {
288 skip_value = false;
289 continue;
290 }
291 let argument = argument.as_str();
292 if option_takes_separate_value(manager, executable, command, argument) {
293 skip_value = true;
294 } else if !argument.starts_with('-') {
295 return Some(index);
296 }
297 }
298 None
299}
300
301fn option_takes_separate_value(
302 manager: PackageManager,
303 executable: &str,
304 command: Option<&str>,
305 argument: &str,
306) -> bool {
307 match manager {
308 PackageManager::Npm => {
309 matches!(
310 argument,
311 "--cache"
312 | "--config"
313 | "--globalconfig"
314 | "--prefix"
315 | "--registry"
316 | "--script-shell"
317 | "--userconfig"
318 | "--workspace"
319 | "-w"
320 ) || executable == "npx"
321 && matches!(
322 argument,
323 "--allow-scripts" | "--call" | "--package" | "--shell" | "-c" | "-p"
324 )
325 }
326 PackageManager::Pnpm => {
327 matches!(
328 argument,
329 "--cache-dir"
330 | "--config"
331 | "--config-dir"
332 | "--config-file"
333 | "--dir"
334 | "--filter"
335 | "--global-bin-dir"
336 | "--global-dir"
337 | "--globalconfig"
338 | "--prefix"
339 | "--registry"
340 | "--reporter"
341 | "--state-dir"
342 | "--store-dir"
343 | "--userconfig"
344 | "--virtual-store-dir"
345 | "-C"
346 | "-F"
347 ) || (executable == "pnpx" || command == Some("dlx"))
348 && matches!(argument, "--allow-build" | "--package" | "-p")
349 }
350 PackageManager::YarnClassic | PackageManager::YarnBerry => {
351 matches!(
352 argument,
353 "--cache-folder"
354 | "--cwd"
355 | "--mutex"
356 | "--npm-registry-server"
357 | "--registry"
358 | "--use-yarnrc"
359 | "-C"
360 ) || matches!(command, Some("dlx" | "create")) && matches!(argument, "--package" | "-p")
361 }
362 PackageManager::Bun => {
363 matches!(
364 argument,
365 "--backend" | "--cache-dir" | "--config" | "--cwd" | "--linker" | "--registry"
366 ) || (executable == "bunx" || command == Some("x"))
367 && matches!(argument, "--package" | "-p")
368 }
369 PackageManager::Deno => {
370 matches!(
371 argument,
372 "--cert"
373 | "--config"
374 | "--config-file"
375 | "--cwd"
376 | "--env-file"
377 | "--import-map"
378 | "--inspect"
379 | "--inspect-brk"
380 | "--inspect-wait"
381 | "--location"
382 | "--lock"
383 | "--log-level"
384 | "--node-modules-dir"
385 | "--seed"
386 | "--v8-flags"
387 | "--watch-exclude"
388 | "-c"
389 ) || command == Some("compile")
390 && matches!(
391 argument,
392 "--exclude" | "--icon" | "--include" | "--output" | "--target" | "-o"
393 )
394 || command == Some("eval") && argument == "--ext"
395 || command == Some("serve") && matches!(argument, "--host" | "--port")
396 || command == Some("task") && matches!(argument, "--filter" | "-F")
397 }
398 }
399}
400
401fn manager_option_scope<'a>(
406 manager: PackageManager,
407 executable_alias: &str,
408 args: &'a [String],
409) -> &'a [String] {
410 let separator = args
411 .iter()
412 .position(|argument| argument == "--")
413 .unwrap_or(args.len());
414 let before_separator = &args[..separator];
415 let executable = executable_name(executable_alias);
416 let Some(command_index) =
417 first_positional_index(manager, &executable, None, before_separator, 0)
418 else {
419 return before_separator;
420 };
421 let command = before_separator[command_index].as_str();
422
423 let target_index = if matches!(executable.as_str(), "npx" | "pnpx" | "bunx") {
424 Some(command_index)
425 } else if matches!(manager, PackageManager::Pnpm) && command == "dlx"
426 || matches!(
427 manager,
428 PackageManager::YarnClassic | PackageManager::YarnBerry
429 ) && matches!(command, "dlx" | "create")
430 || matches!(manager, PackageManager::Bun) && command == "x"
431 || matches!(manager, PackageManager::Deno)
432 && matches!(command, "compile" | "eval" | "run" | "serve" | "task")
433 {
434 first_positional_index(
435 manager,
436 &executable,
437 Some(command),
438 before_separator,
439 command_index + 1,
440 )
441 } else {
442 None
443 };
444
445 target_index
446 .map(|index| &before_separator[..=index])
447 .unwrap_or(before_separator)
448}
449
450fn explicit_registry_arg(args: &[&str]) -> bool {
451 args.iter().any(|arg| {
452 let lower = arg.to_ascii_lowercase();
453 lower == "--registry"
454 || lower.starts_with("--registry=")
455 || lower == "--npm-registry"
456 || lower.starts_with("--npm-registry=")
457 || lower == "--npm-registry-server"
458 || lower.starts_with("--npm-registry-server=")
459 })
460}
461
462fn explicit_config_context_arg(manager: PackageManager, args: &[&str]) -> bool {
463 args.iter().any(|arg| {
464 let lower = arg.to_ascii_lowercase();
465 matches!(
466 lower.as_str(),
467 "--cwd"
468 | "--dir"
469 | "--prefix"
470 | "--config"
471 | "--config-file"
472 | "--userconfig"
473 | "--globalconfig"
474 | "--use-yarnrc"
475 ) || [
476 "--cwd=",
477 "--dir=",
478 "--prefix=",
479 "--config=",
480 "--config-file=",
481 "--userconfig=",
482 "--globalconfig=",
483 "--use-yarnrc=",
484 ]
485 .iter()
486 .any(|prefix| lower.starts_with(prefix))
487 || manager == PackageManager::Deno && lower == "-c"
488 })
489}
490
491fn explicit_offline_arg(manager: PackageManager, command: Option<&str>, args: &[&str]) -> bool {
492 if manager == PackageManager::Deno {
493 let supports_cached_only = matches!(
494 command,
495 Some(
496 "bench" | "check" | "compile" | "doc" | "eval" | "info" | "run" | "serve" | "test"
497 )
498 );
499 return supports_cached_only
500 && args
501 .iter()
502 .any(|arg| boolean_flag_enabled(arg, "--cached-only"));
503 }
504 args.iter()
505 .any(|arg| boolean_flag_enabled(arg, "--offline"))
506}
507
508fn boolean_flag_enabled(argument: &str, name: &str) -> bool {
509 argument == name
510 || argument
511 .strip_prefix(name)
512 .and_then(|value| value.strip_prefix('='))
513 .is_some_and(|value| matches!(value.to_ascii_lowercase().as_str(), "1" | "true"))
514}
515
516fn leading_introspection(args: &[&str]) -> bool {
517 args.iter()
518 .any(|arg| matches!(*arg, "--help" | "-h" | "--version" | "-v"))
519}
520
521fn npx_fetch_form(options: &[&str]) -> bool {
522 options.iter().any(|argument| {
523 matches!(*argument, "--call" | "--package" | "-c" | "-p")
524 || argument.starts_with("--call=")
525 || argument.starts_with("--package=")
526 || argument.starts_with("-c=")
527 || argument.starts_with("-p=")
528 })
529}
530
531pub async fn plan<F>(
534 ctx: &Ctx,
535 cwd: &Path,
536 manager: PackageManager,
537 executable_alias: &str,
538 args: &[String],
539 getenv: F,
540) -> Result<RegistryPlan>
541where
542 F: Fn(&str) -> Option<String> + Copy,
543{
544 let invocation = analyze_invocation(manager, executable_alias, args);
545 if !should_plan(manager, executable_alias, args) {
546 let reason = if explicit_registry_arg(&invocation.options) {
547 "the command has an explicit registry"
548 } else if explicit_config_context_arg(manager, &invocation.options) {
549 "the command selects a different working directory or native configuration"
550 } else if explicit_offline_arg(manager, invocation.command, &invocation.options) {
551 "the package manager was explicitly asked to use only local cache data"
552 } else {
553 "the command does not require registry preflight"
554 };
555 return Ok(RegistryPlan::PassThrough {
556 reason: reason.into(),
557 });
558 }
559 if ctx.config.settings.offline {
560 return Ok(RegistryPlan::PassThrough {
561 reason: "osdk is offline".into(),
562 });
563 }
564 if let Some(name) = explicit_registry_env(manager, getenv) {
565 return Ok(RegistryPlan::PassThrough {
566 reason: format!("registry is explicitly configured by environment variable {name}"),
567 });
568 }
569
570 let native = match native_registry_candidates(ctx, cwd, manager, getenv)? {
571 NativeDecision::PassThrough(reason) => return Ok(RegistryPlan::PassThrough { reason }),
572 NativeDecision::Candidates(native) => native,
573 };
574 let preserve_order = !native.is_empty() || !ctx.config.registries().npm.urls.is_empty();
575 let candidates = effective_candidates(ctx, native)?;
576 let probes = probe_all(&candidates, ctx.config.registries().npm.probe_timeout_ms).await;
577 let selected = select_probe(&probes, preserve_order);
578 if let Some(selected) = selected {
579 Ok(RegistryPlan::Selected {
580 url: selected.url.clone(),
581 probes,
582 })
583 } else {
584 Ok(RegistryPlan::Unavailable { probes })
585 }
586}
587
588fn select_probe(probes: &[RegistryProbe], preserve_order: bool) -> Option<&RegistryProbe> {
589 if preserve_order {
590 probes.iter().find(|probe| probe.ok)
591 } else {
592 probes
593 .iter()
594 .filter(|probe| probe.ok)
595 .min_by_key(|probe| probe.latency_ms.unwrap_or(u64::MAX))
596 }
597}
598
599fn explicit_registry_env<F>(manager: PackageManager, getenv: F) -> Option<&'static str>
600where
601 F: Fn(&str) -> Option<String> + Copy,
602{
603 let names: &[&str] = match manager {
604 PackageManager::Pnpm => &[
605 "pnpm_config_registry",
606 "PNPM_CONFIG_REGISTRY",
607 "npm_config_registry",
608 "NPM_CONFIG_REGISTRY",
609 ],
610 PackageManager::YarnBerry => &[
611 "YARN_NPM_REGISTRY_SERVER",
612 "yarn_npm_registry_server",
613 "npm_config_registry",
614 "NPM_CONFIG_REGISTRY",
615 ],
616 PackageManager::YarnClassic => &[
617 "YARN_REGISTRY",
618 "yarn_registry",
619 "npm_config_registry",
620 "NPM_CONFIG_REGISTRY",
621 ],
622 PackageManager::Bun => &[
623 "BUN_CONFIG_REGISTRY",
624 "bun_config_registry",
625 "npm_config_registry",
626 "NPM_CONFIG_REGISTRY",
627 ],
628 PackageManager::Deno => &["NPM_CONFIG_REGISTRY", "npm_config_registry"],
629 _ => &["npm_config_registry", "NPM_CONFIG_REGISTRY"],
630 };
631 names
632 .iter()
633 .copied()
634 .find(|name| getenv(name).is_some_and(|value| !value.trim().is_empty()))
635}
636
637#[derive(Debug)]
638enum NativeDecision {
639 PassThrough(String),
640 Candidates(Vec<String>),
641}
642
643fn effective_candidates(ctx: &Ctx, native: Vec<String>) -> Result<Vec<String>> {
644 let configured = &ctx.config.registries().npm.urls;
645 let values: Vec<String> = if !configured.is_empty() {
646 configured.clone()
647 } else {
648 native
649 .into_iter()
650 .chain([NPMMIRROR.to_string(), NPMJS.to_string()])
651 .collect()
652 };
653 normalize_candidates(values)
654}
655
656fn normalize_candidates(values: Vec<String>) -> Result<Vec<String>> {
657 let mut seen = BTreeSet::new();
658 let mut output = Vec::new();
659 for value in values {
660 let value = normalize_registry_url(&value)?;
661 if seen.insert(value.clone()) {
662 output.push(value);
663 }
664 }
665 Ok(output)
666}
667
668fn native_registry_candidates<F>(
669 ctx: &Ctx,
670 cwd: &Path,
671 manager: PackageManager,
672 getenv: F,
673) -> Result<NativeDecision>
674where
675 F: Fn(&str) -> Option<String> + Copy,
676{
677 let mut files = Vec::new();
678 if let Some(name) = uncertain_native_config_path_env(getenv) {
679 return Ok(NativeDecision::PassThrough(format!(
680 "cannot safely resolve native registry configuration selected by environment variable {name}"
681 )));
682 }
683 match manager {
684 PackageManager::Npm | PackageManager::Pnpm | PackageManager::YarnClassic => {
685 if let Err(path) = push_nearest(cwd, ".npmrc", &mut files) {
686 return Ok(unreadable_native_config(path));
687 }
688 if manager == PackageManager::YarnClassic {
689 if let Err(path) = push_nearest(cwd, ".yarnrc", &mut files) {
690 return Ok(unreadable_native_config(path));
691 }
692 }
693 }
694 PackageManager::YarnBerry => {
695 if let Err(path) = push_nearest(cwd, ".yarnrc.yml", &mut files) {
696 return Ok(unreadable_native_config(path));
697 }
698 }
699 PackageManager::Bun => {
700 if let Err(path) = push_nearest(cwd, "bunfig.toml", &mut files) {
701 return Ok(unreadable_native_config(path));
702 }
703 if let Err(path) = push_nearest(cwd, ".npmrc", &mut files) {
704 return Ok(unreadable_native_config(path));
705 }
706 }
707 PackageManager::Deno => {
708 if let Err(path) = push_nearest(cwd, ".npmrc", &mut files) {
709 return Ok(unreadable_native_config(path));
710 }
711 }
712 }
713 let reads_npm_config = matches!(
714 manager,
715 PackageManager::Npm
716 | PackageManager::Pnpm
717 | PackageManager::YarnClassic
718 | PackageManager::Bun
719 | PackageManager::Deno
720 );
721 if reads_npm_config {
722 for name in ["NPM_CONFIG_USERCONFIG", "npm_config_userconfig"] {
723 if let Some(path) = env_path(cwd, name, getenv) {
724 if let Err(path) = push_if_present(path, &mut files) {
725 return Ok(unreadable_native_config(path));
726 }
727 }
728 }
729 }
730 for home in native_home_directories(cwd, getenv, cfg!(windows)) {
731 if reads_npm_config {
732 if let Err(path) = push_if_present(home.join(".npmrc"), &mut files) {
733 return Ok(unreadable_native_config(path));
734 }
735 }
736 if matches!(
737 manager,
738 PackageManager::YarnClassic | PackageManager::YarnBerry
739 ) {
740 for path in [home.join(".yarnrc"), home.join(".yarnrc.yml")] {
741 if let Err(path) = push_if_present(path, &mut files) {
742 return Ok(unreadable_native_config(path));
743 }
744 }
745 }
746 if manager == PackageManager::Bun {
747 for path in [
748 home.join(".bunfig.toml"),
749 home.join(".config/bun/bunfig.toml"),
750 ] {
751 if let Err(path) = push_if_present(path, &mut files) {
752 return Ok(unreadable_native_config(path));
753 }
754 }
755 }
756 }
757 if reads_npm_config {
758 let mut explicit_global_config = false;
759 for name in ["NPM_CONFIG_GLOBALCONFIG", "npm_config_globalconfig"] {
760 if let Some(path) = env_path(cwd, name, getenv) {
761 explicit_global_config = true;
762 if let Err(path) = push_if_present(path, &mut files) {
763 return Ok(unreadable_native_config(path));
764 }
765 }
766 }
767 if !explicit_global_config {
768 let mut explicit_prefix = false;
769 for name in ["NPM_CONFIG_PREFIX", "npm_config_prefix"] {
770 if let Some(prefix) = env_path(cwd, name, getenv) {
771 explicit_prefix = true;
772 if let Err(path) = push_if_present(prefix.join("etc/npmrc"), &mut files) {
773 return Ok(unreadable_native_config(path));
774 }
775 }
776 }
777 if !explicit_prefix {
778 if let Some(prefix) = env_path(cwd, "PREFIX", getenv) {
779 if let Err(path) = push_if_present(prefix.join("etc/npmrc"), &mut files) {
780 return Ok(unreadable_native_config(path));
781 }
782 }
783 }
784 }
785 for path in managed_npm_config_files(ctx) {
786 if let Err(path) = push_if_present(path, &mut files) {
787 return Ok(unreadable_native_config(path));
788 }
789 }
790 }
791 if manager == PackageManager::Bun {
792 if let Some(config_home) =
793 getenv("XDG_CONFIG_HOME").filter(|value| !value.trim().is_empty())
794 {
795 let config_home = resolve_from(cwd, config_home);
796 if let Err(path) = push_if_present(config_home.join("bun/bunfig.toml"), &mut files) {
797 return Ok(unreadable_native_config(path));
798 }
799 }
800 }
801
802 if let Some(name) = global_auth_env(getenv) {
803 return Ok(NativeDecision::PassThrough(format!(
804 "global registry authentication is configured by environment variable {name}"
805 )));
806 }
807 if let Some(name) = global_tls_policy_env(getenv) {
808 return Ok(NativeDecision::PassThrough(format!(
809 "global registry TLS policy is configured by environment variable {name}"
810 )));
811 }
812 if let Some(name) = global_native_proxy_env(getenv) {
813 return Ok(NativeDecision::PassThrough(format!(
814 "native registry proxy is configured by environment variable {name}"
815 )));
816 }
817
818 let mut public = Vec::new();
819 for file in files {
820 let text = match std::fs::read_to_string(&file) {
821 Ok(text) => text,
822 Err(_) => {
823 return Ok(NativeDecision::PassThrough(format!(
824 "cannot safely read native registry configuration {}",
825 file.display()
826 )));
827 }
828 };
829 let analysis = match analyze_native_file(&file, &text) {
830 Ok(analysis) => analysis,
831 Err(_) => {
832 return Ok(NativeDecision::PassThrough(format!(
833 "cannot safely parse native registry configuration {}",
834 file.display()
835 )));
836 }
837 };
838 if analysis.auth {
839 return Ok(NativeDecision::PassThrough(format!(
840 "native registry authentication is configured in {}",
841 file.display()
842 )));
843 }
844 if analysis.tls_policy {
845 return Ok(NativeDecision::PassThrough(format!(
846 "native registry TLS policy is configured in {}",
847 file.display()
848 )));
849 }
850 if analysis.config_location {
851 return Ok(NativeDecision::PassThrough(format!(
852 "native registry configuration selects another config location in {}",
853 file.display()
854 )));
855 }
856 if analysis.proxy {
857 return Ok(NativeDecision::PassThrough(format!(
858 "native registry proxy is configured in {}",
859 file.display()
860 )));
861 }
862 if analysis.scoped_registry {
863 return Ok(NativeDecision::PassThrough(format!(
864 "a scoped registry is configured in {}",
865 file.display()
866 )));
867 }
868 if analysis.registry.is_none() {
869 continue;
870 }
871 if let Some(registry) = analysis.registry {
872 let parsed = match reqwest::Url::parse(registry.trim()) {
873 Ok(parsed) => parsed,
874 Err(_) => {
875 return Ok(NativeDecision::PassThrough(format!(
876 "private or unknown native registry is configured in {}",
877 file.display()
878 )));
879 }
880 };
881 if !parsed.username().is_empty()
882 || parsed.password().is_some()
883 || parsed.query().is_some()
884 || parsed.fragment().is_some()
885 {
886 return Ok(NativeDecision::PassThrough(format!(
887 "native registry authentication is configured in {}",
888 file.display()
889 )));
890 }
891 let Ok(normalized) = normalize_registry_url(®istry) else {
892 return Ok(NativeDecision::PassThrough(format!(
893 "private or unknown native registry is configured in {}",
894 file.display()
895 )));
896 };
897 if !is_known_public_registry(&normalized) {
898 return Ok(NativeDecision::PassThrough(format!(
899 "private or unknown native registry is configured in {}",
900 file.display()
901 )));
902 }
903 public.push(normalized);
904 }
905 }
906 if let Some(primary) = public.first().cloned() {
907 return Ok(NativeDecision::Candidates(vec![primary]));
908 }
909 Ok(NativeDecision::Candidates(Vec::new()))
910}
911
912fn managed_npm_config_files(ctx: &Ctx) -> Vec<PathBuf> {
913 let mut files = Vec::new();
914 let Ok(entries) = std::fs::read_dir(ctx.dirs.installs.join("node")) else {
915 return files;
916 };
917 for entry in entries.flatten() {
918 let root = entry.path();
919 if !root.is_dir() || !root.join(".osdk-complete").is_file() {
920 continue;
921 }
922 let prefix = if ctx.platform.os == crate::platform::Os::Windows {
923 root.clone()
924 } else {
925 root.clone()
928 };
929 push_unique(prefix.join("etc/npmrc"), &mut files);
930 for path in [
931 root.join("lib/node_modules/npm/npmrc"),
932 root.join("lib/node_modules/npm/.npmrc"),
933 root.join("node_modules/npm/npmrc"),
934 root.join("node_modules/npm/.npmrc"),
935 ] {
936 push_unique(path, &mut files);
937 }
938 }
939 files
940}
941
942fn unreadable_native_config(path: PathBuf) -> NativeDecision {
943 NativeDecision::PassThrough(format!(
944 "cannot safely read native registry configuration {}",
945 path.display()
946 ))
947}
948
949fn push_nearest(
950 cwd: &Path,
951 name: &str,
952 files: &mut Vec<PathBuf>,
953) -> std::result::Result<(), PathBuf> {
954 for directory in cwd.ancestors() {
955 let path = directory.join(name);
956 match std::fs::symlink_metadata(&path) {
957 Ok(_) => {
958 push_unique(path, files);
959 break;
960 }
961 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
962 Err(_) => return Err(path),
963 }
964 }
965 Ok(())
966}
967
968fn push_if_present(path: PathBuf, files: &mut Vec<PathBuf>) -> std::result::Result<(), PathBuf> {
969 match std::fs::symlink_metadata(&path) {
970 Ok(_) => push_unique(path, files),
971 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
972 Err(_) => return Err(path),
973 }
974 Ok(())
975}
976
977fn push_unique(path: PathBuf, files: &mut Vec<PathBuf>) {
978 if !files.contains(&path) {
979 files.push(path);
980 }
981}
982
983fn env_path<F>(cwd: &Path, name: &str, getenv: F) -> Option<PathBuf>
984where
985 F: Fn(&str) -> Option<String> + Copy,
986{
987 getenv(name)
988 .filter(|value| !value.trim().is_empty())
989 .map(|value| resolve_from(cwd, value))
990}
991
992fn uncertain_native_config_path_env<F>(getenv: F) -> Option<&'static str>
993where
994 F: Fn(&str) -> Option<String> + Copy,
995{
996 [
997 "NPM_CONFIG_USERCONFIG",
998 "npm_config_userconfig",
999 "NPM_CONFIG_GLOBALCONFIG",
1000 "npm_config_globalconfig",
1001 "NPM_CONFIG_PREFIX",
1002 "npm_config_prefix",
1003 "PREFIX",
1004 "XDG_CONFIG_HOME",
1005 ]
1006 .into_iter()
1007 .find(|name| {
1008 getenv(name).is_some_and(|value| {
1009 let value = value.trim();
1010 value.contains("${") || value.starts_with("~/") || value.starts_with("~\\")
1011 })
1012 })
1013}
1014
1015fn resolve_from(cwd: &Path, value: String) -> PathBuf {
1016 let path = PathBuf::from(value);
1017 if path.is_absolute() || is_windows_absolute(&path) {
1018 path
1019 } else {
1020 cwd.join(path)
1021 }
1022}
1023
1024fn native_home_directories<F>(cwd: &Path, getenv: F, windows: bool) -> Vec<PathBuf>
1025where
1026 F: Fn(&str) -> Option<String> + Copy,
1027{
1028 let names = if windows {
1029 ["USERPROFILE", "HOME"]
1030 } else {
1031 ["HOME", "USERPROFILE"]
1032 };
1033 let mut homes = Vec::new();
1034 for name in names {
1035 if let Some(value) = getenv(name).filter(|value| !value.trim().is_empty()) {
1036 let path = PathBuf::from(value);
1037 let path = if path.is_absolute() || (windows && is_windows_absolute(&path)) {
1038 path
1039 } else {
1040 cwd.join(path)
1041 };
1042 if !homes.contains(&path) {
1043 homes.push(path);
1044 }
1045 }
1046 }
1047 homes
1048}
1049
1050fn is_windows_absolute(path: &Path) -> bool {
1051 let value = path.to_string_lossy().as_bytes().to_vec();
1052 value.starts_with(b"\\\\")
1053 || value.starts_with(b"//")
1054 || (value.len() >= 3
1055 && value[0].is_ascii_alphabetic()
1056 && value[1] == b':'
1057 && matches!(value[2], b'/' | b'\\'))
1058}
1059
1060#[derive(Default)]
1061struct NativeAnalysis {
1062 registry: Option<String>,
1063 auth: bool,
1064 tls_policy: bool,
1065 scoped_registry: bool,
1066 config_location: bool,
1067 proxy: bool,
1068}
1069
1070fn analyze_native_file(path: &Path, text: &str) -> Result<NativeAnalysis> {
1071 match path.file_name().and_then(|name| name.to_str()) {
1072 Some(".yarnrc.yml") => analyze_yarn_yaml(text),
1073 Some("bunfig.toml") | Some(".bunfig.toml") => analyze_bun_toml(text),
1074 Some(".yarnrc") => Ok(analyze_yarn_classic(text)),
1075 _ => Ok(analyze_npmrc(text)),
1076 }
1077}
1078
1079fn analyze_npmrc(text: &str) -> NativeAnalysis {
1080 let mut analysis = NativeAnalysis::default();
1081 for raw in text.lines() {
1082 let line = raw.trim();
1083 if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
1084 continue;
1085 }
1086 let Some((key, value)) = line.split_once('=') else {
1087 continue;
1088 };
1089 let key_lower = key.trim().to_ascii_lowercase();
1090 let value = value.trim();
1091 if auth_key(&key_lower) {
1092 analysis.auth |= !value.is_empty()
1095 && (!key_lower.ends_with("always-auth") || !value.eq_ignore_ascii_case("false"));
1096 } else if tls_policy_key(&key_lower) {
1097 analysis.tls_policy = true;
1100 } else if native_proxy_key(&key_lower) {
1101 analysis.proxy = true;
1102 } else if matches!(key_lower.as_str(), "prefix" | "globalconfig" | "userconfig") {
1103 analysis.config_location = true;
1106 } else if key_lower == "registry" {
1107 analysis.registry = Some(value.trim().trim_matches(&['"', '\''][..]).into());
1108 } else if key_lower.starts_with('@') && key_lower.ends_with(":registry") {
1109 analysis.scoped_registry = true;
1110 }
1111 }
1112 analysis
1113}
1114
1115fn analyze_yarn_classic(text: &str) -> NativeAnalysis {
1116 let mut analysis = NativeAnalysis::default();
1117 for raw in text.lines() {
1118 let line = raw.trim();
1119 let mut pieces = line.split_whitespace();
1120 let key = pieces.next().unwrap_or("").trim_matches(&['"', '\''][..]);
1121 let value = pieces.next().unwrap_or("").trim_matches(&['"', '\''][..]);
1122 if auth_key(&key.to_ascii_lowercase()) && !value.is_empty() {
1123 analysis.auth = true;
1124 }
1125 if tls_policy_key(&key.to_ascii_lowercase()) {
1126 analysis.tls_policy = true;
1127 }
1128 if native_proxy_key(&key.to_ascii_lowercase()) {
1129 analysis.proxy = true;
1130 }
1131 if key.starts_with('@') && key.ends_with(":registry") {
1132 analysis.scoped_registry = true;
1133 }
1134 if let Some(value) = line
1135 .strip_prefix("registry ")
1136 .or_else(|| line.strip_prefix("--registry "))
1137 {
1138 analysis.registry = Some(value.trim().trim_matches(&['"', '\''][..]).into());
1139 }
1140 }
1141 analysis
1142}
1143
1144fn analyze_yarn_yaml(text: &str) -> Result<NativeAnalysis> {
1145 let value: serde_yaml::Value = serde_yaml::from_str(text)
1146 .map_err(|error| crate::error::Error::config(format!("invalid Yarn YAML: {error}")))?;
1147 analyze_yarn_yaml_value(&value)
1148}
1149
1150fn analyze_yarn_yaml_value(value: &serde_yaml::Value) -> Result<NativeAnalysis> {
1151 let mut analysis = NativeAnalysis::default();
1152 scan_yarn_yaml_security(value, &mut analysis)?;
1153 let mapping = value
1154 .as_mapping()
1155 .ok_or_else(|| crate::error::Error::config("Yarn configuration must be a YAML mapping"))?;
1156 for (key, value) in mapping {
1157 let key = key.as_str().ok_or_else(|| {
1158 crate::error::Error::config("Yarn configuration contains a non-string key")
1159 })?;
1160 let lower = key.to_ascii_lowercase();
1161 if lower == "npmregistryserver" {
1162 analysis.registry = Some(
1163 value
1164 .as_str()
1165 .ok_or_else(|| {
1166 crate::error::Error::config("Yarn npmRegistryServer must be a string")
1167 })?
1168 .to_owned(),
1169 );
1170 }
1171 }
1172 Ok(analysis)
1173}
1174
1175fn scan_yarn_yaml_security(value: &serde_yaml::Value, analysis: &mut NativeAnalysis) -> Result<()> {
1176 match value {
1177 serde_yaml::Value::Mapping(mapping) => {
1178 for (key, value) in mapping {
1179 let key = key.as_str().ok_or_else(|| {
1180 crate::error::Error::config("Yarn configuration contains a non-string key")
1181 })?;
1182 match key.to_ascii_lowercase().as_str() {
1183 "npmauthtoken" | "npmauthident" | "npmauthalways" | "httpscertfilepath"
1184 | "httpskeyfilepath" => analysis.auth = true,
1185 "httpscafilepath" | "enablestrictssl" => analysis.tls_policy = true,
1186 "httpproxy" | "httpsproxy" => analysis.proxy = true,
1187 "npmscopes" | "npmregistries" => analysis.scoped_registry = true,
1188 _ => {}
1189 }
1190 scan_yarn_yaml_security(value, analysis)?;
1191 }
1192 }
1193 serde_yaml::Value::Sequence(values) => {
1194 for value in values {
1195 scan_yarn_yaml_security(value, analysis)?;
1196 }
1197 }
1198 serde_yaml::Value::Tagged(tagged) => {
1199 scan_yarn_yaml_security(&tagged.value, analysis)?;
1200 }
1201 _ => {}
1202 }
1203 Ok(())
1204}
1205
1206#[cfg(test)]
1207fn analyze_yaml_conservative(text: &str) -> NativeAnalysis {
1208 analyze_yarn_yaml(text).expect("test Yarn YAML should parse")
1209}
1210
1211fn analyze_bun_toml(text: &str) -> Result<NativeAnalysis> {
1212 let value: toml::Value = toml::from_str(text)?;
1213 let Some(install) = value.get("install").and_then(toml::Value::as_table) else {
1214 return Ok(NativeAnalysis::default());
1215 };
1216 let mut analysis = NativeAnalysis::default();
1217 if let Some(registry) = install.get("registry") {
1218 match registry {
1219 toml::Value::String(value) => analysis.registry = Some(value.clone()),
1220 toml::Value::Table(table) => {
1221 analysis.registry = table
1222 .get("url")
1223 .and_then(toml::Value::as_str)
1224 .map(str::to_owned);
1225 analysis.auth = table.keys().any(|key| {
1226 matches!(
1227 key.to_ascii_lowercase().as_str(),
1228 "token" | "username" | "password" | "cert" | "key" | "certfile" | "keyfile"
1229 )
1230 });
1231 analysis.tls_policy = table.keys().any(|key| {
1232 matches!(
1233 key.to_ascii_lowercase().as_str(),
1234 "ca" | "cafile" | "strict-ssl" | "strictssl"
1235 )
1236 });
1237 }
1238 _ => {
1239 analysis.auth = true;
1240 }
1241 }
1242 }
1243 if install.contains_key("scopes") {
1244 analysis.scoped_registry = true;
1245 }
1246 analysis.proxy |= [
1247 "proxy",
1248 "httpProxy",
1249 "httpsProxy",
1250 "http_proxy",
1251 "https_proxy",
1252 ]
1253 .into_iter()
1254 .any(|key| install.contains_key(key));
1255 Ok(analysis)
1256}
1257
1258fn auth_key(key: &str) -> bool {
1259 key == "_auth"
1260 || key == "_authtoken"
1261 || key == "username"
1262 || key == "password"
1263 || key == "_password"
1264 || key == "always-auth"
1265 || key == "cert"
1266 || key == "key"
1267 || key == "certfile"
1268 || key == "keyfile"
1269 || key.ends_with(":_auth")
1270 || key.ends_with(":_authtoken")
1271 || key.ends_with(":_password")
1272 || key.ends_with(":username")
1273 || key.ends_with(":password")
1274 || key.ends_with(":always-auth")
1275 || key.ends_with(":cert")
1276 || key.ends_with(":key")
1277 || key.ends_with(":certfile")
1278 || key.ends_with(":keyfile")
1279}
1280
1281fn tls_policy_key(key: &str) -> bool {
1282 matches!(key, "ca" | "cafile" | "strict-ssl")
1283 || key.ends_with(":ca")
1284 || key.ends_with(":cafile")
1285 || key.ends_with(":strict-ssl")
1286}
1287
1288fn native_proxy_key(key: &str) -> bool {
1289 matches!(
1290 key,
1291 "proxy" | "https-proxy" | "http-proxy" | "noproxy" | "no-proxy"
1292 )
1293}
1294
1295fn global_auth_env<F>(getenv: F) -> Option<&'static str>
1296where
1297 F: Fn(&str) -> Option<String> + Copy,
1298{
1299 [
1300 "NODE_AUTH_TOKEN",
1301 "NPM_TOKEN",
1302 "YARN_NPM_AUTH_TOKEN",
1303 "YARN_NPM_AUTH_IDENT",
1304 "NPM_CONFIG__AUTH",
1305 "npm_config__auth",
1306 "NPM_CONFIG__AUTHTOKEN",
1307 "npm_config__authToken",
1308 "npm_config__authtoken",
1309 "NPM_CONFIG__AUTH_TOKEN",
1310 "npm_config__auth_token",
1311 "NPM_CONFIG_CERT",
1312 "npm_config_cert",
1313 "NPM_CONFIG_KEY",
1314 "npm_config_key",
1315 "NPM_CONFIG_CERTFILE",
1316 "npm_config_certfile",
1317 "NPM_CONFIG_KEYFILE",
1318 "npm_config_keyfile",
1319 "YARN_HTTPS_CERT_FILE_PATH",
1320 "YARN_HTTPS_KEY_FILE_PATH",
1321 "NPM_AUTH_TOKEN",
1322 "npm_auth_token",
1323 "YARN_AUTH_TOKEN",
1324 "BUN_CONFIG_TOKEN",
1325 ]
1326 .into_iter()
1327 .find(|name| getenv(name).is_some_and(|value| !value.trim().is_empty()))
1328}
1329
1330fn global_tls_policy_env<F>(getenv: F) -> Option<&'static str>
1331where
1332 F: Fn(&str) -> Option<String> + Copy,
1333{
1334 [
1335 "NPM_CONFIG_CA",
1336 "npm_config_ca",
1337 "NPM_CONFIG_CAFILE",
1338 "npm_config_cafile",
1339 "NPM_CONFIG_STRICT_SSL",
1340 "npm_config_strict_ssl",
1341 "NODE_EXTRA_CA_CERTS",
1342 "NODE_TLS_REJECT_UNAUTHORIZED",
1343 "YARN_HTTPS_CA_FILE_PATH",
1344 "YARN_ENABLE_STRICT_SSL",
1345 ]
1346 .into_iter()
1347 .find(|name| getenv(name).is_some_and(|value| !value.trim().is_empty()))
1348}
1349
1350fn global_native_proxy_env<F>(getenv: F) -> Option<&'static str>
1351where
1352 F: Fn(&str) -> Option<String> + Copy,
1353{
1354 [
1355 "NPM_CONFIG_PROXY",
1356 "npm_config_proxy",
1357 "NPM_CONFIG_HTTPS_PROXY",
1358 "npm_config_https_proxy",
1359 "NPM_CONFIG_HTTP_PROXY",
1360 "npm_config_http_proxy",
1361 "YARN_HTTP_PROXY",
1362 "YARN_HTTPS_PROXY",
1363 "BUN_CONFIG_PROXY",
1364 "BUN_CONFIG_HTTPS_PROXY",
1365 ]
1366 .into_iter()
1367 .find(|name| getenv(name).is_some_and(|value| !value.trim().is_empty()))
1368}
1369
1370fn is_known_public_registry(value: &str) -> bool {
1371 same_registry(value, NPMJS) || same_registry(value, NPMMIRROR)
1372}
1373
1374fn same_registry(left: &str, right: &str) -> bool {
1375 normalize_registry_url(left).ok() == normalize_registry_url(right).ok()
1376}
1377
1378async fn probe_all(candidates: &[String], timeout_ms: u64) -> Vec<RegistryProbe> {
1379 let timeout = Duration::from_millis(timeout_ms.max(1));
1380 let client = match reqwest::Client::builder()
1381 .user_agent(concat!(
1382 "osdk/",
1383 env!("CARGO_PKG_VERSION"),
1384 " registry-probe"
1385 ))
1386 .redirect(registry_probe_redirect_policy())
1387 .build()
1388 {
1389 Ok(client) => client,
1390 Err(error) => {
1391 return candidates
1392 .iter()
1393 .map(|url| RegistryProbe {
1394 url: url.clone(),
1395 ok: false,
1396 latency_ms: None,
1397 error: Some(format!("client error: {error}")),
1398 })
1399 .collect();
1400 }
1401 };
1402 let futures = candidates.iter().cloned().map(|url| {
1403 let client = client.clone();
1404 async move { probe_one(&client, url, timeout).await }
1405 });
1406 futures_util::future::join_all(futures).await
1407}
1408
1409fn registry_probe_redirect_policy() -> reqwest::redirect::Policy {
1410 reqwest::redirect::Policy::custom(|attempt| {
1411 match validate_registry_probe_redirect(attempt.url(), attempt.previous()) {
1412 Ok(()) => attempt.follow(),
1413 Err(error) => attempt.error(error),
1414 }
1415 })
1416}
1417
1418fn validate_registry_probe_redirect(
1424 next: &reqwest::Url,
1425 previous: &[reqwest::Url],
1426) -> std::result::Result<(), &'static str> {
1427 let Some(initial) = previous.first() else {
1428 return Err("registry probe redirect has no origin");
1429 };
1430 if previous.len() >= MAX_PROBE_REDIRECTS {
1431 return Err("registry probe redirect limit exceeded");
1432 }
1433 if initial.scheme() != "https" || next.scheme() != "https" {
1434 return Err("registry probe redirects must remain on HTTPS");
1435 }
1436 if !next.username().is_empty() || next.password().is_some() {
1437 return Err("registry probe redirect must not contain credentials");
1438 }
1439 if initial.host_str() != next.host_str()
1440 || initial.port_or_known_default() != next.port_or_known_default()
1441 {
1442 return Err("registry probe redirect must remain on the original origin");
1443 }
1444 if previous.iter().any(|url| url == next) {
1445 return Err("registry probe redirect loop detected");
1446 }
1447 Ok(())
1448}
1449
1450async fn probe_one(client: &reqwest::Client, base: String, timeout: Duration) -> RegistryProbe {
1451 let started = Instant::now();
1452 let endpoint = format!("{}-/ping", base.trim_end_matches('/').to_owned() + "/");
1453 let result = tokio::time::timeout(timeout, async {
1454 let response = client
1455 .get(&endpoint)
1456 .header(reqwest::header::ACCEPT, REGISTRY_PROBE_ACCEPT)
1457 .send()
1458 .await
1459 .map_err(probe_error)?;
1460 if !response.status().is_success() {
1461 return Err(format!("HTTP {}", response.status().as_u16()));
1462 }
1463 let mut body = Vec::new();
1464 let mut stream = response.bytes_stream();
1465 while let Some(chunk) = stream.next().await {
1466 let chunk = chunk.map_err(probe_error)?;
1467 if body.len().saturating_add(chunk.len()) > MAX_PROBE_BODY {
1468 return Err(format!("response exceeds {MAX_PROBE_BODY} bytes"));
1469 }
1470 body.extend_from_slice(&chunk);
1471 }
1472 if body.is_empty() {
1473 return Err("empty response".into());
1474 }
1475 let ping: serde_json::Value = serde_json::from_slice(&body)
1476 .map_err(|_| "invalid npm registry ping JSON".to_string())?;
1477 if !ping.is_object() {
1478 return Err("npm registry ping response is not a JSON object".into());
1479 }
1480 Ok(())
1481 })
1482 .await;
1483 match result {
1484 Ok(Ok(())) => RegistryProbe {
1485 url: base,
1486 ok: true,
1487 latency_ms: Some(started.elapsed().as_millis() as u64),
1488 error: None,
1489 },
1490 Ok(Err(error)) => RegistryProbe {
1491 url: base,
1492 ok: false,
1493 latency_ms: None,
1494 error: Some(error),
1495 },
1496 Err(_) => RegistryProbe {
1497 url: base,
1498 ok: false,
1499 latency_ms: None,
1500 error: Some(format!("timed out after {} ms", timeout.as_millis())),
1501 },
1502 }
1503}
1504
1505fn probe_error(error: reqwest::Error) -> String {
1506 if error.is_timeout() {
1507 "request timed out".into()
1508 } else if error.is_connect() {
1509 "connection failed".into()
1510 } else if error.is_body() || error.is_decode() {
1511 "invalid response body".into()
1512 } else {
1513 "request failed".into()
1514 }
1515}
1516
1517#[cfg(test)]
1518mod tests {
1519 use super::*;
1520 use std::io::{Read, Write};
1521 use std::net::{TcpListener, TcpStream};
1522 use std::sync::mpsc;
1523 use std::sync::Arc;
1524 use std::thread;
1525 use std::time::Instant;
1526
1527 use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, COOKIE};
1528
1529 use crate::config::{Config, SourcesConfig};
1530 use crate::dirs::Dirs;
1531 use crate::platform::Platform;
1532 use crate::store::Cas;
1533
1534 #[test]
1535 fn manager_detection_and_registry_environment_are_version_aware() {
1536 assert_eq!(
1537 manager_for_command("C:\\tools\\yarn.cmd", Some("1.22.22")),
1538 Some(PackageManager::YarnClassic)
1539 );
1540 assert_eq!(
1541 manager_for_command("/tools/yarn", Some("4.9.2")),
1542 Some(PackageManager::YarnBerry)
1543 );
1544 assert_eq!(manager_for_command("yarn", None), None);
1545 assert_eq!(registry_env(PackageManager::Npm), "npm_config_registry");
1546 assert_eq!(registry_env(PackageManager::Pnpm), "pnpm_config_registry");
1547 assert_eq!(registry_env(PackageManager::YarnClassic), "YARN_REGISTRY");
1548 assert_eq!(
1549 registry_env(PackageManager::YarnBerry),
1550 "YARN_NPM_REGISTRY_SERVER"
1551 );
1552 assert_eq!(registry_env(PackageManager::Bun), "BUN_CONFIG_REGISTRY");
1553 assert_eq!(registry_env(PackageManager::Deno), "NPM_CONFIG_REGISTRY");
1554 assert!("yarn".parse::<PackageManager>().is_err());
1555 }
1556
1557 #[test]
1558 fn command_filter_only_plans_registry_fetching_invocations() {
1559 let strings = |values: &[&str]| {
1560 values
1561 .iter()
1562 .map(|value| (*value).into())
1563 .collect::<Vec<_>>()
1564 };
1565 assert!(should_plan(
1566 PackageManager::Npm,
1567 "npm",
1568 &strings(&["install"])
1569 ));
1570 assert!(should_plan(
1571 PackageManager::Npm,
1572 "npx",
1573 &strings(&["eslint"])
1574 ));
1575 assert!(!should_plan(
1576 PackageManager::Npm,
1577 "npm",
1578 &strings(&["run", "test"])
1579 ));
1580 assert!(!should_plan(
1581 PackageManager::Npm,
1582 "npm",
1583 &strings(&["--version"])
1584 ));
1585 assert!(!should_plan(
1586 PackageManager::Npm,
1587 "npm",
1588 &strings(&["install", "--registry=https://private.test"])
1589 ));
1590 assert!(!should_plan(
1591 PackageManager::YarnBerry,
1592 "yarn",
1593 &strings(&["install", "--npm-registry-server=https://private.test"])
1594 ));
1595 assert!(!should_plan(
1596 PackageManager::Pnpm,
1597 "pnpm",
1598 &strings(&["--dir", "elsewhere", "install"])
1599 ));
1600 assert!(!should_plan(
1601 PackageManager::Npm,
1602 "npm",
1603 &strings(&["install", "--offline"])
1604 ));
1605 assert!(should_plan(
1606 PackageManager::Npm,
1607 "npm",
1608 &strings(&["install", "--offline=false"])
1609 ));
1610 assert!(should_plan(
1611 PackageManager::Npm,
1612 "npm",
1613 &strings(&["install", "--prefer-offline"])
1614 ));
1615 assert!(should_plan(
1616 PackageManager::YarnBerry,
1617 "yarn",
1618 &strings(&["install", "--immutable-cache"])
1619 ));
1620 assert!(should_plan(PackageManager::YarnBerry, "yarn", &[]));
1621 assert!(should_plan(
1622 PackageManager::YarnBerry,
1623 "yarn",
1624 &strings(&["--immutable-cache"])
1625 ));
1626 assert!(should_plan(
1627 PackageManager::Pnpm,
1628 "pnpm",
1629 &strings(&["install", "--prefer-offline"])
1630 ));
1631 for args in [
1632 vec!["--package", "foo", "-c", "foo --version"],
1633 vec!["--package=foo", "--call=foo --help"],
1634 ] {
1635 assert!(
1636 should_plan(PackageManager::Npm, "npx", &strings(&args)),
1637 "npx package/call forms fetch even without a positional command"
1638 );
1639 }
1640 assert!(should_plan(
1641 PackageManager::Pnpm,
1642 "pnpm",
1643 &strings(&["--reporter", "ndjson", "dlx", "foo"])
1644 ));
1645 assert!(!should_plan(
1646 PackageManager::Pnpm,
1647 "pnpm",
1648 &strings(&["--reporter", "ndjson", "dlx", "--offline", "foo"])
1649 ));
1650 assert!(!should_plan(
1651 PackageManager::Pnpm,
1652 "pnpm",
1653 &strings(&["dlx", "--allow-build", "esbuild", "--offline", "foo"])
1654 ));
1655 assert!(should_plan(
1656 PackageManager::Pnpm,
1657 "pnpm",
1658 &strings(&["dlx", "--allow-build", "esbuild", "foo", "--offline"])
1659 ));
1660 assert!(!should_plan(
1661 PackageManager::Npm,
1662 "npx",
1663 &strings(&["--allow-scripts", "foo", "--offline", "bar"])
1664 ));
1665 assert!(should_plan(
1666 PackageManager::Npm,
1667 "npx",
1668 &strings(&["--allow-scripts", "foo", "bar", "--offline"])
1669 ));
1670 assert!(should_plan(
1671 PackageManager::Deno,
1672 "deno",
1673 &strings(&["eval", "-p", "import('npm:foo')", "--offline"])
1674 ));
1675 assert!(should_plan(PackageManager::Bun, "bun", &strings(&["ci"])));
1676 for command in ["ci", "outdated", "update"] {
1677 assert!(
1678 should_plan(
1679 PackageManager::Deno,
1680 "deno",
1681 &strings(&[command, "--cached-only"])
1682 ),
1683 "deno {command} does not support --cached-only"
1684 );
1685 }
1686 assert!(should_plan(
1687 PackageManager::Deno,
1688 "deno",
1689 &strings(&["run", "--offline", "npm:foo"])
1690 ));
1691 assert!(!should_plan(
1692 PackageManager::Deno,
1693 "deno",
1694 &strings(&["-c", "deno.json", "eval", "import('npm:foo')"])
1695 ));
1696 for (manager, executable, args) in [
1697 (PackageManager::Npm, "npx", vec!["foo", "--", "--version"]),
1698 (
1699 PackageManager::Pnpm,
1700 "pnpm",
1701 vec!["dlx", "foo", "--", "--help"],
1702 ),
1703 (PackageManager::Bun, "bunx", vec!["foo", "--", "--version"]),
1704 (
1705 PackageManager::Deno,
1706 "deno",
1707 vec!["run", "npm:foo", "--", "--offline"],
1708 ),
1709 ] {
1710 assert!(
1711 should_plan(manager, executable, &strings(&args)),
1712 "child arguments after `--` must not suppress {executable} preflight"
1713 );
1714 }
1715 for (manager, executable, args) in [
1716 (PackageManager::Npm, "npx", vec!["foo", "--version"]),
1717 (PackageManager::Pnpm, "pnpm", vec!["dlx", "foo", "--help"]),
1718 (PackageManager::Bun, "bunx", vec!["foo", "--version"]),
1719 (
1720 PackageManager::Deno,
1721 "deno",
1722 vec!["run", "npm:foo", "--offline"],
1723 ),
1724 ] {
1725 assert!(
1726 should_plan(manager, executable, &strings(&args)),
1727 "child flags without a separator must not suppress {executable} preflight"
1728 );
1729 }
1730 for (manager, executable, args) in [
1731 (PackageManager::Npm, "npx", vec!["--offline", "foo"]),
1732 (
1733 PackageManager::Pnpm,
1734 "pnpm",
1735 vec!["dlx", "--offline", "foo"],
1736 ),
1737 (PackageManager::Bun, "bunx", vec!["--help", "foo"]),
1738 (
1739 PackageManager::Deno,
1740 "deno",
1741 vec!["run", "--cached-only", "npm:foo"],
1742 ),
1743 ] {
1744 assert!(
1745 !should_plan(manager, executable, &strings(&args)),
1746 "manager-owned flags must still suppress {executable} preflight"
1747 );
1748 }
1749 for command in [
1750 "add", "bench", "cache", "check", "ci", "compile", "doc", "eval", "info", "install",
1751 "outdated", "run", "serve", "task", "test", "update",
1752 ] {
1753 assert!(
1754 should_plan(PackageManager::Deno, "deno", &strings(&[command])),
1755 "deno {command} may fetch npm packages"
1756 );
1757 }
1758 }
1759
1760 #[test]
1761 fn builtin_selection_uses_latency_but_declared_selection_uses_order() {
1762 let probes = vec![
1763 RegistryProbe {
1764 url: "primary".into(),
1765 ok: true,
1766 latency_ms: Some(80),
1767 error: None,
1768 },
1769 RegistryProbe {
1770 url: "fast".into(),
1771 ok: true,
1772 latency_ms: Some(5),
1773 error: None,
1774 },
1775 ];
1776 assert_eq!(select_probe(&probes, true).unwrap().url, "primary");
1777 assert_eq!(select_probe(&probes, false).unwrap().url, "fast");
1778 }
1779
1780 #[test]
1781 fn explicit_registry_candidates_override_native_public_defaults() {
1782 let temp = tempfile::tempdir().unwrap();
1783 let ctx = test_ctx(temp.path(), Vec::new(), false);
1784 let candidates = effective_candidates(&ctx, vec![NPMMIRROR.into()]).unwrap();
1785 assert_eq!(candidates, [NPMMIRROR, NPMJS]);
1786
1787 let candidates = effective_candidates(&ctx, vec![NPMJS.into()]).unwrap();
1788 assert_eq!(candidates, [NPMJS, NPMMIRROR]);
1789
1790 let ctx = test_ctx(
1791 temp.path(),
1792 vec![
1793 "https://registry.example.test/".into(),
1794 "https://registry.backup.test/".into(),
1795 ],
1796 false,
1797 );
1798 let candidates = effective_candidates(&ctx, vec![NPMJS.into()]).unwrap();
1799 assert_eq!(
1800 candidates,
1801 [
1802 "https://registry.example.test/",
1803 "https://registry.backup.test/"
1804 ]
1805 );
1806 }
1807
1808 #[test]
1809 fn registry_probe_redirects_require_the_original_https_origin() {
1810 let initial = reqwest::Url::parse("https://registry.example/npm/latest").unwrap();
1811 let same_origin =
1812 reqwest::Url::parse("https://registry.example:443/metadata/npm?source=probe").unwrap();
1813 assert_eq!(
1814 validate_registry_probe_redirect(&same_origin, std::slice::from_ref(&initial)),
1815 Ok(())
1816 );
1817
1818 let unsafe_targets = [
1819 (
1820 "http://registry.example/npm/latest",
1821 "registry probe redirects must remain on HTTPS",
1822 ),
1823 (
1824 "https://127.0.0.1/npm/latest",
1825 "registry probe redirect must remain on the original origin",
1826 ),
1827 (
1828 "https://10.0.0.1/npm/latest",
1829 "registry probe redirect must remain on the original origin",
1830 ),
1831 (
1832 "https://169.254.169.254/latest/meta-data",
1833 "registry probe redirect must remain on the original origin",
1834 ),
1835 (
1836 "https://metadata.internal/latest",
1837 "registry probe redirect must remain on the original origin",
1838 ),
1839 (
1840 "https://registry.example:444/npm/latest",
1841 "registry probe redirect must remain on the original origin",
1842 ),
1843 ];
1844 for (target, expected) in unsafe_targets {
1845 let target = reqwest::Url::parse(target).unwrap();
1846 assert_eq!(
1847 validate_registry_probe_redirect(&target, std::slice::from_ref(&initial)),
1848 Err(expected),
1849 "target {target}"
1850 );
1851 }
1852 }
1853
1854 #[test]
1855 fn registry_probe_redirects_reject_loops_and_enforce_the_hop_limit() {
1856 let urls = (0..=4)
1857 .map(|index| {
1858 reqwest::Url::parse(&format!("https://registry.example/redirect/{index}")).unwrap()
1859 })
1860 .collect::<Vec<_>>();
1861
1862 assert_eq!(
1863 validate_registry_probe_redirect(&urls[2], &urls[..2]),
1864 Ok(()),
1865 "the third URL in the redirect chain is allowed"
1866 );
1867 assert_eq!(
1868 validate_registry_probe_redirect(&urls[3], &urls[..3]),
1869 Err("registry probe redirect limit exceeded")
1870 );
1871 assert_eq!(
1872 validate_registry_probe_redirect(&urls[1], &urls[..2]),
1873 Err("registry probe redirect loop detected")
1874 );
1875 }
1876
1877 #[test]
1878 fn scoped_registry_and_host_credentials_are_detected_conservatively() {
1879 let scoped = analyze_npmrc("@private:registry=https://packages.test/\n");
1880 assert!(scoped.scoped_registry);
1881 let host_auth = analyze_npmrc("//packages.test/:_authToken=secret\n");
1882 assert!(host_auth.auth);
1883 let host_auth_with_port = analyze_npmrc("//packages.test:4873/:_authToken=secret\n");
1884 assert!(host_auth_with_port.auth);
1885
1886 let berry = analyze_yaml_conservative(
1887 r#"
1888npmRegistries:
1889 //packages.test:
1890 npmAuthToken: secret
1891"#,
1892 );
1893 assert!(berry.scoped_registry);
1894
1895 for config in [
1896 r#""npmScopes": {private: {npmRegistryServer: "https://packages.test/"}}"#,
1897 r#"'npmRegistries': {'//packages.test': {npmAuthToken: secret}}"#,
1898 ] {
1899 let berry = analyze_yarn_yaml(config).unwrap();
1900 assert!(
1901 berry.scoped_registry,
1902 "quoted or flow-style Yarn registry configuration was missed: {config}"
1903 );
1904 }
1905 assert!(analyze_yarn_yaml(r#""npmAuthToken": secret"#).unwrap().auth);
1906 assert!(
1907 analyze_yarn_yaml(
1908 r#"npmScopes:
1909 private:
1910 "npmAuthToken": secret
1911"#,
1912 )
1913 .unwrap()
1914 .auth
1915 );
1916 assert!(analyze_yarn_yaml("[not, a, mapping]").is_err());
1917 }
1918
1919 #[test]
1920 fn yarn_yaml_quoted_and_flow_security_settings_force_native_pass_through() {
1921 let temp = tempfile::tempdir().unwrap();
1922 let project = temp.path().join("project");
1923 std::fs::create_dir_all(&project).unwrap();
1924
1925 for config in [
1926 r#""npmScopes": {private: {npmRegistryServer: "https://packages.test/"}}"#,
1927 r#"'npmRegistries': {'//packages.test': {npmAuthToken: secret}}"#,
1928 r#""npmAuthToken": secret"#,
1929 ] {
1930 std::fs::write(project.join(".yarnrc.yml"), config).unwrap();
1931 let ctx = test_ctx(temp.path(), vec![unused_loopback_url()], false);
1932 let decision =
1933 native_registry_candidates(&ctx, &project, PackageManager::YarnBerry, |_| None)
1934 .unwrap();
1935 assert!(
1936 matches!(decision, NativeDecision::PassThrough(_)),
1937 "Yarn security configuration was not passed through: {config}"
1938 );
1939 }
1940 }
1941
1942 #[test]
1943 fn npm_client_identity_and_tls_policy_are_detected_conservatively() {
1944 for config in [
1945 "cert=-----BEGIN CERTIFICATE-----\n",
1946 "key=-----BEGIN PRIVATE KEY-----\n",
1947 "//packages.test/:certfile=/secure/client.pem\n",
1948 "//packages.test/team/:keyfile=/secure/client.key\n",
1949 ] {
1950 assert!(analyze_npmrc(config).auth, "missed identity: {config}");
1951 }
1952 for config in [
1953 "cafile=/secure/corporate-ca.pem\n",
1954 "strict-ssl=false\n",
1955 "//packages.test/:cafile=/secure/corporate-ca.pem\n",
1956 ] {
1957 let analysis = analyze_npmrc(config);
1958 assert!(!analysis.auth, "CA policy is not client identity: {config}");
1959 assert!(analysis.tls_policy, "missed TLS policy: {config}");
1960 }
1961
1962 let berry = analyze_yaml_conservative(
1963 "httpsCertFilePath: /secure/client.pem\nhttpsKeyFilePath: /secure/client.key\n",
1964 );
1965 assert!(berry.auth);
1966 let berry_ca =
1967 analyze_yaml_conservative("httpsCaFilePath: /secure/ca.pem\nenableStrictSsl: false\n");
1968 assert!(!berry_ca.auth);
1969 assert!(berry_ca.tls_policy);
1970 }
1971
1972 #[test]
1973 fn npm_auth_and_tls_environment_keys_are_detected_without_values_leaking() {
1974 for expected in [
1975 "NPM_CONFIG_CERT",
1976 "npm_config_key",
1977 "NPM_CONFIG_CERTFILE",
1978 "npm_config_keyfile",
1979 "YARN_HTTPS_CERT_FILE_PATH",
1980 ] {
1981 assert_eq!(
1982 global_auth_env(|key| (key == expected).then(|| "secret-path".into())),
1983 Some(expected)
1984 );
1985 }
1986 for expected in [
1987 "NPM_CONFIG_CAFILE",
1988 "npm_config_strict_ssl",
1989 "NODE_EXTRA_CA_CERTS",
1990 "YARN_HTTPS_CA_FILE_PATH",
1991 ] {
1992 assert_eq!(
1993 global_tls_policy_env(|key| (key == expected).then(|| "secret-path".into())),
1994 Some(expected)
1995 );
1996 }
1997 }
1998
1999 #[test]
2000 fn native_home_discovery_is_platform_ordered_and_deduplicated() {
2001 let cwd = Path::new("/work");
2002 let windows_home = PathBuf::from(r"C:\Users\person");
2003 let getenv = |key: &str| match key {
2004 "HOME" => Some("/posix-home".into()),
2005 "USERPROFILE" => Some("C:\\Users\\person".into()),
2006 _ => None,
2007 };
2008 let non_windows_userprofile = if windows_home.is_absolute() {
2009 windows_home.clone()
2010 } else {
2011 cwd.join(&windows_home)
2012 };
2013 assert_eq!(
2014 native_home_directories(cwd, getenv, false),
2015 [PathBuf::from("/posix-home"), non_windows_userprofile]
2016 );
2017 assert_eq!(
2018 native_home_directories(cwd, getenv, true),
2019 [windows_home, PathBuf::from("/posix-home")]
2020 );
2021 assert_eq!(
2022 native_home_directories(
2023 cwd,
2024 |key| matches!(key, "HOME" | "USERPROFILE").then(|| "/same".into()),
2025 true
2026 ),
2027 [PathBuf::from("/same")]
2028 );
2029 assert_eq!(
2030 native_home_directories(
2031 cwd,
2032 |key| (key == "HOME").then(|| "relative-home".into()),
2033 false
2034 ),
2035 [PathBuf::from("/work/relative-home")]
2036 );
2037 }
2038
2039 #[test]
2040 fn native_proxy_settings_pass_through_without_claiming_authentication() {
2041 for config in [
2042 "proxy=http://proxy.example.test:8080\n",
2043 "https-proxy=http://proxy.example.test:8080\n",
2044 ] {
2045 let analysis = analyze_npmrc(config);
2046 assert!(analysis.proxy, "missed npm proxy: {config}");
2047 assert!(!analysis.auth);
2048 }
2049 let berry = analyze_yaml_conservative(
2050 "httpProxy: http://proxy.example.test:8080\nhttpsProxy: http://proxy.example.test:8080\n",
2051 );
2052 assert!(berry.proxy);
2053 let bun = analyze_bun_toml(
2054 "[install]\nregistry = \"https://registry.npmjs.org/\"\nhttpsProxy = \"http://proxy.example.test:8080\"\n",
2055 )
2056 .unwrap();
2057 assert!(bun.proxy);
2058
2059 for expected in [
2060 "NPM_CONFIG_PROXY",
2061 "npm_config_https_proxy",
2062 "YARN_HTTP_PROXY",
2063 "BUN_CONFIG_PROXY",
2064 ] {
2065 assert_eq!(
2066 global_native_proxy_env(|key| (key == expected).then(|| "secret-proxy".into())),
2067 Some(expected)
2068 );
2069 }
2070 assert_eq!(
2071 global_native_proxy_env(|key| {
2072 matches!(key, "HTTP_PROXY" | "HTTPS_PROXY")
2073 .then(|| "http://environment-proxy".into())
2074 }),
2075 None
2076 );
2077 }
2078
2079 #[test]
2080 fn explicit_and_prefix_derived_npm_global_configs_are_inspected() {
2081 let temp = tempfile::tempdir().unwrap();
2082 let cwd = temp.path().join("project");
2083 let home = temp.path().join("home");
2084 let prefix = temp.path().join("prefix");
2085 let explicit = temp.path().join("explicit/npmrc");
2086 std::fs::create_dir_all(&cwd).unwrap();
2087 std::fs::create_dir_all(&home).unwrap();
2088 std::fs::create_dir_all(prefix.join("etc")).unwrap();
2089 std::fs::create_dir_all(explicit.parent().unwrap()).unwrap();
2090 std::fs::write(
2091 prefix.join("etc/npmrc"),
2092 "//packages.test/:certfile=/secure/client.pem\n",
2093 )
2094 .unwrap();
2095 std::fs::write(&explicit, "cafile=/secure/corporate-ca.pem\n").unwrap();
2096
2097 let home_value = home.display().to_string();
2098 let prefix_value = prefix.display().to_string();
2099 let ctx = test_ctx(temp.path(), Vec::new(), false);
2100 let derived =
2101 native_registry_candidates(&ctx, &cwd, PackageManager::Npm, |key| match key {
2102 "HOME" => Some(home_value.clone()),
2103 "npm_config_prefix" => Some(prefix_value.clone()),
2104 _ => None,
2105 })
2106 .unwrap();
2107 let NativeDecision::PassThrough(reason) = derived else {
2108 panic!("expected derived global config pass-through");
2109 };
2110 assert!(reason.contains("authentication"));
2111 assert!(reason.contains("etc/npmrc"));
2112 assert!(!reason.contains("client.pem"));
2113
2114 let explicit_value = explicit.display().to_string();
2115 let explicit_decision =
2116 native_registry_candidates(&ctx, &cwd, PackageManager::Pnpm, |key| match key {
2117 "HOME" => Some(home_value.clone()),
2118 "NPM_CONFIG_GLOBALCONFIG" => Some(explicit_value.clone()),
2119 _ => None,
2120 })
2121 .unwrap();
2122 let NativeDecision::PassThrough(reason) = explicit_decision else {
2123 panic!("expected explicit global config pass-through");
2124 };
2125 assert!(reason.contains("TLS policy"));
2126 assert!(reason.contains("explicit/npmrc"));
2127 assert!(!reason.contains("corporate-ca.pem"));
2128
2129 let raw_prefix = temp.path().join("raw-prefix");
2130 std::fs::create_dir_all(raw_prefix.join("etc")).unwrap();
2131 std::fs::write(
2132 raw_prefix.join("etc/npmrc"),
2133 "proxy=http://proxy.example.test:8080\n",
2134 )
2135 .unwrap();
2136 let raw_prefix_value = raw_prefix.display().to_string();
2137 let decision =
2138 native_registry_candidates(&ctx, &cwd, PackageManager::Npm, |key| match key {
2139 "HOME" => Some(home_value.clone()),
2140 "PREFIX" => Some(raw_prefix_value.clone()),
2141 _ => None,
2142 })
2143 .unwrap();
2144 assert!(
2145 matches!(decision, NativeDecision::PassThrough(reason) if reason.contains("proxy"))
2146 );
2147 }
2148
2149 #[test]
2150 fn managed_node_default_global_and_builtin_npmrc_are_inspected() {
2151 let temp = tempfile::tempdir().unwrap();
2152 let cwd = temp.path().join("project");
2153 let home = temp.path().join("home");
2154 std::fs::create_dir_all(&cwd).unwrap();
2155 std::fs::create_dir_all(&home).unwrap();
2156 let ctx = test_ctx(temp.path(), Vec::new(), false);
2157 let node = ctx.dirs.install_path("node", "22.0.0");
2158 std::fs::create_dir_all(node.join("etc")).unwrap();
2159 std::fs::create_dir_all(node.join("lib/node_modules/npm")).unwrap();
2160 std::fs::write(node.join(".osdk-complete"), b"").unwrap();
2161 std::fs::write(
2162 node.join("etc/npmrc"),
2163 "proxy=http://proxy.example.test:8080\n",
2164 )
2165 .unwrap();
2166 let home_value = home.display().to_string();
2167 let decision = native_registry_candidates(&ctx, &cwd, PackageManager::Npm, |key| {
2168 (key == "HOME").then(|| home_value.clone())
2169 })
2170 .unwrap();
2171 assert!(
2172 matches!(decision, NativeDecision::PassThrough(reason) if reason.contains("proxy"))
2173 );
2174
2175 std::fs::remove_file(node.join("etc/npmrc")).unwrap();
2176 std::fs::write(
2177 node.join("lib/node_modules/npm/npmrc"),
2178 "//packages.test/:certfile=/secure/client.pem\n",
2179 )
2180 .unwrap();
2181 let decision = native_registry_candidates(&ctx, &cwd, PackageManager::Npm, |key| {
2182 (key == "HOME").then(|| home_value.clone())
2183 })
2184 .unwrap();
2185 assert!(
2186 matches!(decision, NativeDecision::PassThrough(reason) if reason.contains("authentication"))
2187 );
2188 }
2189
2190 #[test]
2191 fn both_home_candidates_are_inspected_and_redirects_pass_through() {
2192 let temp = tempfile::tempdir().unwrap();
2193 let cwd = temp.path().join("project");
2194 let home = temp.path().join("home");
2195 let userprofile = temp.path().join("userprofile");
2196 std::fs::create_dir_all(&cwd).unwrap();
2197 std::fs::create_dir_all(&home).unwrap();
2198 std::fs::create_dir_all(&userprofile).unwrap();
2199 let ctx = test_ctx(temp.path(), Vec::new(), false);
2200 std::fs::write(
2201 userprofile.join(".npmrc"),
2202 "//packages.test/:keyfile=/secure/client.key\n",
2203 )
2204 .unwrap();
2205 let home_value = home.display().to_string();
2206 let userprofile_value = userprofile.display().to_string();
2207 let decision =
2208 native_registry_candidates(&ctx, &cwd, PackageManager::Npm, |key| match key {
2209 "HOME" => Some(home_value.clone()),
2210 "USERPROFILE" => Some(userprofile_value.clone()),
2211 _ => None,
2212 })
2213 .unwrap();
2214 assert!(
2215 matches!(decision, NativeDecision::PassThrough(reason) if reason.contains("authentication"))
2216 );
2217
2218 std::fs::write(home.join(".npmrc"), "globalconfig=${CUSTOM_NPMRC}\n").unwrap();
2219 std::fs::remove_file(userprofile.join(".npmrc")).unwrap();
2220 let decision =
2221 native_registry_candidates(&ctx, &cwd, PackageManager::Npm, |key| match key {
2222 "HOME" => Some(home_value.clone()),
2223 "USERPROFILE" => Some(userprofile_value.clone()),
2224 "CUSTOM_NPMRC" => Some("/not-inspected/npmrc".into()),
2225 _ => None,
2226 })
2227 .unwrap();
2228 assert!(
2229 matches!(decision, NativeDecision::PassThrough(reason) if reason.contains("another config location"))
2230 );
2231 }
2232
2233 #[tokio::test]
2234 async fn failed_primary_falls_back_and_probe_is_anonymous() {
2235 let temp = tempfile::tempdir().unwrap();
2236 let home = temp.path().join("home");
2237 std::fs::create_dir_all(&home).unwrap();
2238 let dead_listener = TcpListener::bind("127.0.0.1:0").unwrap();
2239 let dead = format!("http://{}/", dead_listener.local_addr().unwrap());
2240 let (healthy, request, server) = registry_server(
2241 "200 OK",
2242 r#"{"name":"npm","version":"11.0.0"}"#,
2243 Duration::ZERO,
2244 );
2245 drop(dead_listener);
2246 let ctx = test_ctx(temp.path(), vec![dead.clone(), healthy.clone()], false);
2247 let args = vec!["install".into()];
2248 let home_value = home.display().to_string();
2249 let plan = plan(
2250 &ctx,
2251 temp.path(),
2252 PackageManager::Npm,
2253 "npm",
2254 &args,
2255 |key| (key == "HOME").then(|| home_value.clone()),
2256 )
2257 .await
2258 .unwrap();
2259 let RegistryPlan::Selected { url, probes } = plan else {
2260 panic!("expected selected plan");
2261 };
2262 assert_eq!(url, healthy);
2263 assert_eq!(probes.len(), 2);
2264 assert!(!probes[0].ok);
2265 assert!(probes[1].ok);
2266 let request = request.recv_timeout(Duration::from_secs(3)).unwrap();
2267 let lower = request.to_ascii_lowercase();
2268 assert!(request.starts_with("GET /-/ping HTTP/1.1"), "{request}");
2269 assert!(
2270 lower.contains(&format!("accept: {REGISTRY_PROBE_ACCEPT}\r\n")),
2271 "{request}"
2272 );
2273 assert!(!lower.contains("authorization:"), "{request}");
2274 assert!(!lower.contains("cookie:"), "{request}");
2275 server.join().unwrap();
2276 }
2277
2278 #[tokio::test]
2279 async fn all_failed_candidates_are_unavailable() {
2280 let temp = tempfile::tempdir().unwrap();
2281 let home = temp.path().join("home");
2282 std::fs::create_dir_all(&home).unwrap();
2283 let candidates = vec![unused_loopback_url(), unused_loopback_url()];
2284 let ctx = test_ctx(temp.path(), candidates, false);
2285 let args = vec!["install".into()];
2286 let home_value = home.display().to_string();
2287 let plan = plan(
2288 &ctx,
2289 temp.path(),
2290 PackageManager::Npm,
2291 "npm",
2292 &args,
2293 |key| (key == "HOME").then(|| home_value.clone()),
2294 )
2295 .await
2296 .unwrap();
2297 let RegistryPlan::Unavailable { probes } = plan else {
2298 panic!("expected unavailable plan");
2299 };
2300 assert_eq!(probes.len(), 2);
2301 assert!(probes.iter().all(|probe| !probe.ok));
2302 }
2303
2304 #[tokio::test]
2305 async fn non_object_ping_response_is_not_healthy() {
2306 let (url, _request, server) = registry_server("200 OK", "[]", Duration::ZERO);
2307 let probe = probe_one(&reqwest::Client::new(), url, Duration::from_secs(2)).await;
2308 assert!(!probe.ok);
2309 assert_eq!(
2310 probe.error.as_deref(),
2311 Some("npm registry ping response is not a JSON object")
2312 );
2313 server.join().unwrap();
2314 }
2315
2316 #[tokio::test]
2317 async fn empty_object_ping_response_is_healthy() {
2318 let (url, _request, server) = registry_server("200 OK", "{}", Duration::ZERO);
2319 let probe = probe_one(&reqwest::Client::new(), url, Duration::from_secs(2)).await;
2320 assert!(probe.ok, "{probe:?}");
2321 server.join().unwrap();
2322 }
2323
2324 #[tokio::test]
2325 async fn non_success_status_is_not_healthy() {
2326 let (url, _request, server) = registry_server(
2327 "503 Service Unavailable",
2328 r#"{"name":"npm","version":"11.0.0"}"#,
2329 Duration::ZERO,
2330 );
2331 let probe = probe_one(&reqwest::Client::new(), url, Duration::from_secs(2)).await;
2332 assert!(!probe.ok);
2333 assert_eq!(probe.error.as_deref(), Some("HTTP 503"));
2334 server.join().unwrap();
2335 }
2336
2337 #[tokio::test]
2338 async fn oversized_registry_ping_is_not_healthy() {
2339 let mut body = "{}".to_string();
2340 body.push_str(&" ".repeat(MAX_PROBE_BODY + 1 - body.len()));
2341 let (url, _request, server) = registry_server("200 OK", body, Duration::ZERO);
2342
2343 let probe = probe_one(&reqwest::Client::new(), url, Duration::from_secs(2)).await;
2344
2345 assert!(!probe.ok);
2346 assert_eq!(
2347 probe.error,
2348 Some(format!("response exceeds {MAX_PROBE_BODY} bytes"))
2349 );
2350 server.join().unwrap();
2351 }
2352
2353 #[tokio::test]
2354 async fn registry_probe_does_not_follow_a_cross_origin_loopback_redirect() {
2355 let target = TcpListener::bind("127.0.0.1:0").unwrap();
2356 target.set_nonblocking(true).unwrap();
2357 let location = format!("http://{}/private", target.local_addr().unwrap());
2358 let (url, request, server) = redirect_server(location);
2359 let client = reqwest::Client::builder()
2360 .proxy(reqwest::Proxy::custom(|_| None::<reqwest::Url>))
2361 .redirect(registry_probe_redirect_policy())
2362 .build()
2363 .unwrap();
2364
2365 let probe = probe_one(&client, url, Duration::from_secs(2)).await;
2366 assert!(!probe.ok);
2367 assert!(probe.error.is_some());
2368 let request = request.recv_timeout(Duration::from_secs(3)).unwrap();
2369 assert!(request.starts_with("GET /-/ping HTTP/1.1"), "{request}");
2370 server.join().unwrap();
2371
2372 let error = target.accept().unwrap_err();
2373 assert_eq!(error.kind(), std::io::ErrorKind::WouldBlock);
2374 }
2375
2376 #[cfg(not(windows))]
2377 #[test]
2378 fn registry_probe_honors_http_proxy_without_forwarding_credentials() {
2379 const CHILD_MARKER: &str = "OSDK_REGISTRY_PROXY_TEST_CHILD";
2380 if std::env::var_os(CHILD_MARKER).is_some() {
2381 let runtime = tokio::runtime::Builder::new_current_thread()
2382 .enable_all()
2383 .build()
2384 .unwrap();
2385 let probes =
2386 runtime.block_on(probe_all(&["http://registry-probe.invalid/".into()], 2_000));
2387 assert_eq!(probes.len(), 1);
2388 assert!(probes[0].ok, "{probes:?}");
2389 return;
2390 }
2391
2392 let (proxy, request, server) = registry_server(
2393 "200 OK",
2394 r#"{"name":"npm","version":"11.0.0"}"#,
2395 Duration::ZERO,
2396 );
2397 let output = std::process::Command::new(std::env::current_exe().unwrap())
2398 .args([
2399 "--exact",
2400 "package_registry::tests::registry_probe_honors_http_proxy_without_forwarding_credentials",
2401 "--nocapture",
2402 ])
2403 .env(CHILD_MARKER, "1")
2404 .env("HTTP_PROXY", &proxy)
2405 .env("http_proxy", &proxy)
2406 .env_remove("HTTPS_PROXY")
2407 .env_remove("https_proxy")
2408 .env_remove("ALL_PROXY")
2409 .env_remove("all_proxy")
2410 .env_remove("NO_PROXY")
2411 .env_remove("no_proxy")
2412 .output()
2413 .unwrap();
2414 assert!(
2415 output.status.success(),
2416 "proxy test child failed:\nstdout:\n{}\nstderr:\n{}",
2417 String::from_utf8_lossy(&output.stdout),
2418 String::from_utf8_lossy(&output.stderr)
2419 );
2420
2421 let request = request.recv_timeout(Duration::from_secs(3)).unwrap();
2422 let lower = request.to_ascii_lowercase();
2423 assert!(
2424 request.starts_with("GET http://registry-probe.invalid/-/ping HTTP/1.1"),
2425 "{request}"
2426 );
2427 assert!(!lower.contains("authorization:"), "{request}");
2428 assert!(!lower.contains("cookie:"), "{request}");
2429 server.join().unwrap();
2430 }
2431
2432 #[tokio::test]
2433 async fn user_private_registry_and_offline_mode_do_not_probe() {
2434 let temp = tempfile::tempdir().unwrap();
2435 let home = temp.path().join("home");
2436 std::fs::create_dir_all(&home).unwrap();
2437 std::fs::write(
2438 home.join(".npmrc"),
2439 "registry=https://packages.corp.invalid/\n",
2440 )
2441 .unwrap();
2442 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
2443 listener.set_nonblocking(true).unwrap();
2444 let candidate = format!("http://{}/", listener.local_addr().unwrap());
2445 let args = vec!["install".into()];
2446 let home_value = home.display().to_string();
2447
2448 let ctx = test_ctx(temp.path(), vec![candidate.clone()], false);
2449 let private_plan = plan(
2450 &ctx,
2451 temp.path(),
2452 PackageManager::Npm,
2453 "npm",
2454 &args,
2455 |key| (key == "HOME").then(|| home_value.clone()),
2456 )
2457 .await
2458 .unwrap();
2459 assert!(matches!(private_plan, RegistryPlan::PassThrough { .. }));
2460 assert!(listener.accept().is_err());
2461
2462 std::fs::remove_file(home.join(".npmrc")).unwrap();
2463 let ctx = test_ctx(temp.path(), vec![candidate], true);
2464 let offline_plan = plan(
2465 &ctx,
2466 temp.path(),
2467 PackageManager::Npm,
2468 "npm",
2469 &args,
2470 |key| (key == "HOME").then(|| home_value.clone()),
2471 )
2472 .await
2473 .unwrap();
2474 assert!(matches!(offline_plan, RegistryPlan::PassThrough { .. }));
2475 assert!(listener.accept().is_err());
2476 }
2477
2478 #[tokio::test]
2479 async fn explicit_manager_environment_passes_through() {
2480 let temp = tempfile::tempdir().unwrap();
2481 let ctx = test_ctx(temp.path(), vec![unused_loopback_url()], false);
2482 let args = vec!["install".into()];
2483 let plan = plan(
2484 &ctx,
2485 temp.path(),
2486 PackageManager::Pnpm,
2487 "pnpm",
2488 &args,
2489 |key| {
2490 (key == "pnpm_config_registry")
2491 .then(|| "https://private.test/token-redacted".into())
2492 },
2493 )
2494 .await
2495 .unwrap();
2496 let RegistryPlan::PassThrough { reason } = plan else {
2497 panic!("expected pass-through plan");
2498 };
2499 assert!(reason.contains("pnpm_config_registry"));
2500 assert!(!reason.contains("token-redacted"));
2501 }
2502
2503 fn test_ctx(root: &Path, candidates: Vec<String>, offline: bool) -> Ctx {
2504 let dirs = Dirs::resolve_from(|key| match key {
2505 "OSDK_DATA_DIR" => Some(root.join("data").display().to_string()),
2506 "OSDK_CACHE_DIR" => Some(root.join("cache").display().to_string()),
2507 "OSDK_CONFIG_DIR" => Some(root.join("config").display().to_string()),
2508 "OSDK_STORE_DIR" => Some(root.join("store").display().to_string()),
2509 "OSDK_INSTALL_DIR" => Some(root.join("installs").display().to_string()),
2510 _ => None,
2511 })
2512 .unwrap();
2513 let mut sources = SourcesConfig::default();
2514 sources.registries.npm.urls = candidates;
2515 sources.registries.npm.probe_timeout_ms = 250;
2516 let settings = crate::config::Settings {
2517 offline,
2518 ..Default::default()
2519 };
2520 let config = Config {
2521 settings,
2522 sources,
2523 tools: Default::default(),
2524 tool_configs: Default::default(),
2525 global_tools: Default::default(),
2526 global_tool_configs: Default::default(),
2527 tool_origins: Default::default(),
2528 aliases: Default::default(),
2529 project_config_path: None,
2530 };
2531 let mut headers = HeaderMap::new();
2532 headers.insert(AUTHORIZATION, HeaderValue::from_static("Bearer secret"));
2533 headers.insert(COOKIE, HeaderValue::from_static("session=secret"));
2534 Ctx {
2535 dirs: dirs.clone(),
2536 platform: Platform::current(),
2537 config,
2538 client: reqwest::Client::builder()
2539 .default_headers(headers)
2540 .build()
2541 .unwrap(),
2542 cas: Arc::new(Cas::new(dirs.store)),
2543 show_progress: false,
2544 }
2545 }
2546
2547 fn unused_loopback_url() -> String {
2548 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
2549 let address = listener.local_addr().unwrap();
2550 drop(listener);
2551 format!("http://{address}/")
2552 }
2553
2554 fn registry_server(
2555 status: &'static str,
2556 body: impl Into<String>,
2557 delay: Duration,
2558 ) -> (String, mpsc::Receiver<String>, thread::JoinHandle<()>) {
2559 let body = body.into();
2560 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
2561 listener.set_nonblocking(true).unwrap();
2562 let address = listener.local_addr().unwrap();
2563 let (sender, receiver) = mpsc::channel();
2564 let handle = thread::spawn(move || {
2565 let deadline = Instant::now() + Duration::from_secs(5);
2566 let mut stream = loop {
2567 match listener.accept() {
2568 Ok((stream, _)) => break stream,
2569 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
2570 assert!(Instant::now() < deadline, "no registry probe arrived");
2571 thread::sleep(Duration::from_millis(5));
2572 }
2573 Err(error) => panic!("accepting registry probe: {error}"),
2574 }
2575 };
2576 let request = read_request(&mut stream);
2577 sender.send(request).unwrap();
2578 if !delay.is_zero() {
2579 thread::sleep(delay);
2580 }
2581 write!(
2582 stream,
2583 "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
2584 body.len()
2585 )
2586 .unwrap();
2587 });
2588 (format!("http://{address}/"), receiver, handle)
2589 }
2590
2591 fn redirect_server(
2592 location: String,
2593 ) -> (String, mpsc::Receiver<String>, thread::JoinHandle<()>) {
2594 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
2595 let address = listener.local_addr().unwrap();
2596 let (sender, receiver) = mpsc::channel();
2597 let handle = thread::spawn(move || {
2598 let (mut stream, _) = listener.accept().unwrap();
2599 sender.send(read_request(&mut stream)).unwrap();
2600 write!(
2601 stream,
2602 "HTTP/1.1 302 Found\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
2603 )
2604 .unwrap();
2605 });
2606 (format!("http://{address}/"), receiver, handle)
2607 }
2608
2609 fn read_request(stream: &mut TcpStream) -> String {
2610 stream.set_nonblocking(false).unwrap();
2615 stream
2616 .set_read_timeout(Some(Duration::from_secs(2)))
2617 .unwrap();
2618 let mut bytes = Vec::new();
2619 let mut buffer = [0_u8; 1024];
2620 while !bytes.windows(4).any(|window| window == b"\r\n\r\n") {
2621 let read = stream.read(&mut buffer).unwrap();
2622 assert!(read > 0, "probe closed before sending headers");
2623 bytes.extend_from_slice(&buffer[..read]);
2624 assert!(
2625 bytes.len() < 32 * 1024,
2626 "probe headers are unexpectedly large"
2627 );
2628 }
2629 String::from_utf8(bytes).unwrap()
2630 }
2631}