//! Per-ecosystem proxy-config emit per [per-ecosystem-proxy-config-emit-format
//! decision 2026-05-20]. Phase 1 Tier A: npm + pypi + go.
//!
//! CLEANLIB-373 + CLEANLIB-374 (cycle-18): extend the accepted-ecosystem list
//! with `crates` (cargo) and `maven`. The 8-ecosystem catalog the wire supports
//! today (npm, pypi, go, crates, maven, nuget, rubygems, composer) was already
//! locked in `cli_matrix` fixtures + the ecosystem-specific crates
//! (`cleanlib-ecosystem-*`); `config init` was still refusing two of the eight
//! at the CLI validation layer despite the App backend accepting them.
//!
//! Each emit function returns a [`ProxyConfig`] holding the config-blob text +
//! the canonical local path where the file would be written. Callers
//! (typically [`cleanlib config init`]) decide whether to write or print.
use std::path::PathBuf;
use thiserror::Error;
/// Locked vocabulary per matrix §8 — ecosystem identifiers are always lowercase.
///
/// CLEANLIB-373 + CLEANLIB-374 close: `Crates` and `Maven` join the accepted
/// set. Both emit shell-snippet form (no `canonical_location`) because the
/// canonical config file placement is workflow-dependent — cargo per-user
/// (`~/.cargo/config.toml`) vs per-workspace, maven per-user
/// (`~/.m2/settings.xml`) vs per-project `mvn -s`. `config init` prints the
/// snippet + the recommended file path in the header line rather than
/// silently mutating either default. Sister of the `Ecosystem::Go` shape
/// which follows the same shell-snippet pattern for GOPROXY / GOAUTH.
///
/// CLEANLIB-818 close: `Nuget` joins the accepted set. All four App-side
/// layers nuget requires were verified live in prod (service index +
/// `list_versions` enumeration + attested `.nupkg` serve + a provisioned
/// drain job) — the refusal here had no technical basis, a stale
/// hard-coded allow-list. `Rubygems` and `Composer` deliberately do NOT
/// join: per CLEANLIB-818's own explicit scoping, the platform genuinely
/// does not resolve those two yet (their App-side endpoints 404) — adding
/// them to this enum would silence a CORRECT refusal and hand a customer a
/// config that looks right and fails on every restore. That work is
/// server-side, tracked in CLEANLIB-818's sibling ticket, a different repo.
/// `Nuget` follows the `Maven` precedent (XML merged into an existing file,
/// not npm's, since NuGet.Config is XML with the same
/// mirror-entry + credential-block shape) and the same `canonical_location`-
/// empty reasoning: NuGet.Config resolution is per-project or per-user
/// (`~/.nuget/NuGet/NuGet.Config` / `%APPDATA%\NuGet\NuGet.Config`),
/// workflow-dependent same as cargo/maven.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Ecosystem {
Npm,
Pypi,
Go,
/// CLEANLIB-373 — cargo / crates.io registry (rustaceans).
Crates,
/// CLEANLIB-374 — Maven Central (JVM: maven/gradle/sbt).
Maven,
/// CLEANLIB-818 — NuGet (V3 protocol, `.NET`/C#).
Nuget,
}
impl Ecosystem {
pub fn parse(s: &str) -> Option<Self> {
match s {
"npm" => Some(Self::Npm),
"pypi" => Some(Self::Pypi),
"go" => Some(Self::Go),
// CLEANLIB-373 + CLEANLIB-374 — lowercase vocabulary only, matches
// the ecosystem identifier the App wire uses (never `cargo` /
// `mvn` / `MAVEN` — matrix §8 locks lowercase canonical names).
"crates" => Some(Self::Crates),
"maven" => Some(Self::Maven),
// CLEANLIB-818 — same lowercase-only discipline. `rubygems` and
// `composer` are DELIBERATELY absent here — see the enum doc.
"nuget" => Some(Self::Nuget),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Npm => "npm",
Self::Pypi => "pypi",
Self::Go => "go",
Self::Crates => "crates",
Self::Maven => "maven",
Self::Nuget => "nuget",
}
}
}
impl Ecosystem {
pub const ALL: &'static [Ecosystem] = &[
Ecosystem::Npm,
Ecosystem::Pypi,
Ecosystem::Go,
Ecosystem::Crates,
Ecosystem::Maven,
Ecosystem::Nuget,
];
pub fn supported_list() -> String {
Self::ALL
.iter()
.map(|e| e.as_str())
.collect::<Vec<_>>()
.join(", ")
}
}
/// Emitted config: blob is the literal file/shell content; canonical_location
/// is the default write path (or empty for ecosystems without a single
/// canonical file like Go's `GOPROXY` env).
#[derive(Debug)]
pub struct ProxyConfig {
pub ecosystem: Ecosystem,
pub config_blob: String,
pub canonical_location: PathBuf,
}
#[derive(Debug, Error)]
pub enum ProxyConfigError {
#[error("home directory not discoverable; cannot resolve canonical location for {0:?}")]
HomeDirUnavailable(Ecosystem),
/// CLEANLIB-129 / Jira CLEANLIB-28: refuse `inline_token=true` when the
/// provided `api_key` is missing or empty/whitespace-only. Pre-fix
/// behaviour emitted `_authToken=` (empty) → broken `.npmrc`.
#[error(
"inline_token requires a non-empty api_key (would emit broken auth header for {0:?})"
)]
InlineTokenEmpty(Ecosystem),
/// CLEANLIB-758 · pip does NOT expand `${VAR}` in `pip.conf` (unlike npm
/// in `~/.npmrc` or a shell in `GOAUTH`). Emitting the placeholder into
/// the pypi index-url produced a pip.conf that 401'd on every request —
/// then surfaced as `No matching distribution found` and read to the
/// customer as "the catalog is empty". The pypi emitter now requires a
/// resolved key so the emitted config actually authenticates; the caller
/// hits this error when neither a stored api_key nor `--emit-netrc` is
/// available and prompts the customer to `cleanlib login` first.
#[error(
"pypi config emit requires a resolved API key or --emit-netrc — pip does not \
expand ${{CLEANLIBRARY_API_KEY}} in pip.conf, so a placeholder would 401 every \
request. Run `cleanlib login --api-key <KEY>` first (or set CLEANLIBRARY_API_KEY \
in the env before `cleanlib config init`)."
)]
PypiRequiresResolvedKey,
}
/// Options controlling emit shape.
pub struct EmitOptions {
/// Base App endpoint, e.g., `https://cleanapp.clnstrt.dev`. Trailing slash
/// is tolerated.
pub endpoint: String,
/// Optional npm scope (e.g., `@my-org`). Only applies to npm emit.
pub scope: Option<String>,
/// If `true`, embed `api_key` literal in the emitted config (use for CI
/// runners without env-var support). If `false`, emit
/// `${CLEANLIBRARY_API_KEY}` shell-expansion for the ecosystems that can
/// expand it (npm/go/crates/maven). For pypi the placeholder does not
/// work — pip does not expand `${VAR}` in `pip.conf` (CLEANLIB-758) —
/// so the pypi emitter always uses the resolved key from `api_key` on
/// this path and errors with [`ProxyConfigError::PypiRequiresResolvedKey`]
/// when the key is missing (unless [`Self::emit_netrc`] is set).
pub inline_token: bool,
/// API-key value to embed. For npm/go/crates/maven this is used only when
/// `inline_token = true`. For pypi it is used unconditionally (see
/// `inline_token` and CLEANLIB-758).
pub api_key: Option<String>,
/// CLEANLIB-758 (Infra co-review c795783): pypi-only, opt-in via
/// `cleanlib config init --ecosystem pypi --emit-netrc`. Emits a
/// credential-free `pip.conf` (the URL carries no userinfo) plus a
/// companion `~/.netrc` block carrying the API key — pip reads `.netrc`
/// natively (no `${VAR}` expansion needed), and moves the secret out of
/// `pip.conf` for customers whose policy disallows credentials in
/// application configs. Ignored for non-pypi ecosystems.
pub emit_netrc: bool,
}
pub fn emit(ecosystem: Ecosystem, opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
// CLEANLIB-129 / Jira CLEANLIB-28 defense-in-depth: even when the CLI
// forgot to validate, never let the proxy emit `_authToken=` (empty)
// out the back. Sister of the `cleanlib config init --inline-token`
// pre-check in `cleanlib-cli/src/commands/config_init.rs`.
if opts.inline_token {
let has_usable_key = opts
.api_key
.as_deref()
.map(|k| !k.trim().is_empty())
.unwrap_or(false);
if !has_usable_key {
return Err(ProxyConfigError::InlineTokenEmpty(ecosystem));
}
}
match ecosystem {
Ecosystem::Npm => emit_npm(opts),
Ecosystem::Pypi => emit_pypi(opts),
Ecosystem::Go => emit_go(opts),
Ecosystem::Crates => emit_crates(opts),
Ecosystem::Maven => emit_maven(opts),
Ecosystem::Nuget => emit_nuget(opts),
}
}
fn token_expression(opts: &EmitOptions) -> String {
if opts.inline_token {
// `emit` guarantees `api_key` is Some(non_empty) when
// `inline_token=true`; this default is dead code on the success
// path and only reachable via direct internal calls.
opts.api_key.clone().unwrap_or_default()
} else {
"${CLEANLIBRARY_API_KEY}".to_string()
}
}
fn endpoint_host(endpoint: &str) -> &str {
endpoint
.trim_end_matches('/')
.trim_start_matches("https://")
.trim_start_matches("http://")
}
fn emit_npm(opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
let home = dirs::home_dir().ok_or(ProxyConfigError::HomeDirUnavailable(Ecosystem::Npm))?;
let endpoint = opts.endpoint.trim_end_matches('/');
let registry_url = format!("{}/npm/", endpoint);
let host = endpoint_host(endpoint);
let token = token_expression(opts);
let config_blob = match opts.scope.as_deref() {
Some(scope) => format!(
"{scope}:registry={registry_url}\n//{host}/npm/:_authToken={token}\nalways-auth=true\n",
),
None => format!(
"registry={registry_url}\n//{host}/npm/:_authToken={token}\nalways-auth=true\n",
),
};
Ok(ProxyConfig {
ecosystem: Ecosystem::Npm,
config_blob,
canonical_location: home.join(".npmrc"),
})
}
/// Percent-encode a value for the URL userinfo position (RFC 3986). Reserves
/// only the unreserved set — anything else becomes `%HH`. Small inline
/// implementation to avoid adding a dep. CleanLibrary keys today are
/// alphanumeric plus `_` and `-`, so the loop is a no-op on the happy path;
/// the encoding is defence-in-depth against a future key format that carries
/// `@`, `:`, `/`, `%`, etc. which would otherwise corrupt the URL split.
fn percent_encode_userinfo(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for byte in s.as_bytes() {
let c = *byte;
if c.is_ascii_alphanumeric() || matches!(c, b'-' | b'_' | b'.' | b'~') {
out.push(c as char);
} else {
out.push_str(&format!("%{:02X}", c));
}
}
out
}
/// CLEANLIB-758 · emit a pypi proxy config that actually authenticates.
///
/// Two branches, per the Infra-agent co-review (c795783):
///
/// * Default (URL-userinfo): embed the RESOLVED API key in `index-url`. This
/// is what pip supports today. Note that pip ≥ 24 emits a deprecation
/// warning for credentials in URL — the `--emit-netrc` branch is the
/// deprecation-safe alternative when a customer wants to move now, but
/// URL-userinfo remains the default because it lives in a single file
/// (`pip.conf`) that `--write` can create without touching the customer's
/// `~/.netrc`.
///
/// * `--emit-netrc`: emit a CREDENTIAL-FREE `pip.conf` (no userinfo on the
/// URL) plus a companion `~/.netrc` block carrying the key. pip reads
/// `.netrc` natively — no `${VAR}` expansion needed — so the file is
/// loose-coupled from the app config; useful for customers whose policy
/// disallows credentials in application configs.
///
/// The pre-fix path emitted `${CLEANLIBRARY_API_KEY}` in the URL. pip does
/// not expand `${VAR}` in `pip.conf`, so every install 401'd and surfaced
/// as `No matching distribution found` — a fake "empty catalog" verdict.
/// The default emit now requires a resolved key and errors loudly rather
/// than shipping a config that cannot work.
fn emit_pypi(opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
let home = dirs::home_dir().ok_or(ProxyConfigError::HomeDirUnavailable(Ecosystem::Pypi))?;
let endpoint = opts.endpoint.trim_end_matches('/');
let host = endpoint_host(endpoint);
// Extract the resolved key. Trim guards against a shell-injected trailing
// newline that would otherwise poison the URL / .netrc line.
let api_key = opts
.api_key
.as_deref()
.map(str::trim)
.filter(|k| !k.is_empty());
if opts.emit_netrc {
// --emit-netrc branch: credential-free pip.conf + separate .netrc
// block. `canonical_location` stays empty (like Go / crates / maven)
// because two files can't share one target; the caller prints the
// combined snippet with clear per-file headers, and the customer
// splits it into `~/.config/pip/pip.conf` + `~/.netrc`.
let key = api_key.ok_or(ProxyConfigError::PypiRequiresResolvedKey)?;
let pip_conf = format!(
"[global]\nindex-url = https://{host}/pypi/simple/\nextra-index-url =\n\n[install]\ntrusted-host = {host}\n",
);
let netrc_dest = home.join(".netrc");
// pip's basic-auth: `<KEY>:` (empty password). `.netrc` requires a
// password field, so we emit an explicit empty-value marker (`""`).
// Comment header names the destination so the customer knows where
// this half goes.
let netrc_block = format!(
"machine {host}\n login {key}\n password \"\"\n",
);
let config_blob = format!(
"# === CleanLibrary pypi proxy (--emit-netrc) ===\n\
# Two-file emit: pip.conf carries no credentials; ~/.netrc carries the key.\n\
# pip reads ~/.netrc natively — no ${{VAR}} expansion needed (CLEANLIB-758).\n\
#\n\
# --- write this half to ~/.config/pip/pip.conf ---\n\
{pip_conf}\n\
# --- append this half to {netrc_dest_display} (chmod 600) ---\n\
{netrc_block}",
netrc_dest_display = netrc_dest.display(),
);
return Ok(ProxyConfig {
ecosystem: Ecosystem::Pypi,
config_blob,
// Two-file emit; caller prints the snippet with per-file headers.
canonical_location: PathBuf::new(),
});
}
// Default URL-userinfo branch. Requires a resolved key — refusing a
// known-broken emit is the entire point of CLEANLIB-758.
let key = api_key.ok_or(ProxyConfigError::PypiRequiresResolvedKey)?;
let encoded_key = percent_encode_userinfo(key);
let config_blob = format!(
"[global]\nindex-url = https://{encoded_key}@{host}/pypi/simple/\nextra-index-url =\n\n[install]\ntrusted-host = {host}\n",
);
Ok(ProxyConfig {
ecosystem: Ecosystem::Pypi,
config_blob,
canonical_location: home.join(".config").join("pip").join("pip.conf"),
})
}
fn emit_go(opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
let endpoint = opts.endpoint.trim_end_matches('/');
let token = token_expression(opts);
// Go has no single canonical config file; emit shell-snippet to be sourced
// by ~/.bashrc / ~/.zshrc OR run as `go env -w` invocations.
let config_blob = format!(
"# 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",
);
Ok(ProxyConfig {
ecosystem: Ecosystem::Go,
config_blob,
// Empty path = no canonical file; caller prints or asks user where to write.
canonical_location: PathBuf::new(),
})
}
/// CLEANLIB-373 — cargo / crates.io proxy emit.
///
/// Cargo's registry config lives in `~/.cargo/config.toml` (per-user) or
/// `<workspace>/.cargo/config.toml` (per-workspace). We emit a `[registries]`
/// entry the user can drop into either — matching the shape cargo documents
/// at <https://doc.rust-lang.org/cargo/reference/registries.html>. The token
/// belongs in `~/.cargo/credentials.toml` (never in `config.toml`), so the
/// snippet also carries the `credentials.toml` block for the same registry
/// name. Following the `Ecosystem::Go` precedent, `canonical_location` stays
/// empty because the workspace-vs-user placement is workflow-dependent.
fn emit_crates(opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
let endpoint = opts.endpoint.trim_end_matches('/');
let token = token_expression(opts);
// sparse+ prefix makes cargo use the HTTP protocol (stable since 1.68),
// not git — which is what a CleanLibrary registry proxy speaks.
let config_blob = format!(
"# 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",
);
Ok(ProxyConfig {
ecosystem: Ecosystem::Crates,
config_blob,
// Empty path = no canonical file; caller prints or asks user where to write.
canonical_location: PathBuf::new(),
})
}
/// CLEANLIB-374 — maven / gradle / sbt proxy emit.
///
/// Maven reads `~/.m2/settings.xml` for per-user config, and mirrors + auth
/// belong there (not in a per-project `pom.xml`). We emit the two blocks the
/// user drops into their existing `<settings>` element — a `<mirror>` that
/// diverts every request to the CleanLibrary proxy and a `<server>` that
/// attaches the Bearer token via the standard Maven HTTP-header
/// configuration property (`httpHeaders`). Following the `Ecosystem::Go` +
/// `Ecosystem::Crates` precedent, `canonical_location` stays empty because
/// the per-project `mvn -s` override case is common enough that we do not
/// silently mutate `~/.m2/settings.xml`.
fn emit_maven(opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
let endpoint = opts.endpoint.trim_end_matches('/');
let token = token_expression(opts);
let config_blob = format!(
"<!-- 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",
);
Ok(ProxyConfig {
ecosystem: Ecosystem::Maven,
config_blob,
// Empty path = no canonical file; caller prints or asks user where to write.
canonical_location: PathBuf::new(),
})
}
/// CLEANLIB-818 — NuGet proxy emit.
///
/// NuGet's V3 protocol (the only protocol the live App router speaks —
/// `cleanlib-ecosystem-nuget`'s router nests `/v3/index.json` +
/// `/v3-flatcontainer/{id}/index.json` (list_versions) +
/// `/v3-flatcontainer/{id}/{version}/{filename}` (attested `.nupkg` serve),
/// matching `cleanlib-app/src/http.rs`'s `/nuget` mount — verified live in
/// prod ahead of this fix: both the service index and `list_versions`
/// return real, populated data for known-ingested packages, not a stub)
/// is discovered from a single service-index URL, added as a
/// `<packageSources>` entry in `NuGet.Config`. Credentials go in a sister
/// `<packageSourceCredentials>` block keyed by the SAME source name —
/// NuGet.Config supports either an encrypted (Windows DPAPI) or cleartext
/// password; we emit `ClearTextPassword` since the token is a Bearer-style
/// API key, not an OS-DPAPI-protectable local secret, matching every other
/// ecosystem here embedding the raw token/placeholder in its own native
/// credential store. Following the `Ecosystem::Crates` + `Ecosystem::Maven`
/// precedent, `canonical_location` stays empty — NuGet.Config resolution is
/// per-project (nearest ancestor directory) or per-user
/// (`~/.nuget/NuGet/NuGet.Config` / `%APPDATA%\NuGet\NuGet.Config`),
/// workflow-dependent same as the others.
fn emit_nuget(opts: &EmitOptions) -> Result<ProxyConfig, ProxyConfigError> {
let endpoint = opts.endpoint.trim_end_matches('/');
let token = token_expression(opts);
let config_blob = format!(
"<!-- 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",
);
Ok(ProxyConfig {
ecosystem: Ecosystem::Nuget,
config_blob,
// Empty path = no canonical file; caller prints or asks user where to write.
canonical_location: PathBuf::new(),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn opts(endpoint: &str) -> EmitOptions {
EmitOptions {
endpoint: endpoint.to_string(),
scope: None,
inline_token: false,
api_key: None,
emit_netrc: false,
}
}
/// CLEANLIB-758 · a pypi emit needs a resolved key on both non-netrc
/// branches, unlike npm/go which still work off the shell-expansion
/// placeholder. Every existing test that expected the pypi placeholder
/// output now belongs on this helper (see the updated `pypi_*` tests).
fn opts_with_key(endpoint: &str, key: &str) -> EmitOptions {
EmitOptions {
endpoint: endpoint.to_string(),
scope: None,
inline_token: false,
api_key: Some(key.to_string()),
emit_netrc: false,
}
}
#[test]
fn ecosystem_parse_vocab_locked() {
assert_eq!(Ecosystem::parse("npm"), Some(Ecosystem::Npm));
assert_eq!(Ecosystem::parse("pypi"), Some(Ecosystem::Pypi));
assert_eq!(Ecosystem::parse("go"), Some(Ecosystem::Go));
// CLEANLIB-373 + CLEANLIB-374: crates + maven join the accepted set.
assert_eq!(Ecosystem::parse("crates"), Some(Ecosystem::Crates));
assert_eq!(Ecosystem::parse("maven"), Some(Ecosystem::Maven));
// Matrix §8 forbids these uppercase / off-vocab spellings
assert_eq!(Ecosystem::parse("NPM"), None);
assert_eq!(Ecosystem::parse("PyPI"), None);
assert_eq!(Ecosystem::parse("pip"), None);
assert_eq!(Ecosystem::parse("golang"), None);
// CLEANLIB-373 + CLEANLIB-374: uppercase / alias spellings still rejected.
assert_eq!(Ecosystem::parse("cargo"), None);
assert_eq!(Ecosystem::parse("Crates"), None);
assert_eq!(Ecosystem::parse("CRATES"), None);
assert_eq!(Ecosystem::parse("Maven"), None);
assert_eq!(Ecosystem::parse("MAVEN"), None);
assert_eq!(Ecosystem::parse("mvn"), None);
}
#[test]
fn ecosystem_as_str_roundtrips_lowercase() {
// Every accepted ecosystem must round-trip: parse(as_str(e)) == Some(e).
// Guards against a future variant added without a lowercase parse arm.
for e in Ecosystem::ALL {
assert_eq!(Ecosystem::parse(e.as_str()), Some(*e), "roundtrip failed for {:?}", e);
}
}
#[test]
fn supported_list_includes_crates_and_maven() {
// CLEANLIB-373 + CLEANLIB-374 — the human-facing error emitted by
// `cleanlib config init` when an ecosystem is unsupported reads
// `supported ecosystems: '{supported_list}'`; the list must advertise
// the newly-accepted ecosystems so the CLI's error output matches
// the CLI's actual accepted set.
let list = Ecosystem::supported_list();
for expected in &["npm", "pypi", "go", "crates", "maven"] {
assert!(
list.contains(expected),
"supported_list must advertise `{}`; got: {}",
expected,
list
);
}
}
#[test]
fn npm_emit_shell_expansion_default() {
let blob = emit_npm(&opts("https://cleanapp.clnstrt.dev")).unwrap().config_blob;
assert!(blob.contains("registry=https://cleanapp.clnstrt.dev/npm/"));
assert!(blob.contains("//cleanapp.clnstrt.dev/npm/:_authToken=${CLEANLIBRARY_API_KEY}"));
assert!(blob.contains("always-auth=true"));
}
#[test]
fn npm_emit_with_scope() {
let mut o = opts("https://cleanapp.clnstrt.dev");
o.scope = Some("@my-org".to_string());
let blob = emit_npm(&o).unwrap().config_blob;
assert!(blob.contains("@my-org:registry=https://cleanapp.clnstrt.dev/npm/"));
}
#[test]
fn npm_emit_inline_token() {
let mut o = opts("https://cleanapp.clnstrt.dev");
o.inline_token = true;
o.api_key = Some("cs_live_smoke".to_string());
let blob = emit_npm(&o).unwrap().config_blob;
assert!(blob.contains("_authToken=cs_live_smoke"));
assert!(!blob.contains("${CLEANLIBRARY_API_KEY}"));
}
// CLEANLIB-758 · this test pinned the BROKEN default output that shipped
// (the placeholder `${CLEANLIBRARY_API_KEY}` in the URL that pip could
// not expand). The default now emits the RESOLVED key; the placeholder
// form is unreachable on the pypi path. Sister of the CLEANLIB-129
// defense-in-depth pattern where `--inline-token` with no key is a hard
// error rather than an empty-value emission. Renamed so the test's role
// documents the fix, not the defect.
#[test]
fn pypi_emit_default_embeds_resolved_key_in_url_userinfo() {
let blob = emit_pypi(&opts_with_key("https://cleanapp.clnstrt.dev", "cs_live_abc"))
.unwrap()
.config_blob;
assert!(
blob.contains("index-url = https://cs_live_abc@cleanapp.clnstrt.dev/pypi/simple/"),
"resolved key must land in the URL userinfo; got:\n{blob}"
);
assert!(blob.contains("trusted-host = cleanapp.clnstrt.dev"));
assert!(
!blob.contains("${CLEANLIBRARY_API_KEY}"),
"pypi emit must never leave the placeholder in the URL — pip cannot expand it"
);
}
#[test]
fn pypi_emit_percent_encodes_key_with_reserved_characters() {
// Defence-in-depth: a future key format with `@` / `:` / `/` would
// otherwise corrupt the URL split. The unreserved set today is a
// no-op on the alphanumeric+`_-` keys CleanLibrary uses.
let blob = emit_pypi(&opts_with_key("https://cleanapp.clnstrt.dev", "k@e:y/1"))
.unwrap()
.config_blob;
// `@` → %40, `:` → %3A, `/` → %2F.
assert!(
blob.contains("k%40e%3Ay%2F1@cleanapp.clnstrt.dev"),
"percent-encode reserved chars in userinfo; got:\n{blob}"
);
}
#[test]
fn pypi_emit_refuses_default_when_key_missing() {
// Default branch without a key would previously emit the
// `${CLEANLIBRARY_API_KEY}` placeholder into the URL — 401 on every
// pip install and read as "empty catalog" to the customer. The emit
// now fails LOUD, naming the fix (login first / --emit-netrc).
let err = emit_pypi(&opts("https://cleanapp.clnstrt.dev")).unwrap_err();
assert!(matches!(err, ProxyConfigError::PypiRequiresResolvedKey));
}
#[test]
fn pypi_emit_refuses_default_on_whitespace_only_key() {
let mut o = opts_with_key("https://cleanapp.clnstrt.dev", " \t\n");
// trim reduces the key to empty; must NOT emit `https:// @host/…` .
o.api_key = Some(" \t\n".to_string());
let err = emit_pypi(&o).unwrap_err();
assert!(matches!(err, ProxyConfigError::PypiRequiresResolvedKey));
}
#[test]
fn pypi_emit_netrc_branch_produces_credential_free_pip_conf() {
let mut o = opts_with_key("https://cleanapp.clnstrt.dev", "cs_live_abc");
o.emit_netrc = true;
let cfg = emit_pypi(&o).unwrap();
let blob = &cfg.config_blob;
// pip.conf half — no userinfo on the URL.
assert!(
blob.contains("index-url = https://cleanapp.clnstrt.dev/pypi/simple/"),
"pip.conf half must carry no credentials; got:\n{blob}"
);
assert!(
!blob.contains("@cleanapp.clnstrt.dev"),
"pip.conf URL must not carry an @-userinfo; got:\n{blob}"
);
// .netrc half — machine line + login line carrying the key.
assert!(
blob.contains("machine cleanapp.clnstrt.dev\n login cs_live_abc\n"),
".netrc half must carry a machine block with the resolved key; got:\n{blob}"
);
// No canonical location — the two-file emit is print-only.
assert!(
cfg.canonical_location.as_os_str().is_empty(),
"--emit-netrc must not silently write two files off one canonical target"
);
}
#[test]
fn pypi_emit_netrc_still_requires_a_key() {
// No key + --emit-netrc = still a broken emit (nothing to put in
// `login`). The error steers the customer to the same fix.
let mut o = opts("https://cleanapp.clnstrt.dev");
o.emit_netrc = true;
let err = emit_pypi(&o).unwrap_err();
assert!(matches!(err, ProxyConfigError::PypiRequiresResolvedKey));
}
#[test]
fn pypi_emit_inline_token_true_and_default_produce_same_url_userinfo() {
// Pre-fix `inline_token=true` produced `_authToken=<key>` (npm) but
// for pypi the "inline_token" and "default" branches converge on
// the same URL-userinfo shape now that the default requires a
// resolved key. Regression guard so a future refactor doesn't
// re-diverge them into two subtly-different URLs.
let mut o_inline = opts_with_key("https://cleanapp.clnstrt.dev", "cs_live_abc");
o_inline.inline_token = true;
let blob_inline = emit_pypi(&o_inline).unwrap().config_blob;
let blob_default =
emit_pypi(&opts_with_key("https://cleanapp.clnstrt.dev", "cs_live_abc"))
.unwrap()
.config_blob;
assert_eq!(blob_inline, blob_default);
}
#[test]
fn go_emit_env_form() {
let blob = emit_go(&opts("https://cleanapp.clnstrt.dev")).unwrap().config_blob;
assert!(blob.contains("export GOPROXY=https://cleanapp.clnstrt.dev/go,direct"));
assert!(blob.contains("export GOAUTH=\"Authorization: Bearer ${CLEANLIBRARY_API_KEY}\""));
}
#[test]
fn go_emit_has_no_canonical_location() {
let cfg = emit_go(&opts("https://cleanapp.clnstrt.dev")).unwrap();
assert!(cfg.canonical_location.as_os_str().is_empty());
}
#[test]
fn endpoint_trailing_slash_tolerated() {
let blob = emit_npm(&opts("https://cleanapp.clnstrt.dev/")).unwrap().config_blob;
// Should NOT have double slash
assert!(blob.contains("registry=https://cleanapp.clnstrt.dev/npm/"));
assert!(!blob.contains("//npm/"));
}
// CLEANLIB-129 / Jira CLEANLIB-28 — defense-in-depth `emit`-level rejection
// of `inline_token=true` when `api_key` is missing or empty/whitespace.
#[test]
fn emit_rejects_inline_token_with_none_api_key() {
let mut o = opts("https://cleanapp.clnstrt.dev");
o.inline_token = true;
o.api_key = None;
let err = emit(Ecosystem::Npm, &o).unwrap_err();
assert!(matches!(err, ProxyConfigError::InlineTokenEmpty(Ecosystem::Npm)));
}
#[test]
fn emit_rejects_inline_token_with_empty_string_api_key() {
let mut o = opts("https://cleanapp.clnstrt.dev");
o.inline_token = true;
o.api_key = Some(String::new());
let err = emit(Ecosystem::Pypi, &o).unwrap_err();
assert!(matches!(err, ProxyConfigError::InlineTokenEmpty(Ecosystem::Pypi)));
}
#[test]
fn emit_rejects_inline_token_with_whitespace_only_api_key() {
let mut o = opts("https://cleanapp.clnstrt.dev");
o.inline_token = true;
o.api_key = Some(" \t\n".to_string());
let err = emit(Ecosystem::Go, &o).unwrap_err();
assert!(matches!(err, ProxyConfigError::InlineTokenEmpty(Ecosystem::Go)));
}
#[test]
fn emit_accepts_inline_token_with_valid_api_key() {
let mut o = opts("https://cleanapp.clnstrt.dev");
o.inline_token = true;
o.api_key = Some("std_001".to_string());
let blob = emit(Ecosystem::Npm, &o).unwrap().config_blob;
// Verify the legit key reaches the rendered _authToken line.
assert!(blob.contains("_authToken=std_001"));
// Ensure we did NOT regress to the broken `_authToken=` empty form.
assert!(!blob.contains("_authToken=\n"));
assert!(!blob.contains("_authToken= "));
}
#[test]
fn emit_shell_expansion_path_unaffected_by_empty_key_for_env_expanding_ecosystems() {
// When `inline_token=false`, an empty api_key is fine for the
// ecosystems whose tooling expands `${CLEANLIBRARY_API_KEY}` from
// the environment at runtime (npm reads `${VAR}` in `.npmrc`,
// shells expand `GOAUTH`, cargo credentials.toml is read by cargo
// in a shell-launched process, maven's `httpHeaders` is templated
// by `mvn`).
//
// pypi is DIFFERENT — pip does NOT expand `${VAR}` in pip.conf, so
// a placeholder would 401 (CLEANLIB-758). The pypi emit now
// requires a resolved key on this path and is exercised separately
// by the `pypi_emit_refuses_default_when_key_missing` guard above.
let mut o = opts("https://cleanapp.clnstrt.dev");
o.inline_token = false;
o.api_key = None;
assert!(emit(Ecosystem::Npm, &o).is_ok());
assert!(emit(Ecosystem::Go, &o).is_ok());
// CLEANLIB-373 + CLEANLIB-374 — the new variants also emit.
assert!(emit(Ecosystem::Crates, &o).is_ok());
assert!(emit(Ecosystem::Maven, &o).is_ok());
// Pypi on this path errors LOUD (see the dedicated test above).
}
// ── CLEANLIB-373 — cargo / crates.io proxy emit ────────────────────────
#[test]
fn crates_emit_registry_url_and_placeholder_token() {
let blob = emit_crates(&opts("https://cleanapp.clnstrt.dev")).unwrap().config_blob;
// sparse+ HTTP registry index — the wire cargo speaks after 1.68.
assert!(
blob.contains("index = \"sparse+https://cleanapp.clnstrt.dev/crates/\""),
"crates blob missing sparse index; got:\n{blob}"
);
assert!(
blob.contains("[registries.cleanlibrary]"),
"crates blob missing [registries.cleanlibrary]; got:\n{blob}"
);
// Shell-expansion (no --inline-token) → placeholder in the token line.
assert!(
blob.contains("token = \"Bearer ${CLEANLIBRARY_API_KEY}\""),
"crates blob missing token placeholder; got:\n{blob}"
);
}
#[test]
fn crates_emit_inline_token_embeds_key() {
let mut o = opts("https://cleanapp.clnstrt.dev");
o.inline_token = true;
o.api_key = Some("cs_live_smoke".to_string());
let blob = emit_crates(&o).unwrap().config_blob;
assert!(
blob.contains("token = \"Bearer cs_live_smoke\""),
"inline_token must embed the key in the credentials.toml block; got:\n{blob}"
);
// And never leave the placeholder in place — that would look ok but
// silently break auth (sister of the CLEANLIB-129 `_authToken=` empty
// regression the npm emit codepath already guards against).
assert!(
!blob.contains("${CLEANLIBRARY_API_KEY}"),
"inline_token blob must NOT retain the placeholder"
);
}
#[test]
fn crates_emit_has_no_canonical_location() {
// Following the Ecosystem::Go precedent — cargo's config placement is
// per-user vs per-workspace; the CLI prints the snippet rather than
// silently mutating either.
let cfg = emit_crates(&opts("https://cleanapp.clnstrt.dev")).unwrap();
assert!(cfg.canonical_location.as_os_str().is_empty());
}
// ── CLEANLIB-374 — maven / gradle / sbt proxy emit ─────────────────────
#[test]
fn maven_emit_mirror_url_and_placeholder_token() {
let blob = emit_maven(&opts("https://cleanapp.clnstrt.dev")).unwrap().config_blob;
assert!(
blob.contains("<url>https://cleanapp.clnstrt.dev/maven/</url>"),
"maven blob missing mirror URL; got:\n{blob}"
);
assert!(
blob.contains("<mirrorOf>*</mirrorOf>"),
"maven blob must divert every repo through the CleanLibrary mirror; got:\n{blob}"
);
assert!(
blob.contains("<value>Bearer ${CLEANLIBRARY_API_KEY}</value>"),
"maven blob must carry Bearer placeholder in the Authorization header; got:\n{blob}"
);
}
#[test]
fn maven_emit_inline_token_embeds_key() {
let mut o = opts("https://cleanapp.clnstrt.dev");
o.inline_token = true;
o.api_key = Some("cs_live_smoke".to_string());
let blob = emit_maven(&o).unwrap().config_blob;
assert!(
blob.contains("<value>Bearer cs_live_smoke</value>"),
"inline_token must embed the key in the httpHeaders block; got:\n{blob}"
);
assert!(
!blob.contains("${CLEANLIBRARY_API_KEY}"),
"inline_token blob must NOT retain the placeholder"
);
}
#[test]
fn maven_emit_has_no_canonical_location() {
// ~/.m2/settings.xml is common but the per-project `mvn -s` override
// pattern is common enough that we do not silently mutate it.
let cfg = emit_maven(&opts("https://cleanapp.clnstrt.dev")).unwrap();
assert!(cfg.canonical_location.as_os_str().is_empty());
}
#[test]
fn crates_and_maven_endpoint_trailing_slash_tolerated() {
// Sister of the existing npm test — the trim_end_matches('/') defence
// must fire for the new ecosystems too so `--endpoint https://x/`
// doesn't produce `//crates/` / `//maven/` in the emitted config.
let crates_blob = emit_crates(&opts("https://cleanapp.clnstrt.dev/"))
.unwrap()
.config_blob;
assert!(crates_blob.contains("sparse+https://cleanapp.clnstrt.dev/crates/"));
assert!(!crates_blob.contains("//crates/"));
let maven_blob = emit_maven(&opts("https://cleanapp.clnstrt.dev/"))
.unwrap()
.config_blob;
assert!(maven_blob.contains("<url>https://cleanapp.clnstrt.dev/maven/</url>"));
assert!(!maven_blob.contains("//maven/"));
}
// ── CLEANLIB-818 — NuGet proxy emit ────────────────────────────────────
#[test]
fn nuget_parse_accepted_rubygems_composer_still_rejected() {
assert_eq!(Ecosystem::parse("nuget"), Some(Ecosystem::Nuget));
// These two are DELIBERATELY not accepted — the platform genuinely
// does not resolve them yet (see the enum doc); a passing parse
// here would be a regression, not progress.
assert_eq!(Ecosystem::parse("rubygems"), None);
assert_eq!(Ecosystem::parse("composer"), None);
// Off-vocab spellings still rejected, same discipline as crates/maven.
assert_eq!(Ecosystem::parse("Nuget"), None);
assert_eq!(Ecosystem::parse("NUGET"), None);
assert_eq!(Ecosystem::parse("nuspec"), None);
}
#[test]
fn supported_list_includes_nuget_not_rubygems_or_composer() {
let list = Ecosystem::supported_list();
assert!(
list.contains("nuget"),
"supported_list must advertise `nuget`; got: {}",
list
);
assert!(
!list.contains("rubygems") && !list.contains("composer"),
"supported_list must NOT advertise rubygems/composer — the \
platform does not resolve them yet; got: {}",
list
);
}
#[test]
fn nuget_emit_service_index_url_and_credentials_block() {
let blob = emit_nuget(&opts("https://cleanapp.clnstrt.dev")).unwrap().config_blob;
assert!(
blob.contains("value=\"https://cleanapp.clnstrt.dev/nuget/v3/index.json\""),
"nuget blob missing the V3 service-index source entry; got:\n{blob}"
);
assert!(
blob.contains("<packageSourceCredentials>"),
"nuget blob missing the credentials block; got:\n{blob}"
);
// Shell-expansion (no --inline-token) -> placeholder in the password.
assert!(
blob.contains("<add key=\"ClearTextPassword\" value=\"${CLEANLIBRARY_API_KEY}\" />"),
"nuget blob must carry the placeholder password by default; got:\n{blob}"
);
}
#[test]
fn nuget_emit_inline_token_embeds_key() {
let mut o = opts("https://cleanapp.clnstrt.dev");
o.inline_token = true;
o.api_key = Some("cs_live_smoke".to_string());
let blob = emit_nuget(&o).unwrap().config_blob;
assert!(
blob.contains("<add key=\"ClearTextPassword\" value=\"cs_live_smoke\" />"),
"inline_token must embed the key in the credentials block; got:\n{blob}"
);
assert!(
!blob.contains("${CLEANLIBRARY_API_KEY}"),
"inline_token blob must NOT retain the placeholder"
);
}
#[test]
fn nuget_emit_has_no_canonical_location() {
// Following the Crates/Maven precedent — NuGet.Config placement is
// per-project vs per-user; the CLI prints the snippet rather than
// silently mutating either.
let cfg = emit_nuget(&opts("https://cleanapp.clnstrt.dev")).unwrap();
assert!(cfg.canonical_location.as_os_str().is_empty());
}
#[test]
fn nuget_emit_endpoint_trailing_slash_tolerated() {
let blob = emit_nuget(&opts("https://cleanapp.clnstrt.dev/"))
.unwrap()
.config_blob;
assert!(blob.contains("https://cleanapp.clnstrt.dev/nuget/v3/index.json"));
assert!(!blob.contains("//nuget/"));
}
#[test]
fn ecosystem_all_matches_what_the_platform_actually_resolves() {
// CLEANLIB-818 deliverable 3: this list previously went stale
// silently — nuget's four App-side layers were live in prod while
// this enum still refused it. Assert against the EXPLICIT verified
// set (not `Ecosystem::ALL` compared to itself, which can never
// catch a stale list) so a future ecosystem landing server-side
// without a corresponding client update fails this test, not a
// customer's `config init` run.
//
// As of CLEANLIB-818: npm/pypi/go/crates/maven/nuget are verified
// live end-to-end. rubygems/composer are NOT — their App-side
// resolution 404s (sibling ticket, tracked separately) — and must
// stay OUT of this set until that lands.
const PLATFORM_RESOLVES: &[&str] =
&["npm", "pypi", "go", "crates", "maven", "nuget"];
let client_accepts: Vec<&str> = Ecosystem::ALL.iter().map(|e| e.as_str()).collect();
assert_eq!(
client_accepts, PLATFORM_RESOLVES,
"Ecosystem::ALL must track exactly what the platform actually \
resolves end-to-end, in the same order — update BOTH this \
constant and Ecosystem::ALL together, never one alone"
);
}
}