Skip to main content

cleanlib_client/
proxy.rs

1//! Per-ecosystem proxy-config emit per [per-ecosystem-proxy-config-emit-format
2//! decision 2026-05-20]. Phase 1 Tier A: npm + pypi + go.
3//!
4//! CLEANLIB-373 + CLEANLIB-374 (cycle-18): extend the accepted-ecosystem list
5//! with `crates` (cargo) and `maven`. The 8-ecosystem catalog the wire supports
6//! today (npm, pypi, go, crates, maven, nuget, rubygems, composer) was already
7//! locked in `cli_matrix` fixtures + the ecosystem-specific crates
8//! (`cleanlib-ecosystem-*`); `config init` was still refusing two of the eight
9//! at the CLI validation layer despite the App backend accepting them.
10//!
11//! Each emit function returns a [`ProxyConfig`] holding the config-blob text +
12//! the canonical local path where the file would be written. Callers
13//! (typically [`cleanlib config init`]) decide whether to write or print.
14
15use std::path::PathBuf;
16
17use thiserror::Error;
18
19/// Locked vocabulary per matrix §8 — ecosystem identifiers are always lowercase.
20///
21/// CLEANLIB-373 + CLEANLIB-374 close: `Crates` and `Maven` join the accepted
22/// set. Both emit shell-snippet form (no `canonical_location`) because the
23/// canonical config file placement is workflow-dependent — cargo per-user
24/// (`~/.cargo/config.toml`) vs per-workspace, maven per-user
25/// (`~/.m2/settings.xml`) vs per-project `mvn -s`. `config init` prints the
26/// snippet + the recommended file path in the header line rather than
27/// silently mutating either default. Sister of the `Ecosystem::Go` shape
28/// which follows the same shell-snippet pattern for GOPROXY / GOAUTH.
29///
30/// CLEANLIB-818 close: `Nuget` joins the accepted set. All four App-side
31/// layers nuget requires were verified live in prod (service index +
32/// `list_versions` enumeration + attested `.nupkg` serve + a provisioned
33/// drain job) — the refusal here had no technical basis, a stale
34/// hard-coded allow-list. `Rubygems` and `Composer` deliberately do NOT
35/// join: per CLEANLIB-818's own explicit scoping, the platform genuinely
36/// does not resolve those two yet (their App-side endpoints 404) — adding
37/// them to this enum would silence a CORRECT refusal and hand a customer a
38/// config that looks right and fails on every restore. That work is
39/// server-side, tracked in CLEANLIB-818's sibling ticket, a different repo.
40/// `Nuget` follows the `Maven` precedent (XML merged into an existing file,
41/// not npm's, since NuGet.Config is XML with the same
42/// mirror-entry + credential-block shape) and the same `canonical_location`-
43/// empty reasoning: NuGet.Config resolution is per-project or per-user
44/// (`~/.nuget/NuGet/NuGet.Config` / `%APPDATA%\NuGet\NuGet.Config`),
45/// workflow-dependent same as cargo/maven.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum Ecosystem {
48    Npm,
49    Pypi,
50    Go,
51    /// CLEANLIB-373 — cargo / crates.io registry (rustaceans).
52    Crates,
53    /// CLEANLIB-374 — Maven Central (JVM: maven/gradle/sbt).
54    Maven,
55    /// CLEANLIB-818 — NuGet (V3 protocol, `.NET`/C#).
56    Nuget,
57}
58
59impl Ecosystem {
60    pub fn parse(s: &str) -> Option<Self> {
61        match s {
62            "npm" => Some(Self::Npm),
63            "pypi" => Some(Self::Pypi),
64            "go" => Some(Self::Go),
65            // CLEANLIB-373 + CLEANLIB-374 — lowercase vocabulary only, matches
66            // the ecosystem identifier the App wire uses (never `cargo` /
67            // `mvn` / `MAVEN` — matrix §8 locks lowercase canonical names).
68            "crates" => Some(Self::Crates),
69            "maven" => Some(Self::Maven),
70            // CLEANLIB-818 — same lowercase-only discipline. `rubygems` and
71            // `composer` are DELIBERATELY absent here — see the enum doc.
72            "nuget" => Some(Self::Nuget),
73            _ => None,
74        }
75    }
76    pub fn as_str(self) -> &'static str {
77        match self {
78            Self::Npm => "npm",
79            Self::Pypi => "pypi",
80            Self::Go => "go",
81            Self::Crates => "crates",
82            Self::Maven => "maven",
83            Self::Nuget => "nuget",
84        }
85    }
86}
87
88impl Ecosystem {
89    pub const ALL: &'static [Ecosystem] = &[
90        Ecosystem::Npm,
91        Ecosystem::Pypi,
92        Ecosystem::Go,
93        Ecosystem::Crates,
94        Ecosystem::Maven,
95        Ecosystem::Nuget,
96    ];
97
98    pub fn supported_list() -> String {
99        Self::ALL
100            .iter()
101            .map(|e| e.as_str())
102            .collect::<Vec<_>>()
103            .join(", ")
104    }
105}
106
107/// Emitted config: blob is the literal file/shell content; canonical_location
108/// is the default write path (or empty for ecosystems without a single
109/// canonical file like Go's `GOPROXY` env).
110#[derive(Debug)]
111pub struct ProxyConfig {
112    pub ecosystem: Ecosystem,
113    pub config_blob: String,
114    pub canonical_location: PathBuf,
115}
116
117#[derive(Debug, Error)]
118pub enum ProxyConfigError {
119    #[error("home directory not discoverable; cannot resolve canonical location for {0:?}")]
120    HomeDirUnavailable(Ecosystem),
121    /// CLEANLIB-129 / Jira CLEANLIB-28: refuse `inline_token=true` when the
122    /// provided `api_key` is missing or empty/whitespace-only. Pre-fix
123    /// behaviour emitted `_authToken=` (empty) → broken `.npmrc`.
124    #[error(
125        "inline_token requires a non-empty api_key (would emit broken auth header for {0:?})"
126    )]
127    InlineTokenEmpty(Ecosystem),
128    /// CLEANLIB-758 · pip does NOT expand `${VAR}` in `pip.conf` (unlike npm
129    /// in `~/.npmrc` or a shell in `GOAUTH`). Emitting the placeholder into
130    /// the pypi index-url produced a pip.conf that 401'd on every request —
131    /// then surfaced as `No matching distribution found` and read to the
132    /// customer as "the catalog is empty". The pypi emitter now requires a
133    /// resolved key so the emitted config actually authenticates; the caller
134    /// hits this error when neither a stored api_key nor `--emit-netrc` is
135    /// available and prompts the customer to `cleanlib login` first.
136    #[error(
137        "pypi config emit requires a resolved API key or --emit-netrc — pip does not \
138         expand ${{CLEANLIBRARY_API_KEY}} in pip.conf, so a placeholder would 401 every \
139         request. Run `cleanlib login --api-key <KEY>` first (or set CLEANLIBRARY_API_KEY \
140         in the env before `cleanlib config init`)."
141    )]
142    PypiRequiresResolvedKey,
143}
144
145/// Options controlling emit shape.
146pub struct EmitOptions {
147    /// Base App endpoint, e.g., `https://cleanapp.clnstrt.dev`. Trailing slash
148    /// is tolerated.
149    pub endpoint: String,
150    /// Optional npm scope (e.g., `@my-org`). Only applies to npm emit.
151    pub scope: Option<String>,
152    /// If `true`, embed `api_key` literal in the emitted config (use for CI
153    /// runners without env-var support). If `false`, emit
154    /// `${CLEANLIBRARY_API_KEY}` shell-expansion for the ecosystems that can
155    /// expand it (npm/go/crates/maven). For pypi the placeholder does not
156    /// work — pip does not expand `${VAR}` in `pip.conf` (CLEANLIB-758) —
157    /// so the pypi emitter always uses the resolved key from `api_key` on
158    /// this path and errors with [`ProxyConfigError::PypiRequiresResolvedKey`]
159    /// when the key is missing (unless [`Self::emit_netrc`] is set).
160    pub inline_token: bool,
161    /// API-key value to embed. For npm/go/crates/maven this is used only when
162    /// `inline_token = true`. For pypi it is used unconditionally (see
163    /// `inline_token` and CLEANLIB-758).
164    pub api_key: Option<String>,
165    /// CLEANLIB-758 (Infra co-review c795783): pypi-only, opt-in via
166    /// `cleanlib config init --ecosystem pypi --emit-netrc`. Emits a
167    /// credential-free `pip.conf` (the URL carries no userinfo) plus a
168    /// companion `~/.netrc` block carrying the API key — pip reads `.netrc`
169    /// natively (no `${VAR}` expansion needed), and moves the secret out of
170    /// `pip.conf` for customers whose policy disallows credentials in
171    /// application configs. Ignored for non-pypi ecosystems.
172    pub emit_netrc: bool,
173}
174
175pub fn emit(ecosystem: Ecosystem, opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
176    // CLEANLIB-129 / Jira CLEANLIB-28 defense-in-depth: even when the CLI
177    // forgot to validate, never let the proxy emit `_authToken=` (empty)
178    // out the back. Sister of the `cleanlib config init --inline-token`
179    // pre-check in `cleanlib-cli/src/commands/config_init.rs`.
180    if opts.inline_token {
181        let has_usable_key = opts
182            .api_key
183            .as_deref()
184            .map(|k| !k.trim().is_empty())
185            .unwrap_or(false);
186        if !has_usable_key {
187            return Err(ProxyConfigError::InlineTokenEmpty(ecosystem));
188        }
189    }
190    match ecosystem {
191        Ecosystem::Npm => emit_npm(opts),
192        Ecosystem::Pypi => emit_pypi(opts),
193        Ecosystem::Go => emit_go(opts),
194        Ecosystem::Crates => emit_crates(opts),
195        Ecosystem::Maven => emit_maven(opts),
196        Ecosystem::Nuget => emit_nuget(opts),
197    }
198}
199
200fn token_expression(opts: &EmitOptions) -> String {
201    if opts.inline_token {
202        // `emit` guarantees `api_key` is Some(non_empty) when
203        // `inline_token=true`; this default is dead code on the success
204        // path and only reachable via direct internal calls.
205        opts.api_key.clone().unwrap_or_default()
206    } else {
207        "${CLEANLIBRARY_API_KEY}".to_string()
208    }
209}
210
211fn endpoint_host(endpoint: &str) -> &str {
212    endpoint
213        .trim_end_matches('/')
214        .trim_start_matches("https://")
215        .trim_start_matches("http://")
216}
217
218fn emit_npm(opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
219    let home = dirs::home_dir().ok_or(ProxyConfigError::HomeDirUnavailable(Ecosystem::Npm))?;
220    let endpoint = opts.endpoint.trim_end_matches('/');
221    let registry_url = format!("{}/npm/", endpoint);
222    let host = endpoint_host(endpoint);
223    let token = token_expression(opts);
224
225    let config_blob = match opts.scope.as_deref() {
226        Some(scope) => format!(
227            "{scope}:registry={registry_url}\n//{host}/npm/:_authToken={token}\nalways-auth=true\n",
228        ),
229        None => format!(
230            "registry={registry_url}\n//{host}/npm/:_authToken={token}\nalways-auth=true\n",
231        ),
232    };
233
234    Ok(ProxyConfig {
235        ecosystem: Ecosystem::Npm,
236        config_blob,
237        canonical_location: home.join(".npmrc"),
238    })
239}
240
241/// Percent-encode a value for the URL userinfo position (RFC 3986). Reserves
242/// only the unreserved set — anything else becomes `%HH`. Small inline
243/// implementation to avoid adding a dep. CleanLibrary keys today are
244/// alphanumeric plus `_` and `-`, so the loop is a no-op on the happy path;
245/// the encoding is defence-in-depth against a future key format that carries
246/// `@`, `:`, `/`, `%`, etc. which would otherwise corrupt the URL split.
247fn percent_encode_userinfo(s: &str) -> String {
248    let mut out = String::with_capacity(s.len());
249    for byte in s.as_bytes() {
250        let c = *byte;
251        if c.is_ascii_alphanumeric() || matches!(c, b'-' | b'_' | b'.' | b'~') {
252            out.push(c as char);
253        } else {
254            out.push_str(&format!("%{:02X}", c));
255        }
256    }
257    out
258}
259
260/// CLEANLIB-758 · emit a pypi proxy config that actually authenticates.
261///
262/// Two branches, per the Infra-agent co-review (c795783):
263///
264/// * Default (URL-userinfo): embed the RESOLVED API key in `index-url`. This
265///   is what pip supports today. Note that pip ≥ 24 emits a deprecation
266///   warning for credentials in URL — the `--emit-netrc` branch is the
267///   deprecation-safe alternative when a customer wants to move now, but
268///   URL-userinfo remains the default because it lives in a single file
269///   (`pip.conf`) that `--write` can create without touching the customer's
270///   `~/.netrc`.
271///
272/// * `--emit-netrc`: emit a CREDENTIAL-FREE `pip.conf` (no userinfo on the
273///   URL) plus a companion `~/.netrc` block carrying the key. pip reads
274///   `.netrc` natively — no `${VAR}` expansion needed — so the file is
275///   loose-coupled from the app config; useful for customers whose policy
276///   disallows credentials in application configs.
277///
278/// The pre-fix path emitted `${CLEANLIBRARY_API_KEY}` in the URL. pip does
279/// not expand `${VAR}` in `pip.conf`, so every install 401'd and surfaced
280/// as `No matching distribution found` — a fake "empty catalog" verdict.
281/// The default emit now requires a resolved key and errors loudly rather
282/// than shipping a config that cannot work.
283fn emit_pypi(opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
284    let home = dirs::home_dir().ok_or(ProxyConfigError::HomeDirUnavailable(Ecosystem::Pypi))?;
285    let endpoint = opts.endpoint.trim_end_matches('/');
286    let host = endpoint_host(endpoint);
287
288    // Extract the resolved key. Trim guards against a shell-injected trailing
289    // newline that would otherwise poison the URL / .netrc line.
290    let api_key = opts
291        .api_key
292        .as_deref()
293        .map(str::trim)
294        .filter(|k| !k.is_empty());
295
296    if opts.emit_netrc {
297        // --emit-netrc branch: credential-free pip.conf + separate .netrc
298        // block. `canonical_location` stays empty (like Go / crates / maven)
299        // because two files can't share one target; the caller prints the
300        // combined snippet with clear per-file headers, and the customer
301        // splits it into `~/.config/pip/pip.conf` + `~/.netrc`.
302        let key = api_key.ok_or(ProxyConfigError::PypiRequiresResolvedKey)?;
303        let pip_conf = format!(
304            "[global]\nindex-url = https://{host}/pypi/simple/\nextra-index-url =\n\n[install]\ntrusted-host = {host}\n",
305        );
306        let netrc_dest = home.join(".netrc");
307        // pip's basic-auth: `<KEY>:` (empty password). `.netrc` requires a
308        // password field, so we emit an explicit empty-value marker (`""`).
309        // Comment header names the destination so the customer knows where
310        // this half goes.
311        let netrc_block = format!(
312            "machine {host}\n  login {key}\n  password \"\"\n",
313        );
314        let config_blob = format!(
315            "# === CleanLibrary pypi proxy (--emit-netrc) ===\n\
316             # Two-file emit: pip.conf carries no credentials; ~/.netrc carries the key.\n\
317             # pip reads ~/.netrc natively — no ${{VAR}} expansion needed (CLEANLIB-758).\n\
318             #\n\
319             # --- write this half to ~/.config/pip/pip.conf ---\n\
320             {pip_conf}\n\
321             # --- append this half to {netrc_dest_display} (chmod 600) ---\n\
322             {netrc_block}",
323            netrc_dest_display = netrc_dest.display(),
324        );
325        return Ok(ProxyConfig {
326            ecosystem: Ecosystem::Pypi,
327            config_blob,
328            // Two-file emit; caller prints the snippet with per-file headers.
329            canonical_location: PathBuf::new(),
330        });
331    }
332
333    // Default URL-userinfo branch. Requires a resolved key — refusing a
334    // known-broken emit is the entire point of CLEANLIB-758.
335    let key = api_key.ok_or(ProxyConfigError::PypiRequiresResolvedKey)?;
336    let encoded_key = percent_encode_userinfo(key);
337    let config_blob = format!(
338        "[global]\nindex-url = https://{encoded_key}@{host}/pypi/simple/\nextra-index-url =\n\n[install]\ntrusted-host = {host}\n",
339    );
340
341    Ok(ProxyConfig {
342        ecosystem: Ecosystem::Pypi,
343        config_blob,
344        canonical_location: home.join(".config").join("pip").join("pip.conf"),
345    })
346}
347
348fn emit_go(opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
349    let endpoint = opts.endpoint.trim_end_matches('/');
350    let token = token_expression(opts);
351
352    // Go has no single canonical config file; emit shell-snippet to be sourced
353    // by ~/.bashrc / ~/.zshrc OR run as `go env -w` invocations.
354    let config_blob = format!(
355        "# CleanLibrary Go proxy — append to your shell config (~/.bashrc, ~/.zshrc, fish config)\n# or run the equivalent `go env -w GOPROXY=...` / `go env -w GOAUTH=...` invocations.\nexport GOPROXY={endpoint}/go,direct\nexport GOAUTH=\"Authorization: Bearer {token}\"\n",
356    );
357
358    Ok(ProxyConfig {
359        ecosystem: Ecosystem::Go,
360        config_blob,
361        // Empty path = no canonical file; caller prints or asks user where to write.
362        canonical_location: PathBuf::new(),
363    })
364}
365
366/// CLEANLIB-373 — cargo / crates.io proxy emit.
367///
368/// Cargo's registry config lives in `~/.cargo/config.toml` (per-user) or
369/// `<workspace>/.cargo/config.toml` (per-workspace). We emit a `[registries]`
370/// entry the user can drop into either — matching the shape cargo documents
371/// at <https://doc.rust-lang.org/cargo/reference/registries.html>. The token
372/// belongs in `~/.cargo/credentials.toml` (never in `config.toml`), so the
373/// snippet also carries the `credentials.toml` block for the same registry
374/// name. Following the `Ecosystem::Go` precedent, `canonical_location` stays
375/// empty because the workspace-vs-user placement is workflow-dependent.
376fn emit_crates(opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
377    let endpoint = opts.endpoint.trim_end_matches('/');
378    let token = token_expression(opts);
379
380    // sparse+ prefix makes cargo use the HTTP protocol (stable since 1.68),
381    // not git — which is what a CleanLibrary registry proxy speaks.
382    let config_blob = format!(
383        "# CleanLibrary cargo (crates.io) proxy\n#\n# Registry entry — add to ~/.cargo/config.toml (per-user) or\n# <workspace>/.cargo/config.toml (per-workspace):\n[registries.cleanlibrary]\nindex = \"sparse+{endpoint}/crates/\"\n\n# Token — MUST live in ~/.cargo/credentials.toml (never config.toml):\n[registries.cleanlibrary]\ntoken = \"Bearer {token}\"\n\n# Then publish/install:  cargo <cmd> --registry cleanlibrary\n",
384    );
385
386    Ok(ProxyConfig {
387        ecosystem: Ecosystem::Crates,
388        config_blob,
389        // Empty path = no canonical file; caller prints or asks user where to write.
390        canonical_location: PathBuf::new(),
391    })
392}
393
394/// CLEANLIB-374 — maven / gradle / sbt proxy emit.
395///
396/// Maven reads `~/.m2/settings.xml` for per-user config, and mirrors + auth
397/// belong there (not in a per-project `pom.xml`). We emit the two blocks the
398/// user drops into their existing `<settings>` element — a `<mirror>` that
399/// diverts every request to the CleanLibrary proxy and a `<server>` that
400/// attaches the Bearer token via the standard Maven HTTP-header
401/// configuration property (`httpHeaders`). Following the `Ecosystem::Go` +
402/// `Ecosystem::Crates` precedent, `canonical_location` stays empty because
403/// the per-project `mvn -s` override case is common enough that we do not
404/// silently mutate `~/.m2/settings.xml`.
405fn emit_maven(opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
406    let endpoint = opts.endpoint.trim_end_matches('/');
407    let token = token_expression(opts);
408
409    let config_blob = format!(
410        "<!-- CleanLibrary Maven proxy — merge into ~/.m2/settings.xml (or a project-scoped -s file) -->\n<!-- <settings> root element assumed to exist. -->\n<mirrors>\n  <mirror>\n    <id>cleanlibrary</id>\n    <name>CleanLibrary Maven mirror</name>\n    <url>{endpoint}/maven/</url>\n    <mirrorOf>*</mirrorOf>\n  </mirror>\n</mirrors>\n<servers>\n  <server>\n    <id>cleanlibrary</id>\n    <configuration>\n      <httpHeaders>\n        <property>\n          <name>Authorization</name>\n          <value>Bearer {token}</value>\n        </property>\n      </httpHeaders>\n    </configuration>\n  </server>\n</servers>\n",
411    );
412
413    Ok(ProxyConfig {
414        ecosystem: Ecosystem::Maven,
415        config_blob,
416        // Empty path = no canonical file; caller prints or asks user where to write.
417        canonical_location: PathBuf::new(),
418    })
419}
420
421/// CLEANLIB-818 — NuGet proxy emit.
422///
423/// NuGet's V3 protocol (the only protocol the live App router speaks —
424/// `cleanlib-ecosystem-nuget`'s router nests `/v3/index.json` +
425/// `/v3-flatcontainer/{id}/index.json` (list_versions) +
426/// `/v3-flatcontainer/{id}/{version}/{filename}` (attested `.nupkg` serve),
427/// matching `cleanlib-app/src/http.rs`'s `/nuget` mount — verified live in
428/// prod ahead of this fix: both the service index and `list_versions`
429/// return real, populated data for known-ingested packages, not a stub)
430/// is discovered from a single service-index URL, added as a
431/// `<packageSources>` entry in `NuGet.Config`. Credentials go in a sister
432/// `<packageSourceCredentials>` block keyed by the SAME source name —
433/// NuGet.Config supports either an encrypted (Windows DPAPI) or cleartext
434/// password; we emit `ClearTextPassword` since the token is a Bearer-style
435/// API key, not an OS-DPAPI-protectable local secret, matching every other
436/// ecosystem here embedding the raw token/placeholder in its own native
437/// credential store. Following the `Ecosystem::Crates` + `Ecosystem::Maven`
438/// precedent, `canonical_location` stays empty — NuGet.Config resolution is
439/// per-project (nearest ancestor directory) or per-user
440/// (`~/.nuget/NuGet/NuGet.Config` / `%APPDATA%\NuGet\NuGet.Config`),
441/// workflow-dependent same as the others.
442fn emit_nuget(opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
443    let endpoint = opts.endpoint.trim_end_matches('/');
444    let token = token_expression(opts);
445
446    let config_blob = format!(
447        "<!-- CleanLibrary NuGet proxy — merge into NuGet.Config (per-project, or\n     ~/.nuget/NuGet/NuGet.Config / %APPDATA%\\NuGet\\NuGet.Config per-user) -->\n<!-- <configuration> root element assumed to exist. -->\n<packageSources>\n  <add key=\"cleanlibrary\" value=\"{endpoint}/nuget/v3/index.json\" />\n</packageSources>\n<packageSourceCredentials>\n  <cleanlibrary>\n    <add key=\"Username\" value=\"cleanlibrary\" />\n    <add key=\"ClearTextPassword\" value=\"{token}\" />\n  </cleanlibrary>\n</packageSourceCredentials>\n",
448    );
449
450    Ok(ProxyConfig {
451        ecosystem: Ecosystem::Nuget,
452        config_blob,
453        // Empty path = no canonical file; caller prints or asks user where to write.
454        canonical_location: PathBuf::new(),
455    })
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461
462    fn opts(endpoint: &str) -> EmitOptions {
463        EmitOptions {
464            endpoint: endpoint.to_string(),
465            scope: None,
466            inline_token: false,
467            api_key: None,
468            emit_netrc: false,
469        }
470    }
471
472    /// CLEANLIB-758 · a pypi emit needs a resolved key on both non-netrc
473    /// branches, unlike npm/go which still work off the shell-expansion
474    /// placeholder. Every existing test that expected the pypi placeholder
475    /// output now belongs on this helper (see the updated `pypi_*` tests).
476    fn opts_with_key(endpoint: &str, key: &str) -> EmitOptions {
477        EmitOptions {
478            endpoint: endpoint.to_string(),
479            scope: None,
480            inline_token: false,
481            api_key: Some(key.to_string()),
482            emit_netrc: false,
483        }
484    }
485
486    #[test]
487    fn ecosystem_parse_vocab_locked() {
488        assert_eq!(Ecosystem::parse("npm"), Some(Ecosystem::Npm));
489        assert_eq!(Ecosystem::parse("pypi"), Some(Ecosystem::Pypi));
490        assert_eq!(Ecosystem::parse("go"), Some(Ecosystem::Go));
491        // CLEANLIB-373 + CLEANLIB-374: crates + maven join the accepted set.
492        assert_eq!(Ecosystem::parse("crates"), Some(Ecosystem::Crates));
493        assert_eq!(Ecosystem::parse("maven"), Some(Ecosystem::Maven));
494        // Matrix §8 forbids these uppercase / off-vocab spellings
495        assert_eq!(Ecosystem::parse("NPM"), None);
496        assert_eq!(Ecosystem::parse("PyPI"), None);
497        assert_eq!(Ecosystem::parse("pip"), None);
498        assert_eq!(Ecosystem::parse("golang"), None);
499        // CLEANLIB-373 + CLEANLIB-374: uppercase / alias spellings still rejected.
500        assert_eq!(Ecosystem::parse("cargo"), None);
501        assert_eq!(Ecosystem::parse("Crates"), None);
502        assert_eq!(Ecosystem::parse("CRATES"), None);
503        assert_eq!(Ecosystem::parse("Maven"), None);
504        assert_eq!(Ecosystem::parse("MAVEN"), None);
505        assert_eq!(Ecosystem::parse("mvn"), None);
506    }
507
508    #[test]
509    fn ecosystem_as_str_roundtrips_lowercase() {
510        // Every accepted ecosystem must round-trip: parse(as_str(e)) == Some(e).
511        // Guards against a future variant added without a lowercase parse arm.
512        for e in Ecosystem::ALL {
513            assert_eq!(Ecosystem::parse(e.as_str()), Some(*e), "roundtrip failed for {:?}", e);
514        }
515    }
516
517    #[test]
518    fn supported_list_includes_crates_and_maven() {
519        // CLEANLIB-373 + CLEANLIB-374 — the human-facing error emitted by
520        // `cleanlib config init` when an ecosystem is unsupported reads
521        // `supported ecosystems: '{supported_list}'`; the list must advertise
522        // the newly-accepted ecosystems so the CLI's error output matches
523        // the CLI's actual accepted set.
524        let list = Ecosystem::supported_list();
525        for expected in &["npm", "pypi", "go", "crates", "maven"] {
526            assert!(
527                list.contains(expected),
528                "supported_list must advertise `{}`; got: {}",
529                expected,
530                list
531            );
532        }
533    }
534
535    #[test]
536    fn npm_emit_shell_expansion_default() {
537        let blob = emit_npm(&opts("https://cleanapp.clnstrt.dev")).unwrap().config_blob;
538        assert!(blob.contains("registry=https://cleanapp.clnstrt.dev/npm/"));
539        assert!(blob.contains("//cleanapp.clnstrt.dev/npm/:_authToken=${CLEANLIBRARY_API_KEY}"));
540        assert!(blob.contains("always-auth=true"));
541    }
542
543    #[test]
544    fn npm_emit_with_scope() {
545        let mut o = opts("https://cleanapp.clnstrt.dev");
546        o.scope = Some("@my-org".to_string());
547        let blob = emit_npm(&o).unwrap().config_blob;
548        assert!(blob.contains("@my-org:registry=https://cleanapp.clnstrt.dev/npm/"));
549    }
550
551    #[test]
552    fn npm_emit_inline_token() {
553        let mut o = opts("https://cleanapp.clnstrt.dev");
554        o.inline_token = true;
555        o.api_key = Some("cs_live_smoke".to_string());
556        let blob = emit_npm(&o).unwrap().config_blob;
557        assert!(blob.contains("_authToken=cs_live_smoke"));
558        assert!(!blob.contains("${CLEANLIBRARY_API_KEY}"));
559    }
560
561    // CLEANLIB-758 · this test pinned the BROKEN default output that shipped
562    // (the placeholder `${CLEANLIBRARY_API_KEY}` in the URL that pip could
563    // not expand). The default now emits the RESOLVED key; the placeholder
564    // form is unreachable on the pypi path. Sister of the CLEANLIB-129
565    // defense-in-depth pattern where `--inline-token` with no key is a hard
566    // error rather than an empty-value emission. Renamed so the test's role
567    // documents the fix, not the defect.
568    #[test]
569    fn pypi_emit_default_embeds_resolved_key_in_url_userinfo() {
570        let blob = emit_pypi(&opts_with_key("https://cleanapp.clnstrt.dev", "cs_live_abc"))
571            .unwrap()
572            .config_blob;
573        assert!(
574            blob.contains("index-url = https://cs_live_abc@cleanapp.clnstrt.dev/pypi/simple/"),
575            "resolved key must land in the URL userinfo; got:\n{blob}"
576        );
577        assert!(blob.contains("trusted-host = cleanapp.clnstrt.dev"));
578        assert!(
579            !blob.contains("${CLEANLIBRARY_API_KEY}"),
580            "pypi emit must never leave the placeholder in the URL — pip cannot expand it"
581        );
582    }
583
584    #[test]
585    fn pypi_emit_percent_encodes_key_with_reserved_characters() {
586        // Defence-in-depth: a future key format with `@` / `:` / `/` would
587        // otherwise corrupt the URL split. The unreserved set today is a
588        // no-op on the alphanumeric+`_-` keys CleanLibrary uses.
589        let blob = emit_pypi(&opts_with_key("https://cleanapp.clnstrt.dev", "k@e:y/1"))
590            .unwrap()
591            .config_blob;
592        // `@` → %40, `:` → %3A, `/` → %2F.
593        assert!(
594            blob.contains("k%40e%3Ay%2F1@cleanapp.clnstrt.dev"),
595            "percent-encode reserved chars in userinfo; got:\n{blob}"
596        );
597    }
598
599    #[test]
600    fn pypi_emit_refuses_default_when_key_missing() {
601        // Default branch without a key would previously emit the
602        // `${CLEANLIBRARY_API_KEY}` placeholder into the URL — 401 on every
603        // pip install and read as "empty catalog" to the customer. The emit
604        // now fails LOUD, naming the fix (login first / --emit-netrc).
605        let err = emit_pypi(&opts("https://cleanapp.clnstrt.dev")).unwrap_err();
606        assert!(matches!(err, ProxyConfigError::PypiRequiresResolvedKey));
607    }
608
609    #[test]
610    fn pypi_emit_refuses_default_on_whitespace_only_key() {
611        let mut o = opts_with_key("https://cleanapp.clnstrt.dev", "   \t\n");
612        // trim reduces the key to empty; must NOT emit `https:// @host/…` .
613        o.api_key = Some("   \t\n".to_string());
614        let err = emit_pypi(&o).unwrap_err();
615        assert!(matches!(err, ProxyConfigError::PypiRequiresResolvedKey));
616    }
617
618    #[test]
619    fn pypi_emit_netrc_branch_produces_credential_free_pip_conf() {
620        let mut o = opts_with_key("https://cleanapp.clnstrt.dev", "cs_live_abc");
621        o.emit_netrc = true;
622        let cfg = emit_pypi(&o).unwrap();
623        let blob = &cfg.config_blob;
624        // pip.conf half — no userinfo on the URL.
625        assert!(
626            blob.contains("index-url = https://cleanapp.clnstrt.dev/pypi/simple/"),
627            "pip.conf half must carry no credentials; got:\n{blob}"
628        );
629        assert!(
630            !blob.contains("@cleanapp.clnstrt.dev"),
631            "pip.conf URL must not carry an @-userinfo; got:\n{blob}"
632        );
633        // .netrc half — machine line + login line carrying the key.
634        assert!(
635            blob.contains("machine cleanapp.clnstrt.dev\n  login cs_live_abc\n"),
636            ".netrc half must carry a machine block with the resolved key; got:\n{blob}"
637        );
638        // No canonical location — the two-file emit is print-only.
639        assert!(
640            cfg.canonical_location.as_os_str().is_empty(),
641            "--emit-netrc must not silently write two files off one canonical target"
642        );
643    }
644
645    #[test]
646    fn pypi_emit_netrc_still_requires_a_key() {
647        // No key + --emit-netrc = still a broken emit (nothing to put in
648        // `login`). The error steers the customer to the same fix.
649        let mut o = opts("https://cleanapp.clnstrt.dev");
650        o.emit_netrc = true;
651        let err = emit_pypi(&o).unwrap_err();
652        assert!(matches!(err, ProxyConfigError::PypiRequiresResolvedKey));
653    }
654
655    #[test]
656    fn pypi_emit_inline_token_true_and_default_produce_same_url_userinfo() {
657        // Pre-fix `inline_token=true` produced `_authToken=<key>` (npm) but
658        // for pypi the "inline_token" and "default" branches converge on
659        // the same URL-userinfo shape now that the default requires a
660        // resolved key. Regression guard so a future refactor doesn't
661        // re-diverge them into two subtly-different URLs.
662        let mut o_inline = opts_with_key("https://cleanapp.clnstrt.dev", "cs_live_abc");
663        o_inline.inline_token = true;
664        let blob_inline = emit_pypi(&o_inline).unwrap().config_blob;
665        let blob_default =
666            emit_pypi(&opts_with_key("https://cleanapp.clnstrt.dev", "cs_live_abc"))
667                .unwrap()
668                .config_blob;
669        assert_eq!(blob_inline, blob_default);
670    }
671
672    #[test]
673    fn go_emit_env_form() {
674        let blob = emit_go(&opts("https://cleanapp.clnstrt.dev")).unwrap().config_blob;
675        assert!(blob.contains("export GOPROXY=https://cleanapp.clnstrt.dev/go,direct"));
676        assert!(blob.contains("export GOAUTH=\"Authorization: Bearer ${CLEANLIBRARY_API_KEY}\""));
677    }
678
679    #[test]
680    fn go_emit_has_no_canonical_location() {
681        let cfg = emit_go(&opts("https://cleanapp.clnstrt.dev")).unwrap();
682        assert!(cfg.canonical_location.as_os_str().is_empty());
683    }
684
685    #[test]
686    fn endpoint_trailing_slash_tolerated() {
687        let blob = emit_npm(&opts("https://cleanapp.clnstrt.dev/")).unwrap().config_blob;
688        // Should NOT have double slash
689        assert!(blob.contains("registry=https://cleanapp.clnstrt.dev/npm/"));
690        assert!(!blob.contains("//npm/"));
691    }
692
693    // CLEANLIB-129 / Jira CLEANLIB-28 — defense-in-depth `emit`-level rejection
694    // of `inline_token=true` when `api_key` is missing or empty/whitespace.
695
696    #[test]
697    fn emit_rejects_inline_token_with_none_api_key() {
698        let mut o = opts("https://cleanapp.clnstrt.dev");
699        o.inline_token = true;
700        o.api_key = None;
701        let err = emit(Ecosystem::Npm, &o).unwrap_err();
702        assert!(matches!(err, ProxyConfigError::InlineTokenEmpty(Ecosystem::Npm)));
703    }
704
705    #[test]
706    fn emit_rejects_inline_token_with_empty_string_api_key() {
707        let mut o = opts("https://cleanapp.clnstrt.dev");
708        o.inline_token = true;
709        o.api_key = Some(String::new());
710        let err = emit(Ecosystem::Pypi, &o).unwrap_err();
711        assert!(matches!(err, ProxyConfigError::InlineTokenEmpty(Ecosystem::Pypi)));
712    }
713
714    #[test]
715    fn emit_rejects_inline_token_with_whitespace_only_api_key() {
716        let mut o = opts("https://cleanapp.clnstrt.dev");
717        o.inline_token = true;
718        o.api_key = Some("   \t\n".to_string());
719        let err = emit(Ecosystem::Go, &o).unwrap_err();
720        assert!(matches!(err, ProxyConfigError::InlineTokenEmpty(Ecosystem::Go)));
721    }
722
723    #[test]
724    fn emit_accepts_inline_token_with_valid_api_key() {
725        let mut o = opts("https://cleanapp.clnstrt.dev");
726        o.inline_token = true;
727        o.api_key = Some("std_001".to_string());
728        let blob = emit(Ecosystem::Npm, &o).unwrap().config_blob;
729        // Verify the legit key reaches the rendered _authToken line.
730        assert!(blob.contains("_authToken=std_001"));
731        // Ensure we did NOT regress to the broken `_authToken=` empty form.
732        assert!(!blob.contains("_authToken=\n"));
733        assert!(!blob.contains("_authToken= "));
734    }
735
736    #[test]
737    fn emit_shell_expansion_path_unaffected_by_empty_key_for_env_expanding_ecosystems() {
738        // When `inline_token=false`, an empty api_key is fine for the
739        // ecosystems whose tooling expands `${CLEANLIBRARY_API_KEY}` from
740        // the environment at runtime (npm reads `${VAR}` in `.npmrc`,
741        // shells expand `GOAUTH`, cargo credentials.toml is read by cargo
742        // in a shell-launched process, maven's `httpHeaders` is templated
743        // by `mvn`).
744        //
745        // pypi is DIFFERENT — pip does NOT expand `${VAR}` in pip.conf, so
746        // a placeholder would 401 (CLEANLIB-758). The pypi emit now
747        // requires a resolved key on this path and is exercised separately
748        // by the `pypi_emit_refuses_default_when_key_missing` guard above.
749        let mut o = opts("https://cleanapp.clnstrt.dev");
750        o.inline_token = false;
751        o.api_key = None;
752        assert!(emit(Ecosystem::Npm, &o).is_ok());
753        assert!(emit(Ecosystem::Go, &o).is_ok());
754        // CLEANLIB-373 + CLEANLIB-374 — the new variants also emit.
755        assert!(emit(Ecosystem::Crates, &o).is_ok());
756        assert!(emit(Ecosystem::Maven, &o).is_ok());
757        // Pypi on this path errors LOUD (see the dedicated test above).
758    }
759
760    // ── CLEANLIB-373 — cargo / crates.io proxy emit ────────────────────────
761
762    #[test]
763    fn crates_emit_registry_url_and_placeholder_token() {
764        let blob = emit_crates(&opts("https://cleanapp.clnstrt.dev")).unwrap().config_blob;
765        // sparse+ HTTP registry index — the wire cargo speaks after 1.68.
766        assert!(
767            blob.contains("index = \"sparse+https://cleanapp.clnstrt.dev/crates/\""),
768            "crates blob missing sparse index; got:\n{blob}"
769        );
770        assert!(
771            blob.contains("[registries.cleanlibrary]"),
772            "crates blob missing [registries.cleanlibrary]; got:\n{blob}"
773        );
774        // Shell-expansion (no --inline-token) → placeholder in the token line.
775        assert!(
776            blob.contains("token = \"Bearer ${CLEANLIBRARY_API_KEY}\""),
777            "crates blob missing token placeholder; got:\n{blob}"
778        );
779    }
780
781    #[test]
782    fn crates_emit_inline_token_embeds_key() {
783        let mut o = opts("https://cleanapp.clnstrt.dev");
784        o.inline_token = true;
785        o.api_key = Some("cs_live_smoke".to_string());
786        let blob = emit_crates(&o).unwrap().config_blob;
787        assert!(
788            blob.contains("token = \"Bearer cs_live_smoke\""),
789            "inline_token must embed the key in the credentials.toml block; got:\n{blob}"
790        );
791        // And never leave the placeholder in place — that would look ok but
792        // silently break auth (sister of the CLEANLIB-129 `_authToken=` empty
793        // regression the npm emit codepath already guards against).
794        assert!(
795            !blob.contains("${CLEANLIBRARY_API_KEY}"),
796            "inline_token blob must NOT retain the placeholder"
797        );
798    }
799
800    #[test]
801    fn crates_emit_has_no_canonical_location() {
802        // Following the Ecosystem::Go precedent — cargo's config placement is
803        // per-user vs per-workspace; the CLI prints the snippet rather than
804        // silently mutating either.
805        let cfg = emit_crates(&opts("https://cleanapp.clnstrt.dev")).unwrap();
806        assert!(cfg.canonical_location.as_os_str().is_empty());
807    }
808
809    // ── CLEANLIB-374 — maven / gradle / sbt proxy emit ─────────────────────
810
811    #[test]
812    fn maven_emit_mirror_url_and_placeholder_token() {
813        let blob = emit_maven(&opts("https://cleanapp.clnstrt.dev")).unwrap().config_blob;
814        assert!(
815            blob.contains("<url>https://cleanapp.clnstrt.dev/maven/</url>"),
816            "maven blob missing mirror URL; got:\n{blob}"
817        );
818        assert!(
819            blob.contains("<mirrorOf>*</mirrorOf>"),
820            "maven blob must divert every repo through the CleanLibrary mirror; got:\n{blob}"
821        );
822        assert!(
823            blob.contains("<value>Bearer ${CLEANLIBRARY_API_KEY}</value>"),
824            "maven blob must carry Bearer placeholder in the Authorization header; got:\n{blob}"
825        );
826    }
827
828    #[test]
829    fn maven_emit_inline_token_embeds_key() {
830        let mut o = opts("https://cleanapp.clnstrt.dev");
831        o.inline_token = true;
832        o.api_key = Some("cs_live_smoke".to_string());
833        let blob = emit_maven(&o).unwrap().config_blob;
834        assert!(
835            blob.contains("<value>Bearer cs_live_smoke</value>"),
836            "inline_token must embed the key in the httpHeaders block; got:\n{blob}"
837        );
838        assert!(
839            !blob.contains("${CLEANLIBRARY_API_KEY}"),
840            "inline_token blob must NOT retain the placeholder"
841        );
842    }
843
844    #[test]
845    fn maven_emit_has_no_canonical_location() {
846        // ~/.m2/settings.xml is common but the per-project `mvn -s` override
847        // pattern is common enough that we do not silently mutate it.
848        let cfg = emit_maven(&opts("https://cleanapp.clnstrt.dev")).unwrap();
849        assert!(cfg.canonical_location.as_os_str().is_empty());
850    }
851
852    #[test]
853    fn crates_and_maven_endpoint_trailing_slash_tolerated() {
854        // Sister of the existing npm test — the trim_end_matches('/') defence
855        // must fire for the new ecosystems too so `--endpoint https://x/`
856        // doesn't produce `//crates/` / `//maven/` in the emitted config.
857        let crates_blob = emit_crates(&opts("https://cleanapp.clnstrt.dev/"))
858            .unwrap()
859            .config_blob;
860        assert!(crates_blob.contains("sparse+https://cleanapp.clnstrt.dev/crates/"));
861        assert!(!crates_blob.contains("//crates/"));
862
863        let maven_blob = emit_maven(&opts("https://cleanapp.clnstrt.dev/"))
864            .unwrap()
865            .config_blob;
866        assert!(maven_blob.contains("<url>https://cleanapp.clnstrt.dev/maven/</url>"));
867        assert!(!maven_blob.contains("//maven/"));
868    }
869
870    // ── CLEANLIB-818 — NuGet proxy emit ────────────────────────────────────
871
872    #[test]
873    fn nuget_parse_accepted_rubygems_composer_still_rejected() {
874        assert_eq!(Ecosystem::parse("nuget"), Some(Ecosystem::Nuget));
875        // These two are DELIBERATELY not accepted — the platform genuinely
876        // does not resolve them yet (see the enum doc); a passing parse
877        // here would be a regression, not progress.
878        assert_eq!(Ecosystem::parse("rubygems"), None);
879        assert_eq!(Ecosystem::parse("composer"), None);
880        // Off-vocab spellings still rejected, same discipline as crates/maven.
881        assert_eq!(Ecosystem::parse("Nuget"), None);
882        assert_eq!(Ecosystem::parse("NUGET"), None);
883        assert_eq!(Ecosystem::parse("nuspec"), None);
884    }
885
886    #[test]
887    fn supported_list_includes_nuget_not_rubygems_or_composer() {
888        let list = Ecosystem::supported_list();
889        assert!(
890            list.contains("nuget"),
891            "supported_list must advertise `nuget`; got: {}",
892            list
893        );
894        assert!(
895            !list.contains("rubygems") && !list.contains("composer"),
896            "supported_list must NOT advertise rubygems/composer — the \
897             platform does not resolve them yet; got: {}",
898            list
899        );
900    }
901
902    #[test]
903    fn nuget_emit_service_index_url_and_credentials_block() {
904        let blob = emit_nuget(&opts("https://cleanapp.clnstrt.dev")).unwrap().config_blob;
905        assert!(
906            blob.contains("value=\"https://cleanapp.clnstrt.dev/nuget/v3/index.json\""),
907            "nuget blob missing the V3 service-index source entry; got:\n{blob}"
908        );
909        assert!(
910            blob.contains("<packageSourceCredentials>"),
911            "nuget blob missing the credentials block; got:\n{blob}"
912        );
913        // Shell-expansion (no --inline-token) -> placeholder in the password.
914        assert!(
915            blob.contains("<add key=\"ClearTextPassword\" value=\"${CLEANLIBRARY_API_KEY}\" />"),
916            "nuget blob must carry the placeholder password by default; got:\n{blob}"
917        );
918    }
919
920    #[test]
921    fn nuget_emit_inline_token_embeds_key() {
922        let mut o = opts("https://cleanapp.clnstrt.dev");
923        o.inline_token = true;
924        o.api_key = Some("cs_live_smoke".to_string());
925        let blob = emit_nuget(&o).unwrap().config_blob;
926        assert!(
927            blob.contains("<add key=\"ClearTextPassword\" value=\"cs_live_smoke\" />"),
928            "inline_token must embed the key in the credentials block; got:\n{blob}"
929        );
930        assert!(
931            !blob.contains("${CLEANLIBRARY_API_KEY}"),
932            "inline_token blob must NOT retain the placeholder"
933        );
934    }
935
936    #[test]
937    fn nuget_emit_has_no_canonical_location() {
938        // Following the Crates/Maven precedent — NuGet.Config placement is
939        // per-project vs per-user; the CLI prints the snippet rather than
940        // silently mutating either.
941        let cfg = emit_nuget(&opts("https://cleanapp.clnstrt.dev")).unwrap();
942        assert!(cfg.canonical_location.as_os_str().is_empty());
943    }
944
945    #[test]
946    fn nuget_emit_endpoint_trailing_slash_tolerated() {
947        let blob = emit_nuget(&opts("https://cleanapp.clnstrt.dev/"))
948            .unwrap()
949            .config_blob;
950        assert!(blob.contains("https://cleanapp.clnstrt.dev/nuget/v3/index.json"));
951        assert!(!blob.contains("//nuget/"));
952    }
953
954    #[test]
955    fn ecosystem_all_matches_what_the_platform_actually_resolves() {
956        // CLEANLIB-818 deliverable 3: this list previously went stale
957        // silently — nuget's four App-side layers were live in prod while
958        // this enum still refused it. Assert against the EXPLICIT verified
959        // set (not `Ecosystem::ALL` compared to itself, which can never
960        // catch a stale list) so a future ecosystem landing server-side
961        // without a corresponding client update fails this test, not a
962        // customer's `config init` run.
963        //
964        // As of CLEANLIB-818: npm/pypi/go/crates/maven/nuget are verified
965        // live end-to-end. rubygems/composer are NOT — their App-side
966        // resolution 404s (sibling ticket, tracked separately) — and must
967        // stay OUT of this set until that lands.
968        const PLATFORM_RESOLVES: &[&str] =
969            &["npm", "pypi", "go", "crates", "maven", "nuget"];
970        let client_accepts: Vec<&str> = Ecosystem::ALL.iter().map(|e| e.as_str()).collect();
971        assert_eq!(
972            client_accepts, PLATFORM_RESOLVES,
973            "Ecosystem::ALL must track exactly what the platform actually \
974             resolves end-to-end, in the same order — update BOTH this \
975             constant and Ecosystem::ALL together, never one alone"
976        );
977    }
978}