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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum Ecosystem {
31    Npm,
32    Pypi,
33    Go,
34    /// CLEANLIB-373 — cargo / crates.io registry (rustaceans).
35    Crates,
36    /// CLEANLIB-374 — Maven Central (JVM: maven/gradle/sbt).
37    Maven,
38}
39
40impl Ecosystem {
41    pub fn parse(s: &str) -> Option<Self> {
42        match s {
43            "npm" => Some(Self::Npm),
44            "pypi" => Some(Self::Pypi),
45            "go" => Some(Self::Go),
46            // CLEANLIB-373 + CLEANLIB-374 — lowercase vocabulary only, matches
47            // the ecosystem identifier the App wire uses (never `cargo` /
48            // `mvn` / `MAVEN` — matrix §8 locks lowercase canonical names).
49            "crates" => Some(Self::Crates),
50            "maven" => Some(Self::Maven),
51            _ => None,
52        }
53    }
54    pub fn as_str(self) -> &'static str {
55        match self {
56            Self::Npm => "npm",
57            Self::Pypi => "pypi",
58            Self::Go => "go",
59            Self::Crates => "crates",
60            Self::Maven => "maven",
61        }
62    }
63}
64
65impl Ecosystem {
66    pub const ALL: &'static [Ecosystem] = &[
67        Ecosystem::Npm,
68        Ecosystem::Pypi,
69        Ecosystem::Go,
70        Ecosystem::Crates,
71        Ecosystem::Maven,
72    ];
73
74    pub fn supported_list() -> String {
75        Self::ALL
76            .iter()
77            .map(|e| e.as_str())
78            .collect::<Vec<_>>()
79            .join(", ")
80    }
81}
82
83/// Emitted config: blob is the literal file/shell content; canonical_location
84/// is the default write path (or empty for ecosystems without a single
85/// canonical file like Go's `GOPROXY` env).
86#[derive(Debug)]
87pub struct ProxyConfig {
88    pub ecosystem: Ecosystem,
89    pub config_blob: String,
90    pub canonical_location: PathBuf,
91}
92
93#[derive(Debug, Error)]
94pub enum ProxyConfigError {
95    #[error("home directory not discoverable; cannot resolve canonical location for {0:?}")]
96    HomeDirUnavailable(Ecosystem),
97    /// CLEANLIB-129 / Jira CLEANLIB-28: refuse `inline_token=true` when the
98    /// provided `api_key` is missing or empty/whitespace-only. Pre-fix
99    /// behaviour emitted `_authToken=` (empty) → broken `.npmrc`.
100    #[error(
101        "inline_token requires a non-empty api_key (would emit broken auth header for {0:?})"
102    )]
103    InlineTokenEmpty(Ecosystem),
104    /// CLEANLIB-758 · pip does NOT expand `${VAR}` in `pip.conf` (unlike npm
105    /// in `~/.npmrc` or a shell in `GOAUTH`). Emitting the placeholder into
106    /// the pypi index-url produced a pip.conf that 401'd on every request —
107    /// then surfaced as `No matching distribution found` and read to the
108    /// customer as "the catalog is empty". The pypi emitter now requires a
109    /// resolved key so the emitted config actually authenticates; the caller
110    /// hits this error when neither a stored api_key nor `--emit-netrc` is
111    /// available and prompts the customer to `cleanlib login` first.
112    #[error(
113        "pypi config emit requires a resolved API key or --emit-netrc — pip does not \
114         expand ${{CLEANLIBRARY_API_KEY}} in pip.conf, so a placeholder would 401 every \
115         request. Run `cleanlib login --api-key <KEY>` first (or set CLEANLIBRARY_API_KEY \
116         in the env before `cleanlib config init`)."
117    )]
118    PypiRequiresResolvedKey,
119}
120
121/// Options controlling emit shape.
122pub struct EmitOptions {
123    /// Base App endpoint, e.g., `https://cleanapp.clnstrt.dev`. Trailing slash
124    /// is tolerated.
125    pub endpoint: String,
126    /// Optional npm scope (e.g., `@my-org`). Only applies to npm emit.
127    pub scope: Option<String>,
128    /// If `true`, embed `api_key` literal in the emitted config (use for CI
129    /// runners without env-var support). If `false`, emit
130    /// `${CLEANLIBRARY_API_KEY}` shell-expansion for the ecosystems that can
131    /// expand it (npm/go/crates/maven). For pypi the placeholder does not
132    /// work — pip does not expand `${VAR}` in `pip.conf` (CLEANLIB-758) —
133    /// so the pypi emitter always uses the resolved key from `api_key` on
134    /// this path and errors with [`ProxyConfigError::PypiRequiresResolvedKey`]
135    /// when the key is missing (unless [`Self::emit_netrc`] is set).
136    pub inline_token: bool,
137    /// API-key value to embed. For npm/go/crates/maven this is used only when
138    /// `inline_token = true`. For pypi it is used unconditionally (see
139    /// `inline_token` and CLEANLIB-758).
140    pub api_key: Option<String>,
141    /// CLEANLIB-758 (Infra co-review c795783): pypi-only, opt-in via
142    /// `cleanlib config init --ecosystem pypi --emit-netrc`. Emits a
143    /// credential-free `pip.conf` (the URL carries no userinfo) plus a
144    /// companion `~/.netrc` block carrying the API key — pip reads `.netrc`
145    /// natively (no `${VAR}` expansion needed), and moves the secret out of
146    /// `pip.conf` for customers whose policy disallows credentials in
147    /// application configs. Ignored for non-pypi ecosystems.
148    pub emit_netrc: bool,
149}
150
151pub fn emit(ecosystem: Ecosystem, opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
152    // CLEANLIB-129 / Jira CLEANLIB-28 defense-in-depth: even when the CLI
153    // forgot to validate, never let the proxy emit `_authToken=` (empty)
154    // out the back. Sister of the `cleanlib config init --inline-token`
155    // pre-check in `cleanlib-cli/src/commands/config_init.rs`.
156    if opts.inline_token {
157        let has_usable_key = opts
158            .api_key
159            .as_deref()
160            .map(|k| !k.trim().is_empty())
161            .unwrap_or(false);
162        if !has_usable_key {
163            return Err(ProxyConfigError::InlineTokenEmpty(ecosystem));
164        }
165    }
166    match ecosystem {
167        Ecosystem::Npm => emit_npm(opts),
168        Ecosystem::Pypi => emit_pypi(opts),
169        Ecosystem::Go => emit_go(opts),
170        Ecosystem::Crates => emit_crates(opts),
171        Ecosystem::Maven => emit_maven(opts),
172    }
173}
174
175fn token_expression(opts: &EmitOptions) -> String {
176    if opts.inline_token {
177        // `emit` guarantees `api_key` is Some(non_empty) when
178        // `inline_token=true`; this default is dead code on the success
179        // path and only reachable via direct internal calls.
180        opts.api_key.clone().unwrap_or_default()
181    } else {
182        "${CLEANLIBRARY_API_KEY}".to_string()
183    }
184}
185
186fn endpoint_host(endpoint: &str) -> &str {
187    endpoint
188        .trim_end_matches('/')
189        .trim_start_matches("https://")
190        .trim_start_matches("http://")
191}
192
193fn emit_npm(opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
194    let home = dirs::home_dir().ok_or(ProxyConfigError::HomeDirUnavailable(Ecosystem::Npm))?;
195    let endpoint = opts.endpoint.trim_end_matches('/');
196    let registry_url = format!("{}/npm/", endpoint);
197    let host = endpoint_host(endpoint);
198    let token = token_expression(opts);
199
200    let config_blob = match opts.scope.as_deref() {
201        Some(scope) => format!(
202            "{scope}:registry={registry_url}\n//{host}/npm/:_authToken={token}\nalways-auth=true\n",
203        ),
204        None => format!(
205            "registry={registry_url}\n//{host}/npm/:_authToken={token}\nalways-auth=true\n",
206        ),
207    };
208
209    Ok(ProxyConfig {
210        ecosystem: Ecosystem::Npm,
211        config_blob,
212        canonical_location: home.join(".npmrc"),
213    })
214}
215
216/// Percent-encode a value for the URL userinfo position (RFC 3986). Reserves
217/// only the unreserved set — anything else becomes `%HH`. Small inline
218/// implementation to avoid adding a dep. CleanLibrary keys today are
219/// alphanumeric plus `_` and `-`, so the loop is a no-op on the happy path;
220/// the encoding is defence-in-depth against a future key format that carries
221/// `@`, `:`, `/`, `%`, etc. which would otherwise corrupt the URL split.
222fn percent_encode_userinfo(s: &str) -> String {
223    let mut out = String::with_capacity(s.len());
224    for byte in s.as_bytes() {
225        let c = *byte;
226        if c.is_ascii_alphanumeric() || matches!(c, b'-' | b'_' | b'.' | b'~') {
227            out.push(c as char);
228        } else {
229            out.push_str(&format!("%{:02X}", c));
230        }
231    }
232    out
233}
234
235/// CLEANLIB-758 · emit a pypi proxy config that actually authenticates.
236///
237/// Two branches, per the Infra-agent co-review (c795783):
238///
239/// * Default (URL-userinfo): embed the RESOLVED API key in `index-url`. This
240///   is what pip supports today. Note that pip ≥ 24 emits a deprecation
241///   warning for credentials in URL — the `--emit-netrc` branch is the
242///   deprecation-safe alternative when a customer wants to move now, but
243///   URL-userinfo remains the default because it lives in a single file
244///   (`pip.conf`) that `--write` can create without touching the customer's
245///   `~/.netrc`.
246///
247/// * `--emit-netrc`: emit a CREDENTIAL-FREE `pip.conf` (no userinfo on the
248///   URL) plus a companion `~/.netrc` block carrying the key. pip reads
249///   `.netrc` natively — no `${VAR}` expansion needed — so the file is
250///   loose-coupled from the app config; useful for customers whose policy
251///   disallows credentials in application configs.
252///
253/// The pre-fix path emitted `${CLEANLIBRARY_API_KEY}` in the URL. pip does
254/// not expand `${VAR}` in `pip.conf`, so every install 401'd and surfaced
255/// as `No matching distribution found` — a fake "empty catalog" verdict.
256/// The default emit now requires a resolved key and errors loudly rather
257/// than shipping a config that cannot work.
258fn emit_pypi(opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
259    let home = dirs::home_dir().ok_or(ProxyConfigError::HomeDirUnavailable(Ecosystem::Pypi))?;
260    let endpoint = opts.endpoint.trim_end_matches('/');
261    let host = endpoint_host(endpoint);
262
263    // Extract the resolved key. Trim guards against a shell-injected trailing
264    // newline that would otherwise poison the URL / .netrc line.
265    let api_key = opts
266        .api_key
267        .as_deref()
268        .map(str::trim)
269        .filter(|k| !k.is_empty());
270
271    if opts.emit_netrc {
272        // --emit-netrc branch: credential-free pip.conf + separate .netrc
273        // block. `canonical_location` stays empty (like Go / crates / maven)
274        // because two files can't share one target; the caller prints the
275        // combined snippet with clear per-file headers, and the customer
276        // splits it into `~/.config/pip/pip.conf` + `~/.netrc`.
277        let key = api_key.ok_or(ProxyConfigError::PypiRequiresResolvedKey)?;
278        let pip_conf = format!(
279            "[global]\nindex-url = https://{host}/pypi/simple/\nextra-index-url =\n\n[install]\ntrusted-host = {host}\n",
280        );
281        let netrc_dest = home.join(".netrc");
282        // pip's basic-auth: `<KEY>:` (empty password). `.netrc` requires a
283        // password field, so we emit an explicit empty-value marker (`""`).
284        // Comment header names the destination so the customer knows where
285        // this half goes.
286        let netrc_block = format!(
287            "machine {host}\n  login {key}\n  password \"\"\n",
288        );
289        let config_blob = format!(
290            "# === CleanLibrary pypi proxy (--emit-netrc) ===\n\
291             # Two-file emit: pip.conf carries no credentials; ~/.netrc carries the key.\n\
292             # pip reads ~/.netrc natively — no ${{VAR}} expansion needed (CLEANLIB-758).\n\
293             #\n\
294             # --- write this half to ~/.config/pip/pip.conf ---\n\
295             {pip_conf}\n\
296             # --- append this half to {netrc_dest_display} (chmod 600) ---\n\
297             {netrc_block}",
298            netrc_dest_display = netrc_dest.display(),
299        );
300        return Ok(ProxyConfig {
301            ecosystem: Ecosystem::Pypi,
302            config_blob,
303            // Two-file emit; caller prints the snippet with per-file headers.
304            canonical_location: PathBuf::new(),
305        });
306    }
307
308    // Default URL-userinfo branch. Requires a resolved key — refusing a
309    // known-broken emit is the entire point of CLEANLIB-758.
310    let key = api_key.ok_or(ProxyConfigError::PypiRequiresResolvedKey)?;
311    let encoded_key = percent_encode_userinfo(key);
312    let config_blob = format!(
313        "[global]\nindex-url = https://{encoded_key}@{host}/pypi/simple/\nextra-index-url =\n\n[install]\ntrusted-host = {host}\n",
314    );
315
316    Ok(ProxyConfig {
317        ecosystem: Ecosystem::Pypi,
318        config_blob,
319        canonical_location: home.join(".config").join("pip").join("pip.conf"),
320    })
321}
322
323fn emit_go(opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
324    let endpoint = opts.endpoint.trim_end_matches('/');
325    let token = token_expression(opts);
326
327    // Go has no single canonical config file; emit shell-snippet to be sourced
328    // by ~/.bashrc / ~/.zshrc OR run as `go env -w` invocations.
329    let config_blob = format!(
330        "# 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",
331    );
332
333    Ok(ProxyConfig {
334        ecosystem: Ecosystem::Go,
335        config_blob,
336        // Empty path = no canonical file; caller prints or asks user where to write.
337        canonical_location: PathBuf::new(),
338    })
339}
340
341/// CLEANLIB-373 — cargo / crates.io proxy emit.
342///
343/// Cargo's registry config lives in `~/.cargo/config.toml` (per-user) or
344/// `<workspace>/.cargo/config.toml` (per-workspace). We emit a `[registries]`
345/// entry the user can drop into either — matching the shape cargo documents
346/// at <https://doc.rust-lang.org/cargo/reference/registries.html>. The token
347/// belongs in `~/.cargo/credentials.toml` (never in `config.toml`), so the
348/// snippet also carries the `credentials.toml` block for the same registry
349/// name. Following the `Ecosystem::Go` precedent, `canonical_location` stays
350/// empty because the workspace-vs-user placement is workflow-dependent.
351fn emit_crates(opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
352    let endpoint = opts.endpoint.trim_end_matches('/');
353    let token = token_expression(opts);
354
355    // sparse+ prefix makes cargo use the HTTP protocol (stable since 1.68),
356    // not git — which is what a CleanLibrary registry proxy speaks.
357    let config_blob = format!(
358        "# 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",
359    );
360
361    Ok(ProxyConfig {
362        ecosystem: Ecosystem::Crates,
363        config_blob,
364        // Empty path = no canonical file; caller prints or asks user where to write.
365        canonical_location: PathBuf::new(),
366    })
367}
368
369/// CLEANLIB-374 — maven / gradle / sbt proxy emit.
370///
371/// Maven reads `~/.m2/settings.xml` for per-user config, and mirrors + auth
372/// belong there (not in a per-project `pom.xml`). We emit the two blocks the
373/// user drops into their existing `<settings>` element — a `<mirror>` that
374/// diverts every request to the CleanLibrary proxy and a `<server>` that
375/// attaches the Bearer token via the standard Maven HTTP-header
376/// configuration property (`httpHeaders`). Following the `Ecosystem::Go` +
377/// `Ecosystem::Crates` precedent, `canonical_location` stays empty because
378/// the per-project `mvn -s` override case is common enough that we do not
379/// silently mutate `~/.m2/settings.xml`.
380fn emit_maven(opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
381    let endpoint = opts.endpoint.trim_end_matches('/');
382    let token = token_expression(opts);
383
384    let config_blob = format!(
385        "<!-- 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",
386    );
387
388    Ok(ProxyConfig {
389        ecosystem: Ecosystem::Maven,
390        config_blob,
391        // Empty path = no canonical file; caller prints or asks user where to write.
392        canonical_location: PathBuf::new(),
393    })
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399
400    fn opts(endpoint: &str) -> EmitOptions {
401        EmitOptions {
402            endpoint: endpoint.to_string(),
403            scope: None,
404            inline_token: false,
405            api_key: None,
406            emit_netrc: false,
407        }
408    }
409
410    /// CLEANLIB-758 · a pypi emit needs a resolved key on both non-netrc
411    /// branches, unlike npm/go which still work off the shell-expansion
412    /// placeholder. Every existing test that expected the pypi placeholder
413    /// output now belongs on this helper (see the updated `pypi_*` tests).
414    fn opts_with_key(endpoint: &str, key: &str) -> EmitOptions {
415        EmitOptions {
416            endpoint: endpoint.to_string(),
417            scope: None,
418            inline_token: false,
419            api_key: Some(key.to_string()),
420            emit_netrc: false,
421        }
422    }
423
424    #[test]
425    fn ecosystem_parse_vocab_locked() {
426        assert_eq!(Ecosystem::parse("npm"), Some(Ecosystem::Npm));
427        assert_eq!(Ecosystem::parse("pypi"), Some(Ecosystem::Pypi));
428        assert_eq!(Ecosystem::parse("go"), Some(Ecosystem::Go));
429        // CLEANLIB-373 + CLEANLIB-374: crates + maven join the accepted set.
430        assert_eq!(Ecosystem::parse("crates"), Some(Ecosystem::Crates));
431        assert_eq!(Ecosystem::parse("maven"), Some(Ecosystem::Maven));
432        // Matrix §8 forbids these uppercase / off-vocab spellings
433        assert_eq!(Ecosystem::parse("NPM"), None);
434        assert_eq!(Ecosystem::parse("PyPI"), None);
435        assert_eq!(Ecosystem::parse("pip"), None);
436        assert_eq!(Ecosystem::parse("golang"), None);
437        // CLEANLIB-373 + CLEANLIB-374: uppercase / alias spellings still rejected.
438        assert_eq!(Ecosystem::parse("cargo"), None);
439        assert_eq!(Ecosystem::parse("Crates"), None);
440        assert_eq!(Ecosystem::parse("CRATES"), None);
441        assert_eq!(Ecosystem::parse("Maven"), None);
442        assert_eq!(Ecosystem::parse("MAVEN"), None);
443        assert_eq!(Ecosystem::parse("mvn"), None);
444    }
445
446    #[test]
447    fn ecosystem_as_str_roundtrips_lowercase() {
448        // Every accepted ecosystem must round-trip: parse(as_str(e)) == Some(e).
449        // Guards against a future variant added without a lowercase parse arm.
450        for e in Ecosystem::ALL {
451            assert_eq!(Ecosystem::parse(e.as_str()), Some(*e), "roundtrip failed for {:?}", e);
452        }
453    }
454
455    #[test]
456    fn supported_list_includes_crates_and_maven() {
457        // CLEANLIB-373 + CLEANLIB-374 — the human-facing error emitted by
458        // `cleanlib config init` when an ecosystem is unsupported reads
459        // `supported ecosystems: '{supported_list}'`; the list must advertise
460        // the newly-accepted ecosystems so the CLI's error output matches
461        // the CLI's actual accepted set.
462        let list = Ecosystem::supported_list();
463        for expected in &["npm", "pypi", "go", "crates", "maven"] {
464            assert!(
465                list.contains(expected),
466                "supported_list must advertise `{}`; got: {}",
467                expected,
468                list
469            );
470        }
471    }
472
473    #[test]
474    fn npm_emit_shell_expansion_default() {
475        let blob = emit_npm(&opts("https://cleanapp.clnstrt.dev")).unwrap().config_blob;
476        assert!(blob.contains("registry=https://cleanapp.clnstrt.dev/npm/"));
477        assert!(blob.contains("//cleanapp.clnstrt.dev/npm/:_authToken=${CLEANLIBRARY_API_KEY}"));
478        assert!(blob.contains("always-auth=true"));
479    }
480
481    #[test]
482    fn npm_emit_with_scope() {
483        let mut o = opts("https://cleanapp.clnstrt.dev");
484        o.scope = Some("@my-org".to_string());
485        let blob = emit_npm(&o).unwrap().config_blob;
486        assert!(blob.contains("@my-org:registry=https://cleanapp.clnstrt.dev/npm/"));
487    }
488
489    #[test]
490    fn npm_emit_inline_token() {
491        let mut o = opts("https://cleanapp.clnstrt.dev");
492        o.inline_token = true;
493        o.api_key = Some("cs_live_smoke".to_string());
494        let blob = emit_npm(&o).unwrap().config_blob;
495        assert!(blob.contains("_authToken=cs_live_smoke"));
496        assert!(!blob.contains("${CLEANLIBRARY_API_KEY}"));
497    }
498
499    // CLEANLIB-758 · this test pinned the BROKEN default output that shipped
500    // (the placeholder `${CLEANLIBRARY_API_KEY}` in the URL that pip could
501    // not expand). The default now emits the RESOLVED key; the placeholder
502    // form is unreachable on the pypi path. Sister of the CLEANLIB-129
503    // defense-in-depth pattern where `--inline-token` with no key is a hard
504    // error rather than an empty-value emission. Renamed so the test's role
505    // documents the fix, not the defect.
506    #[test]
507    fn pypi_emit_default_embeds_resolved_key_in_url_userinfo() {
508        let blob = emit_pypi(&opts_with_key("https://cleanapp.clnstrt.dev", "cs_live_abc"))
509            .unwrap()
510            .config_blob;
511        assert!(
512            blob.contains("index-url = https://cs_live_abc@cleanapp.clnstrt.dev/pypi/simple/"),
513            "resolved key must land in the URL userinfo; got:\n{blob}"
514        );
515        assert!(blob.contains("trusted-host = cleanapp.clnstrt.dev"));
516        assert!(
517            !blob.contains("${CLEANLIBRARY_API_KEY}"),
518            "pypi emit must never leave the placeholder in the URL — pip cannot expand it"
519        );
520    }
521
522    #[test]
523    fn pypi_emit_percent_encodes_key_with_reserved_characters() {
524        // Defence-in-depth: a future key format with `@` / `:` / `/` would
525        // otherwise corrupt the URL split. The unreserved set today is a
526        // no-op on the alphanumeric+`_-` keys CleanLibrary uses.
527        let blob = emit_pypi(&opts_with_key("https://cleanapp.clnstrt.dev", "k@e:y/1"))
528            .unwrap()
529            .config_blob;
530        // `@` → %40, `:` → %3A, `/` → %2F.
531        assert!(
532            blob.contains("k%40e%3Ay%2F1@cleanapp.clnstrt.dev"),
533            "percent-encode reserved chars in userinfo; got:\n{blob}"
534        );
535    }
536
537    #[test]
538    fn pypi_emit_refuses_default_when_key_missing() {
539        // Default branch without a key would previously emit the
540        // `${CLEANLIBRARY_API_KEY}` placeholder into the URL — 401 on every
541        // pip install and read as "empty catalog" to the customer. The emit
542        // now fails LOUD, naming the fix (login first / --emit-netrc).
543        let err = emit_pypi(&opts("https://cleanapp.clnstrt.dev")).unwrap_err();
544        assert!(matches!(err, ProxyConfigError::PypiRequiresResolvedKey));
545    }
546
547    #[test]
548    fn pypi_emit_refuses_default_on_whitespace_only_key() {
549        let mut o = opts_with_key("https://cleanapp.clnstrt.dev", "   \t\n");
550        // trim reduces the key to empty; must NOT emit `https:// @host/…` .
551        o.api_key = Some("   \t\n".to_string());
552        let err = emit_pypi(&o).unwrap_err();
553        assert!(matches!(err, ProxyConfigError::PypiRequiresResolvedKey));
554    }
555
556    #[test]
557    fn pypi_emit_netrc_branch_produces_credential_free_pip_conf() {
558        let mut o = opts_with_key("https://cleanapp.clnstrt.dev", "cs_live_abc");
559        o.emit_netrc = true;
560        let cfg = emit_pypi(&o).unwrap();
561        let blob = &cfg.config_blob;
562        // pip.conf half — no userinfo on the URL.
563        assert!(
564            blob.contains("index-url = https://cleanapp.clnstrt.dev/pypi/simple/"),
565            "pip.conf half must carry no credentials; got:\n{blob}"
566        );
567        assert!(
568            !blob.contains("@cleanapp.clnstrt.dev"),
569            "pip.conf URL must not carry an @-userinfo; got:\n{blob}"
570        );
571        // .netrc half — machine line + login line carrying the key.
572        assert!(
573            blob.contains("machine cleanapp.clnstrt.dev\n  login cs_live_abc\n"),
574            ".netrc half must carry a machine block with the resolved key; got:\n{blob}"
575        );
576        // No canonical location — the two-file emit is print-only.
577        assert!(
578            cfg.canonical_location.as_os_str().is_empty(),
579            "--emit-netrc must not silently write two files off one canonical target"
580        );
581    }
582
583    #[test]
584    fn pypi_emit_netrc_still_requires_a_key() {
585        // No key + --emit-netrc = still a broken emit (nothing to put in
586        // `login`). The error steers the customer to the same fix.
587        let mut o = opts("https://cleanapp.clnstrt.dev");
588        o.emit_netrc = true;
589        let err = emit_pypi(&o).unwrap_err();
590        assert!(matches!(err, ProxyConfigError::PypiRequiresResolvedKey));
591    }
592
593    #[test]
594    fn pypi_emit_inline_token_true_and_default_produce_same_url_userinfo() {
595        // Pre-fix `inline_token=true` produced `_authToken=<key>` (npm) but
596        // for pypi the "inline_token" and "default" branches converge on
597        // the same URL-userinfo shape now that the default requires a
598        // resolved key. Regression guard so a future refactor doesn't
599        // re-diverge them into two subtly-different URLs.
600        let mut o_inline = opts_with_key("https://cleanapp.clnstrt.dev", "cs_live_abc");
601        o_inline.inline_token = true;
602        let blob_inline = emit_pypi(&o_inline).unwrap().config_blob;
603        let blob_default =
604            emit_pypi(&opts_with_key("https://cleanapp.clnstrt.dev", "cs_live_abc"))
605                .unwrap()
606                .config_blob;
607        assert_eq!(blob_inline, blob_default);
608    }
609
610    #[test]
611    fn go_emit_env_form() {
612        let blob = emit_go(&opts("https://cleanapp.clnstrt.dev")).unwrap().config_blob;
613        assert!(blob.contains("export GOPROXY=https://cleanapp.clnstrt.dev/go,direct"));
614        assert!(blob.contains("export GOAUTH=\"Authorization: Bearer ${CLEANLIBRARY_API_KEY}\""));
615    }
616
617    #[test]
618    fn go_emit_has_no_canonical_location() {
619        let cfg = emit_go(&opts("https://cleanapp.clnstrt.dev")).unwrap();
620        assert!(cfg.canonical_location.as_os_str().is_empty());
621    }
622
623    #[test]
624    fn endpoint_trailing_slash_tolerated() {
625        let blob = emit_npm(&opts("https://cleanapp.clnstrt.dev/")).unwrap().config_blob;
626        // Should NOT have double slash
627        assert!(blob.contains("registry=https://cleanapp.clnstrt.dev/npm/"));
628        assert!(!blob.contains("//npm/"));
629    }
630
631    // CLEANLIB-129 / Jira CLEANLIB-28 — defense-in-depth `emit`-level rejection
632    // of `inline_token=true` when `api_key` is missing or empty/whitespace.
633
634    #[test]
635    fn emit_rejects_inline_token_with_none_api_key() {
636        let mut o = opts("https://cleanapp.clnstrt.dev");
637        o.inline_token = true;
638        o.api_key = None;
639        let err = emit(Ecosystem::Npm, &o).unwrap_err();
640        assert!(matches!(err, ProxyConfigError::InlineTokenEmpty(Ecosystem::Npm)));
641    }
642
643    #[test]
644    fn emit_rejects_inline_token_with_empty_string_api_key() {
645        let mut o = opts("https://cleanapp.clnstrt.dev");
646        o.inline_token = true;
647        o.api_key = Some(String::new());
648        let err = emit(Ecosystem::Pypi, &o).unwrap_err();
649        assert!(matches!(err, ProxyConfigError::InlineTokenEmpty(Ecosystem::Pypi)));
650    }
651
652    #[test]
653    fn emit_rejects_inline_token_with_whitespace_only_api_key() {
654        let mut o = opts("https://cleanapp.clnstrt.dev");
655        o.inline_token = true;
656        o.api_key = Some("   \t\n".to_string());
657        let err = emit(Ecosystem::Go, &o).unwrap_err();
658        assert!(matches!(err, ProxyConfigError::InlineTokenEmpty(Ecosystem::Go)));
659    }
660
661    #[test]
662    fn emit_accepts_inline_token_with_valid_api_key() {
663        let mut o = opts("https://cleanapp.clnstrt.dev");
664        o.inline_token = true;
665        o.api_key = Some("std_001".to_string());
666        let blob = emit(Ecosystem::Npm, &o).unwrap().config_blob;
667        // Verify the legit key reaches the rendered _authToken line.
668        assert!(blob.contains("_authToken=std_001"));
669        // Ensure we did NOT regress to the broken `_authToken=` empty form.
670        assert!(!blob.contains("_authToken=\n"));
671        assert!(!blob.contains("_authToken= "));
672    }
673
674    #[test]
675    fn emit_shell_expansion_path_unaffected_by_empty_key_for_env_expanding_ecosystems() {
676        // When `inline_token=false`, an empty api_key is fine for the
677        // ecosystems whose tooling expands `${CLEANLIBRARY_API_KEY}` from
678        // the environment at runtime (npm reads `${VAR}` in `.npmrc`,
679        // shells expand `GOAUTH`, cargo credentials.toml is read by cargo
680        // in a shell-launched process, maven's `httpHeaders` is templated
681        // by `mvn`).
682        //
683        // pypi is DIFFERENT — pip does NOT expand `${VAR}` in pip.conf, so
684        // a placeholder would 401 (CLEANLIB-758). The pypi emit now
685        // requires a resolved key on this path and is exercised separately
686        // by the `pypi_emit_refuses_default_when_key_missing` guard above.
687        let mut o = opts("https://cleanapp.clnstrt.dev");
688        o.inline_token = false;
689        o.api_key = None;
690        assert!(emit(Ecosystem::Npm, &o).is_ok());
691        assert!(emit(Ecosystem::Go, &o).is_ok());
692        // CLEANLIB-373 + CLEANLIB-374 — the new variants also emit.
693        assert!(emit(Ecosystem::Crates, &o).is_ok());
694        assert!(emit(Ecosystem::Maven, &o).is_ok());
695        // Pypi on this path errors LOUD (see the dedicated test above).
696    }
697
698    // ── CLEANLIB-373 — cargo / crates.io proxy emit ────────────────────────
699
700    #[test]
701    fn crates_emit_registry_url_and_placeholder_token() {
702        let blob = emit_crates(&opts("https://cleanapp.clnstrt.dev")).unwrap().config_blob;
703        // sparse+ HTTP registry index — the wire cargo speaks after 1.68.
704        assert!(
705            blob.contains("index = \"sparse+https://cleanapp.clnstrt.dev/crates/\""),
706            "crates blob missing sparse index; got:\n{blob}"
707        );
708        assert!(
709            blob.contains("[registries.cleanlibrary]"),
710            "crates blob missing [registries.cleanlibrary]; got:\n{blob}"
711        );
712        // Shell-expansion (no --inline-token) → placeholder in the token line.
713        assert!(
714            blob.contains("token = \"Bearer ${CLEANLIBRARY_API_KEY}\""),
715            "crates blob missing token placeholder; got:\n{blob}"
716        );
717    }
718
719    #[test]
720    fn crates_emit_inline_token_embeds_key() {
721        let mut o = opts("https://cleanapp.clnstrt.dev");
722        o.inline_token = true;
723        o.api_key = Some("cs_live_smoke".to_string());
724        let blob = emit_crates(&o).unwrap().config_blob;
725        assert!(
726            blob.contains("token = \"Bearer cs_live_smoke\""),
727            "inline_token must embed the key in the credentials.toml block; got:\n{blob}"
728        );
729        // And never leave the placeholder in place — that would look ok but
730        // silently break auth (sister of the CLEANLIB-129 `_authToken=` empty
731        // regression the npm emit codepath already guards against).
732        assert!(
733            !blob.contains("${CLEANLIBRARY_API_KEY}"),
734            "inline_token blob must NOT retain the placeholder"
735        );
736    }
737
738    #[test]
739    fn crates_emit_has_no_canonical_location() {
740        // Following the Ecosystem::Go precedent — cargo's config placement is
741        // per-user vs per-workspace; the CLI prints the snippet rather than
742        // silently mutating either.
743        let cfg = emit_crates(&opts("https://cleanapp.clnstrt.dev")).unwrap();
744        assert!(cfg.canonical_location.as_os_str().is_empty());
745    }
746
747    // ── CLEANLIB-374 — maven / gradle / sbt proxy emit ─────────────────────
748
749    #[test]
750    fn maven_emit_mirror_url_and_placeholder_token() {
751        let blob = emit_maven(&opts("https://cleanapp.clnstrt.dev")).unwrap().config_blob;
752        assert!(
753            blob.contains("<url>https://cleanapp.clnstrt.dev/maven/</url>"),
754            "maven blob missing mirror URL; got:\n{blob}"
755        );
756        assert!(
757            blob.contains("<mirrorOf>*</mirrorOf>"),
758            "maven blob must divert every repo through the CleanLibrary mirror; got:\n{blob}"
759        );
760        assert!(
761            blob.contains("<value>Bearer ${CLEANLIBRARY_API_KEY}</value>"),
762            "maven blob must carry Bearer placeholder in the Authorization header; got:\n{blob}"
763        );
764    }
765
766    #[test]
767    fn maven_emit_inline_token_embeds_key() {
768        let mut o = opts("https://cleanapp.clnstrt.dev");
769        o.inline_token = true;
770        o.api_key = Some("cs_live_smoke".to_string());
771        let blob = emit_maven(&o).unwrap().config_blob;
772        assert!(
773            blob.contains("<value>Bearer cs_live_smoke</value>"),
774            "inline_token must embed the key in the httpHeaders block; got:\n{blob}"
775        );
776        assert!(
777            !blob.contains("${CLEANLIBRARY_API_KEY}"),
778            "inline_token blob must NOT retain the placeholder"
779        );
780    }
781
782    #[test]
783    fn maven_emit_has_no_canonical_location() {
784        // ~/.m2/settings.xml is common but the per-project `mvn -s` override
785        // pattern is common enough that we do not silently mutate it.
786        let cfg = emit_maven(&opts("https://cleanapp.clnstrt.dev")).unwrap();
787        assert!(cfg.canonical_location.as_os_str().is_empty());
788    }
789
790    #[test]
791    fn crates_and_maven_endpoint_trailing_slash_tolerated() {
792        // Sister of the existing npm test — the trim_end_matches('/') defence
793        // must fire for the new ecosystems too so `--endpoint https://x/`
794        // doesn't produce `//crates/` / `//maven/` in the emitted config.
795        let crates_blob = emit_crates(&opts("https://cleanapp.clnstrt.dev/"))
796            .unwrap()
797            .config_blob;
798        assert!(crates_blob.contains("sparse+https://cleanapp.clnstrt.dev/crates/"));
799        assert!(!crates_blob.contains("//crates/"));
800
801        let maven_blob = emit_maven(&opts("https://cleanapp.clnstrt.dev/"))
802            .unwrap()
803            .config_blob;
804        assert!(maven_blob.contains("<url>https://cleanapp.clnstrt.dev/maven/</url>"));
805        assert!(!maven_blob.contains("//maven/"));
806    }
807}