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//! Each emit function returns a [`ProxyConfig`] holding the config-blob text +
5//! the canonical local path where the file would be written. Callers
6//! (typically [`cleanlib config init`]) decide whether to write or print.
7
8use std::path::PathBuf;
9
10use thiserror::Error;
11
12/// Locked vocabulary per matrix §8 — ecosystem identifiers are always lowercase.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum Ecosystem {
15    Npm,
16    Pypi,
17    Go,
18}
19
20impl Ecosystem {
21    pub fn parse(s: &str) -> Option<Self> {
22        match s {
23            "npm" => Some(Self::Npm),
24            "pypi" => Some(Self::Pypi),
25            "go" => Some(Self::Go),
26            _ => None,
27        }
28    }
29    pub fn as_str(self) -> &'static str {
30        match self {
31            Self::Npm => "npm",
32            Self::Pypi => "pypi",
33            Self::Go => "go",
34        }
35    }
36}
37
38/// Emitted config: blob is the literal file/shell content; canonical_location
39/// is the default write path (or empty for ecosystems without a single
40/// canonical file like Go's `GOPROXY` env).
41#[derive(Debug)]
42pub struct ProxyConfig {
43    pub ecosystem: Ecosystem,
44    pub config_blob: String,
45    pub canonical_location: PathBuf,
46}
47
48#[derive(Debug, Error)]
49pub enum ProxyConfigError {
50    #[error("home directory not discoverable; cannot resolve canonical location for {0:?}")]
51    HomeDirUnavailable(Ecosystem),
52    /// CLEANLIB-129 / Jira CLEANLIB-28: refuse `inline_token=true` when the
53    /// provided `api_key` is missing or empty/whitespace-only. Pre-fix
54    /// behaviour emitted `_authToken=` (empty) → broken `.npmrc`.
55    #[error(
56        "inline_token requires a non-empty api_key (would emit broken auth header for {0:?})"
57    )]
58    InlineTokenEmpty(Ecosystem),
59}
60
61/// Options controlling emit shape.
62pub struct EmitOptions {
63    /// Base App endpoint, e.g., `https://cleanapp.clnstrt.dev`. Trailing slash
64    /// is tolerated.
65    pub endpoint: String,
66    /// Optional npm scope (e.g., `@my-org`). Only applies to npm emit.
67    pub scope: Option<String>,
68    /// If `true`, embed `api_key` literal in the emitted config (use for CI
69    /// runners without env-var support). If `false`, emit
70    /// `${CLEANLIBRARY_API_KEY}` shell-expansion (default; preferred).
71    pub inline_token: bool,
72    /// API-key value to embed when `inline_token = true`. Ignored otherwise.
73    pub api_key: Option<String>,
74}
75
76pub fn emit(ecosystem: Ecosystem, opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
77    // CLEANLIB-129 / Jira CLEANLIB-28 defense-in-depth: even when the CLI
78    // forgot to validate, never let the proxy emit `_authToken=` (empty)
79    // out the back. Sister of the `cleanlib config init --inline-token`
80    // pre-check in `cleanlib-cli/src/commands/config_init.rs`.
81    if opts.inline_token {
82        let has_usable_key = opts
83            .api_key
84            .as_deref()
85            .map(|k| !k.trim().is_empty())
86            .unwrap_or(false);
87        if !has_usable_key {
88            return Err(ProxyConfigError::InlineTokenEmpty(ecosystem));
89        }
90    }
91    match ecosystem {
92        Ecosystem::Npm => emit_npm(opts),
93        Ecosystem::Pypi => emit_pypi(opts),
94        Ecosystem::Go => emit_go(opts),
95    }
96}
97
98fn token_expression(opts: &EmitOptions) -> String {
99    if opts.inline_token {
100        // `emit` guarantees `api_key` is Some(non_empty) when
101        // `inline_token=true`; this default is dead code on the success
102        // path and only reachable via direct internal calls.
103        opts.api_key.clone().unwrap_or_default()
104    } else {
105        "${CLEANLIBRARY_API_KEY}".to_string()
106    }
107}
108
109fn endpoint_host(endpoint: &str) -> &str {
110    endpoint
111        .trim_end_matches('/')
112        .trim_start_matches("https://")
113        .trim_start_matches("http://")
114}
115
116fn emit_npm(opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
117    let home = dirs::home_dir().ok_or(ProxyConfigError::HomeDirUnavailable(Ecosystem::Npm))?;
118    let endpoint = opts.endpoint.trim_end_matches('/');
119    let registry_url = format!("{}/npm/", endpoint);
120    let host = endpoint_host(endpoint);
121    let token = token_expression(opts);
122
123    let config_blob = match opts.scope.as_deref() {
124        Some(scope) => format!(
125            "{scope}:registry={registry_url}\n//{host}/npm/:_authToken={token}\nalways-auth=true\n",
126        ),
127        None => format!(
128            "registry={registry_url}\n//{host}/npm/:_authToken={token}\nalways-auth=true\n",
129        ),
130    };
131
132    Ok(ProxyConfig {
133        ecosystem: Ecosystem::Npm,
134        config_blob,
135        canonical_location: home.join(".npmrc"),
136    })
137}
138
139fn emit_pypi(opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
140    let home = dirs::home_dir().ok_or(ProxyConfigError::HomeDirUnavailable(Ecosystem::Pypi))?;
141    let endpoint = opts.endpoint.trim_end_matches('/');
142    let host = endpoint_host(endpoint);
143    let token = token_expression(opts);
144
145    let config_blob = format!(
146        "[global]\nindex-url = https://{token}@{host}/pypi/simple/\nextra-index-url =\n\n[install]\ntrusted-host = {host}\n",
147    );
148
149    Ok(ProxyConfig {
150        ecosystem: Ecosystem::Pypi,
151        config_blob,
152        canonical_location: home.join(".config").join("pip").join("pip.conf"),
153    })
154}
155
156fn emit_go(opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
157    let endpoint = opts.endpoint.trim_end_matches('/');
158    let token = token_expression(opts);
159
160    // Go has no single canonical config file; emit shell-snippet to be sourced
161    // by ~/.bashrc / ~/.zshrc OR run as `go env -w` invocations.
162    let config_blob = format!(
163        "# 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",
164    );
165
166    Ok(ProxyConfig {
167        ecosystem: Ecosystem::Go,
168        config_blob,
169        // Empty path = no canonical file; caller prints or asks user where to write.
170        canonical_location: PathBuf::new(),
171    })
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    fn opts(endpoint: &str) -> EmitOptions {
179        EmitOptions {
180            endpoint: endpoint.to_string(),
181            scope: None,
182            inline_token: false,
183            api_key: None,
184        }
185    }
186
187    #[test]
188    fn ecosystem_parse_vocab_locked() {
189        assert_eq!(Ecosystem::parse("npm"), Some(Ecosystem::Npm));
190        assert_eq!(Ecosystem::parse("pypi"), Some(Ecosystem::Pypi));
191        assert_eq!(Ecosystem::parse("go"), Some(Ecosystem::Go));
192        // Matrix §8 forbids these uppercase / off-vocab spellings
193        assert_eq!(Ecosystem::parse("NPM"), None);
194        assert_eq!(Ecosystem::parse("PyPI"), None);
195        assert_eq!(Ecosystem::parse("pip"), None);
196        assert_eq!(Ecosystem::parse("golang"), None);
197    }
198
199    #[test]
200    fn npm_emit_shell_expansion_default() {
201        let blob = emit_npm(&opts("https://cleanapp.clnstrt.dev")).unwrap().config_blob;
202        assert!(blob.contains("registry=https://cleanapp.clnstrt.dev/npm/"));
203        assert!(blob.contains("//cleanapp.clnstrt.dev/npm/:_authToken=${CLEANLIBRARY_API_KEY}"));
204        assert!(blob.contains("always-auth=true"));
205    }
206
207    #[test]
208    fn npm_emit_with_scope() {
209        let mut o = opts("https://cleanapp.clnstrt.dev");
210        o.scope = Some("@my-org".to_string());
211        let blob = emit_npm(&o).unwrap().config_blob;
212        assert!(blob.contains("@my-org:registry=https://cleanapp.clnstrt.dev/npm/"));
213    }
214
215    #[test]
216    fn npm_emit_inline_token() {
217        let mut o = opts("https://cleanapp.clnstrt.dev");
218        o.inline_token = true;
219        o.api_key = Some("cs_live_smoke".to_string());
220        let blob = emit_npm(&o).unwrap().config_blob;
221        assert!(blob.contains("_authToken=cs_live_smoke"));
222        assert!(!blob.contains("${CLEANLIBRARY_API_KEY}"));
223    }
224
225    #[test]
226    fn pypi_emit_index_url_with_token_in_url() {
227        let blob = emit_pypi(&opts("https://cleanapp.clnstrt.dev")).unwrap().config_blob;
228        // pip's index-url puts the token in the URL userinfo position
229        assert!(blob.contains("index-url = https://${CLEANLIBRARY_API_KEY}@cleanapp.clnstrt.dev/pypi/simple/"));
230        assert!(blob.contains("trusted-host = cleanapp.clnstrt.dev"));
231    }
232
233    #[test]
234    fn go_emit_env_form() {
235        let blob = emit_go(&opts("https://cleanapp.clnstrt.dev")).unwrap().config_blob;
236        assert!(blob.contains("export GOPROXY=https://cleanapp.clnstrt.dev/go,direct"));
237        assert!(blob.contains("export GOAUTH=\"Authorization: Bearer ${CLEANLIBRARY_API_KEY}\""));
238    }
239
240    #[test]
241    fn go_emit_has_no_canonical_location() {
242        let cfg = emit_go(&opts("https://cleanapp.clnstrt.dev")).unwrap();
243        assert!(cfg.canonical_location.as_os_str().is_empty());
244    }
245
246    #[test]
247    fn endpoint_trailing_slash_tolerated() {
248        let blob = emit_npm(&opts("https://cleanapp.clnstrt.dev/")).unwrap().config_blob;
249        // Should NOT have double slash
250        assert!(blob.contains("registry=https://cleanapp.clnstrt.dev/npm/"));
251        assert!(!blob.contains("//npm/"));
252    }
253
254    // CLEANLIB-129 / Jira CLEANLIB-28 — defense-in-depth `emit`-level rejection
255    // of `inline_token=true` when `api_key` is missing or empty/whitespace.
256
257    #[test]
258    fn emit_rejects_inline_token_with_none_api_key() {
259        let mut o = opts("https://cleanapp.clnstrt.dev");
260        o.inline_token = true;
261        o.api_key = None;
262        let err = emit(Ecosystem::Npm, &o).unwrap_err();
263        assert!(matches!(err, ProxyConfigError::InlineTokenEmpty(Ecosystem::Npm)));
264    }
265
266    #[test]
267    fn emit_rejects_inline_token_with_empty_string_api_key() {
268        let mut o = opts("https://cleanapp.clnstrt.dev");
269        o.inline_token = true;
270        o.api_key = Some(String::new());
271        let err = emit(Ecosystem::Pypi, &o).unwrap_err();
272        assert!(matches!(err, ProxyConfigError::InlineTokenEmpty(Ecosystem::Pypi)));
273    }
274
275    #[test]
276    fn emit_rejects_inline_token_with_whitespace_only_api_key() {
277        let mut o = opts("https://cleanapp.clnstrt.dev");
278        o.inline_token = true;
279        o.api_key = Some("   \t\n".to_string());
280        let err = emit(Ecosystem::Go, &o).unwrap_err();
281        assert!(matches!(err, ProxyConfigError::InlineTokenEmpty(Ecosystem::Go)));
282    }
283
284    #[test]
285    fn emit_accepts_inline_token_with_valid_api_key() {
286        let mut o = opts("https://cleanapp.clnstrt.dev");
287        o.inline_token = true;
288        o.api_key = Some("std_001".to_string());
289        let blob = emit(Ecosystem::Npm, &o).unwrap().config_blob;
290        // Verify the legit key reaches the rendered _authToken line.
291        assert!(blob.contains("_authToken=std_001"));
292        // Ensure we did NOT regress to the broken `_authToken=` empty form.
293        assert!(!blob.contains("_authToken=\n"));
294        assert!(!blob.contains("_authToken= "));
295    }
296
297    #[test]
298    fn emit_shell_expansion_path_unaffected_by_empty_key() {
299        // When `inline_token=false`, an empty api_key is fine — the
300        // placeholder resolves at runtime from CLEANLIBRARY_API_KEY env.
301        let mut o = opts("https://cleanapp.clnstrt.dev");
302        o.inline_token = false;
303        o.api_key = None;
304        assert!(emit(Ecosystem::Npm, &o).is_ok());
305        assert!(emit(Ecosystem::Pypi, &o).is_ok());
306        assert!(emit(Ecosystem::Go, &o).is_ok());
307    }
308}