Skip to main content

osdk_core/
package_registry.rs

1//! Safe preflight selection for npm-compatible package registries.
2//!
3//! The planner never executes a package manager. It runs fresh, anonymous
4//! probes before a registry-fetching command starts and returns the one
5//! environment override the caller may apply to that single process.
6
7use 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    /// A bounded, credential-free diagnostic suitable for display.
75    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
92/// Resolve an executable name to a package-manager family. Yarn is only
93/// classified when its major version is known; callers must otherwise pass it
94/// through unchanged.
95pub 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
137/// Environment variable injected into the one package-manager process after a
138/// successful plan. pnpm intentionally uses its own variable; pnpm 11 does not
139/// honor npm_config_registry for this purpose.
140pub 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
151/// Return whether this invocation may resolve or fetch npm packages. This is a
152/// deliberately explicit allow-list so routine commands never make probes.
153pub 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
401/// Return the prefix whose flags are interpreted by the package manager. A
402/// literal separator always ends that prefix. A few launcher-style commands
403/// also hand every argument after their executable/script operand to the
404/// child, even when the separator is omitted.
405fn 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
531/// Build a one-shot registry plan. Each call performs fresh concurrent probes;
532/// the selected URL is the first healthy candidate in configured order.
533pub 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(&registry) 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            // Node's POSIX executable is <root>/bin/node, so npm derives the
926            // global prefix as dirname(dirname(execPath)) == <root>.
927            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            // A single half of an mTLS pair can be completed by another npmrc
1093            // layer. Treat every non-empty identity value as authentication.
1094            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            // CA and certificate-validation settings are not client identity,
1098            // but osdk's anonymous probe cannot faithfully reproduce them.
1099            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            // npm can use these values to load another npmrc. Avoid claiming
1104            // the native configuration is fully inspected when it is not.
1105            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
1418/// Registry probes are anonymous, but following an attacker-controlled
1419/// redirect could still turn them into network-reachability probes. Requiring
1420/// the exact original HTTPS origin on every hop prevents redirects to local or
1421/// internal services, redirects through another public host, and HTTPS
1422/// downgrades without having to trust DNS-based address classification.
1423fn 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        // Accepted sockets can inherit the listener's nonblocking mode on
2611        // Windows/Wine. Return to blocking I/O before applying the bounded
2612        // read timeout so a transient WouldBlock is not treated as a broken
2613        // registry response.
2614        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}