Skip to main content

crates/aube-registry/src/config/apply.rs

use base64::Engine as _;
use std::path::PathBuf;

use super::env::env_any;
use super::token::sanitize_token_helper;
use super::types::{AuthConfig, NpmConfig, NpmrcSource};
use super::url::{
    is_public_npmjs_url, lookup_by_uri_prefix, normalize_npmrc_uri_key, normalize_registry_url,
    package_scope, registry_uri_key,
};
use super::util::{non_empty, pem_value};

impl NpmConfig {
    /// Register default scope→registry mappings that aube ships with
    /// out of the box. Currently only `@jsr` → <https://npm.jsr.io/>,
    /// which lets `jsr:` specs work without the user touching `.npmrc`.
    /// User-provided `.npmrc` entries win — `apply` has already run by
    /// the time we get here, so we only fill in gaps.
    pub(super) fn apply_builtin_scoped_defaults(&mut self) {
        self.scoped_registries
            .entry(crate::jsr::JSR_NPM_SCOPE.to_string())
            .or_insert_with(|| crate::jsr::JSR_DEFAULT_REGISTRY.to_string());
    }

    /// Fallback-only: populate proxy/no_proxy from the standard
    /// `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY` environment variables
    /// when the `.npmrc` layer didn't already set them. A value from
    /// `.npmrc` wins over env so project configuration stays explicit.
    /// Resolve proxy/no_proxy fields using the same precedence
    /// chain pnpm's config reader applies (see
    /// `config/reader/src/index.ts` lines 559-568 in the pnpm
    /// repo):
    ///
    /// - `httpsProxy` ← `.npmrc httpsProxy` ?? `.npmrc proxy` ??
    ///   env `HTTPS_PROXY`/`https_proxy`
    /// - `httpProxy` ← `.npmrc httpProxy` ?? resolved `httpsProxy`
    ///   ?? env `HTTP_PROXY`/`http_proxy` ?? env `PROXY`/`proxy`
    /// - `noProxy` ← `.npmrc noProxy` ?? env `NO_PROXY`/`no_proxy`
    ///
    /// Note that `httpsProxy` does **not** fall back to
    /// `HTTP_PROXY`: pnpm (and npm) only inherit the HTTP proxy
    /// downward into HTTPS, never upward. The `httpProxy` field
    /// *does* inherit whatever `httpsProxy` resolved to, so a
    /// single `https-proxy=...` line in `.npmrc` configures both.
    pub fn apply_proxy_env(&mut self) {
        if self.https_proxy.is_none() {
            self.https_proxy = self
                .npmrc_proxy
                .clone()
                .or_else(|| env_any(&["HTTPS_PROXY", "https_proxy"]));
        }
        if self.http_proxy.is_none() {
            self.http_proxy = self
                .https_proxy
                .clone()
                .or_else(|| env_any(&["HTTP_PROXY", "http_proxy"]))
                .or_else(|| env_any(&["PROXY", "proxy"]));
        }
        if self.no_proxy.is_none() {
            self.no_proxy = env_any(&["NO_PROXY", "no_proxy"]);
        }
    }

    /// Get the registry URL for a given package name.
    pub fn registry_for(&self, package_name: &str) -> &str {
        if let Some(scope) = package_scope(package_name)
            && let Some(url) = self.scoped_registries.get(&scope.to_lowercase())
        {
            return url;
        }
        &self.registry
    }

    /// True when `package_name` resolves through the public
    /// `registry.npmjs.org` registry. Used by supply-chain gates
    /// (`crates/aube/src/commands/add_supply_chain.rs`) to skip
    /// public-only signals (OSV `MAL-*` advisories, npmjs weekly
    /// downloads) on packages a private/internal registry is the
    /// source of truth for. The default registry being swapped out
    /// (`registry=https://internal.example/`) or a scoped override
    /// (`@myorg:registry=https://...`) both cause this to return
    /// `false` so internal packages don't trip the gates.
    pub fn is_public_npmjs(&self, package_name: &str) -> bool {
        is_public_npmjs_url(self.registry_for(package_name))
    }

    /// Get the auth token for a given registry URL.
    pub fn auth_token_for(&self, registry_url: &str) -> Option<&str> {
        if let Some(auth) = self.registry_config_for(registry_url)
            && let Some(ref token) = auth.auth_token
        {
            return Some(token);
        }
        self.global_auth_token.as_deref()
    }

    pub fn token_helper_for(&self, registry_url: &str) -> Option<&str> {
        self.registry_config_for(registry_url)
            .and_then(|auth| auth.token_helper.as_deref())
    }

    /// Get the basic auth (_auth) for a given registry URL.
    pub fn basic_auth_for(&self, registry_url: &str) -> Option<String> {
        let auth = self.registry_config_for(registry_url)?;
        if let Some(ref a) = auth.auth {
            return Some(a.clone());
        }
        let username = auth.username.as_ref()?;
        let password = auth.password.as_ref()?;
        let password = base64::engine::general_purpose::STANDARD
            .decode(password)
            .ok()?;
        let mut raw = Vec::with_capacity(username.len() + 1 + password.len());
        raw.extend_from_slice(username.as_bytes());
        raw.push(b':');
        raw.extend_from_slice(&password);
        Some(base64::engine::general_purpose::STANDARD.encode(raw))
    }

    pub fn registry_config_for(&self, registry_url: &str) -> Option<&AuthConfig> {
        let uri_key = registry_uri_key(registry_url);
        lookup_by_uri_prefix(&self.auth_by_uri, &uri_key)
    }

    /// Test-only compatibility shim. Production code must go through
    /// `apply_tagged` with real source tags so the subprocess-settings
    /// gate fires correctly. Tests that legitimately emulate a
    /// user-scope-only environment can use this helper to avoid
    /// rewriting every fixture.
    #[cfg(test)]
    pub(super) fn apply(&mut self, entries: Vec<(String, String)>) {
        self.apply_tagged(
            entries
                .into_iter()
                .map(|(k, v)| (NpmrcSource::User, k, v))
                .collect(),
        );
    }

    pub(super) fn apply_tagged(&mut self, entries: Vec<(NpmrcSource, String, String)>) {
        for (source, key, value) in entries {
            if key == "registry" {
                self.registry = normalize_registry_url(&value);
            } else if key == "_authToken" {
                self.global_auth_token = Some(value);
            } else if matches!(
                key.as_str(),
                "https-proxy"
                    | "httpsProxy"
                    | "http-proxy"
                    | "httpProxy"
                    | "proxy"
                    | "noproxy"
                    | "noProxy"
                    | "no-proxy"
            ) {
                // Proxies redirect every registry request through a
                // third party for the rest of the process. A
                // project-committed `.npmrc` must not be able to set
                // that for everyone who clones the repository, same
                // trust gate `strict-ssl` and `tokenHelper` already
                // apply.
                if !source.is_trusted_for_subprocess_settings() {
                    tracing::warn!(
                        code = aube_codes::warnings::WARN_AUBE_UNTRUSTED_PROXY,
                        "ignoring {key} from untrusted source {source:?}: committed `.npmrc` cannot set registry proxies"
                    );
                } else {
                    match key.as_str() {
                        "https-proxy" | "httpsProxy" => {
                            self.https_proxy = non_empty(value);
                        }
                        "http-proxy" | "httpProxy" => {
                            self.http_proxy = non_empty(value);
                        }
                        "proxy" => {
                            // pnpm treats `.npmrc proxy=` as the
                            // fallback source for `httpsProxy` (and,
                            // transitively, `httpProxy`) — not as a
                            // direct alias for `httpProxy`. See the
                            // `apply_proxy_env` resolution chain.
                            self.npmrc_proxy = non_empty(value);
                        }
                        _ => {
                            self.no_proxy = non_empty(value);
                        }
                    }
                }
            } else if matches!(key.as_str(), "strict-ssl" | "strictSsl") {
                if let Some(b) = aube_settings::parse_bool(&value) {
                    // strict-ssl=false kills TLS cert validation for
                    // the whole client. A project-committed .npmrc
                    // must never flip this for the whole install. Only
                    // user or global scope can disable validation.
                    // Same trust gate tokenHelper already uses.
                    if !b && !source.is_trusted_for_subprocess_settings() {
                        tracing::warn!(
                            code = aube_codes::warnings::WARN_AUBE_UNTRUSTED_STRICT_SSL_DISABLE,
                            "ignoring strict-ssl=false: {source:?} source is not trusted (committed `.npmrc` cannot disable TLS validation)"
                        );
                    } else {
                        self.strict_ssl = b;
                    }
                }
            } else if matches!(key.as_str(), "local-address" | "localAddress") {
                match value.trim().parse::<std::net::IpAddr>() {
                    Ok(ip) => self.local_address = Some(ip),
                    Err(e) => tracing::warn!(
                        code = aube_codes::warnings::WARN_AUBE_INVALID_LOCAL_ADDRESS,
                        "ignoring invalid local-address {value:?}: {e}"
                    ),
                }
            } else if key == "maxsockets" {
                match value.trim().parse::<usize>() {
                    Ok(n) if n > 0 => self.max_sockets = Some(n),
                    Ok(_) => tracing::warn!(
                        code = aube_codes::warnings::WARN_AUBE_INVALID_MAXSOCKETS,
                        "ignoring maxsockets=0"
                    ),
                    Err(e) => tracing::warn!(
                        code = aube_codes::warnings::WARN_AUBE_INVALID_MAXSOCKETS,
                        "ignoring invalid maxsockets {value:?}: {e}"
                    ),
                }
            } else if matches!(key.as_str(), "cafile" | "caFile") {
                // Top-level (unscoped) cafile — applies to all registries.
                // Diverges from the URI-scoped form in the `//` block
                // below; both can coexist and stack additively.
                self.cafile = Some(PathBuf::from(value));
            } else if matches!(key.as_str(), "ca" | "ca[]") {
                // Top-level inline PEM, single or array form. npm/pnpm
                // accept repeated `ca[]=...` lines to build up a list;
                // mirror that by pushing instead of replacing.
                self.ca.push(pem_value(value));
            } else if let Some(scope) = key.strip_suffix(":registry") {
                if scope.starts_with('@') {
                    self.scoped_registries
                        .insert(scope.to_lowercase(), normalize_registry_url(&value));
                }
            } else if key.starts_with("//") {
                // URI-specific config: //registry.url/:_authToken=TOKEN
                if let Some((uri, suffix)) = key.rsplit_once(':') {
                    // Normalize so `//host:443/x/` and `//host/x/` collapse
                    // to the same key — matches what `registry_uri_key`
                    // produces on the lookup side after stripping the
                    // scheme's default port.
                    let entry = self
                        .auth_by_uri
                        .entry(normalize_npmrc_uri_key(uri))
                        .or_default();
                    match suffix {
                        "_authToken" => entry.auth_token = Some(value),
                        "_auth" => entry.auth = Some(value),
                        "username" => entry.username = Some(value),
                        "_password" => entry.password = Some(value),
                        "tokenHelper" | "token-helper" => {
                            // CVE-2025-69262 (pnpm GHSA-2phv-j68v-wwqx)
                            // class: `tokenHelper` is spawned as
                            // `sh -c <value>` on unix or `cmd /C
                            // <value>` on Windows at the next authed
                            // registry request. Accept only from
                            // trusted sources and only when the
                            // value parses as a sanitized absolute
                            // path to an interpreter.
                            if !source.is_trusted_for_subprocess_settings() {
                                tracing::warn!(
                                    code = aube_codes::warnings::WARN_AUBE_UNTRUSTED_TOKEN_HELPER,
                                    "ignoring tokenHelper for {uri}: {source:?} source is not trusted for subprocess settings (committed `.npmrc` cannot set this)"
                                );
                                continue;
                            }
                            let Some(sanitized) = sanitize_token_helper(&value) else {
                                tracing::warn!(
                                    code = aube_codes::warnings::WARN_AUBE_INVALID_TOKEN_HELPER,
                                    "ignoring tokenHelper for {uri}: value is not a bare absolute path: {value:?}"
                                );
                                continue;
                            };
                            entry.token_helper = Some(sanitized);
                        }
                        "ca" | "ca[]" => entry.tls.ca.push(pem_value(value)),
                        "cafile" | "caFile" => entry.tls.cafile = Some(PathBuf::from(value)),
                        "cert" => entry.tls.cert = Some(pem_value(value)),
                        "key" => entry.tls.key = Some(pem_value(value)),
                        _ => {} // Ignore unknown suffixes for now
                    }
                }
            }
            // Generic pnpm settings (`auto-install-peers`, etc) are NOT
            // matched here — they're resolved by aube's settings
            // module against the raw entries, using the canonical
            // source list from settings.toml. Add a new branch here
            // only if the key maps to a registry-client concept.
        }
    }
}