Skip to main content

cleanlib_client/
transport.rs

1//! HTTP transport — `Client` wraps reqwest with auth header injection +
2//! timeout + per Client spec rev1 §2 + Rev 2 amendment §2 + §4.
3//!
4//! Phase 1 substrate: `fetch_verdict` is the first transport consumer
5//! (CLEANLIB-30); subsequent CLEANLIB-N PRs add `scan` / `audit` /
6//! `policy preview` / `fetch` verb impls reusing this `Client`.
7
8use std::time::Duration;
9
10use reqwest::{header, Client as ReqwestClient, Method, Url};
11
12use crate::attestation_verify::PinnedKeyMap;
13use crate::config::Config;
14use crate::errors::{from_http, CleanLibraryError, TransportError};
15use crate::types::{
16    AuditResponse, PolicyPreviewRequest, PolicyPreviewResponse, ScanRequest, ScanResponse, Verdict,
17};
18
19/// Default HTTP timeout per Rev 2 amendment §4.1 — allows 1-5s GCS-catalog
20/// first-request-ingest + buffer.
21const DEFAULT_TIMEOUT_SECS: u64 = 30;
22
23/// `Client` is the cleanlib-client HTTP transport handle. Cheap to clone;
24/// share across verbs in the same process.
25#[derive(Debug, Clone)]
26pub struct Client {
27    http: ReqwestClient,
28    base_url: Url,
29    api_key: Option<String>,
30    api_version: String,
31}
32
33/// Outcome of a remediation fetch (CLEANLIB-733 Rust remediation client).
34/// Mirrors the sdk-py/js/go `RemediationOrAbsent` discriminated union: a 404 is
35/// a distinct, cacheable "no data" answer — never collapsed into a transient.
36#[derive(Debug, Clone)]
37pub enum RemediationOutcome {
38    /// `2xx` — the sparse remediation composite (loosely-typed JSON, matching
39    /// the envelope `remediation` field shape).
40    Present(serde_json::Value),
41    /// `404 REMEDIATION_NOT_FOUND` — a true, final "no remediation data for this
42    /// coordinate" answer, distinct from a transient failure.
43    NotInSubstrate,
44    /// `5xx` / transport — a transient failure; a retry may succeed.
45    Transient(String),
46}
47
48/// Filters for [`Client::audit`] (CLEANLIB-813). All fields are optional —
49/// `AuditFilters::default()` (or any `..Default::default()` spread)
50/// queries the full audit log, matching the prior `audit(None, None,
51/// None)` shape byte-for-byte.
52///
53/// Deliberately an options struct rather than positional parameters:
54/// `until` is the filter this ticket adds (the real server already
55/// accepts it — `cleanlib-app/src/verbs.rs` — and Go's SDK already
56/// exposes it; this SDK never did). A 4th positional parameter would
57/// have been ANOTHER breaking signature change on top of the one this
58/// struct already is, and the same problem recurs at the 5th filter.
59/// Every field here is purely additive from a caller's perspective when
60/// built via `AuditFilters { since: Some(x), ..Default::default() }` —
61/// a future filter lands as one new field, never another migration.
62#[derive(Debug, Clone, Copy, Default)]
63pub struct AuditFilters<'a> {
64    /// Only entries at or after this RFC 3339 timestamp.
65    pub since: Option<&'a str>,
66    /// CLEANLIB-813: only entries at or before this RFC 3339 timestamp —
67    /// bounds the END of the query window. The gap this ticket closes.
68    pub until: Option<&'a str>,
69    /// Only entries with this decision (`ALLOW` / `INSUFFICIENT` / `DENY`
70    /// — see `cleanlib-cli`'s `VALID_DECISION_FILTERS` for the
71    /// client-side-validated domain).
72    pub decision: Option<&'a str>,
73    /// Only entries for this ecosystem.
74    pub ecosystem: Option<&'a str>,
75}
76
77impl Client {
78    /// Construct from a loaded `Config`. TLS required for non-localhost
79    /// endpoints. Returns `TlsRequired` for `http://` on remote hosts.
80    pub fn from_config(config: &Config) -> Result<Self, CleanLibraryError> {
81        Self::build(&config.endpoint.url, config.auth.api_key.clone(), &config.endpoint.api_version)
82    }
83
84    /// Construct with explicit endpoint + api_key. Intended for integration
85    /// tests + ad-hoc invocations (e.g., CLI `--endpoint=` flag future).
86    pub fn new(endpoint: &str, api_key: Option<String>) -> Result<Self, CleanLibraryError> {
87        Self::build(endpoint, api_key, "v1")
88    }
89
90    fn build(
91        endpoint: &str,
92        api_key: Option<String>,
93        api_version: &str,
94    ) -> Result<Self, CleanLibraryError> {
95        let base_url = Url::parse(endpoint)
96            .map_err(|e| TransportError::InvalidUrl(format!("{}: {}", endpoint, e)))?;
97
98        let is_localhost = matches!(
99            base_url.host_str(),
100            Some("localhost") | Some("127.0.0.1") | Some("::1")
101        );
102        if base_url.scheme() != "https" && !is_localhost {
103            return Err(TransportError::TlsRequired(endpoint.to_string()).into());
104        }
105
106        let http = ReqwestClient::builder()
107            .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
108            .user_agent(concat!("cleanlib-cli/", env!("CARGO_PKG_VERSION")))
109            .build()
110            .map_err(TransportError::Network)?;
111
112        Ok(Self {
113            http,
114            base_url,
115            api_key,
116            api_version: api_version.to_string(),
117        })
118    }
119
120    /// Cosign gate 3 (Q6=a): verify a `Verdict`'s signed attestation.
121    ///
122    /// Q14 (capability parity, not policy) — this is opt-in. Nothing else in
123    /// `Client` calls this; a caller that never invokes it sees no behavior
124    /// change. Returns `Err(AttestationInvalid { reason_code:
125    /// "ATTESTATION_ABSENT", .. })` when the verdict carries no attestation
126    /// at all (distinct from a present-but-invalid one) so callers can tell
127    /// "nothing to verify" apart from "verification failed".
128    ///
129    /// Post gate-3 redesign (2026-09-13, BD-ratified per Jira CLEANLIB-379
130    /// comment 804236): verifies against [`PinnedKeyMap`] — a compiled-in,
131    /// fail-closed key set — NOT a `/v1/pubkeys` fetch. See
132    /// [`crate::attestation_verify`]'s module doc for why the earlier
133    /// fetch-by-`key_id` design (PR #536) was architecturally circular and
134    /// got redesigned before any release shipped it as a default. No lazy
135    /// network object to build here anymore — `PinnedKeyMap::default()` is
136    /// a cheap, synchronous, in-memory construction.
137    pub async fn verify_attestation(&self, verdict: &Verdict) -> Result<(), CleanLibraryError> {
138        let envelope = verdict.attestation.as_ref().ok_or_else(|| {
139            CleanLibraryError::AttestationInvalid {
140                reason_code: "ATTESTATION_ABSENT".to_string(),
141                message: "verdict carries no attestation (attestation_status = \
142                          signature_absent, or a v1/pre-attestation response)"
143                    .to_string(),
144            }
145        })?;
146        let lookup = PinnedKeyMap::default();
147        crate::attestation_verify::verify_attestation(envelope, &lookup).await
148    }
149
150    /// Fetch a single verdict per App Rev 4 §9.3 +
151    /// `GET /v1/customer/verdicts/{ecosystem}/{package}/{version}`.
152    pub async fn fetch_verdict(
153        &self,
154        ecosystem: &str,
155        package: &str,
156        version: &str,
157    ) -> Result<Verdict, CleanLibraryError> {
158        let path = format!(
159            "{}/customer/verdicts/{}/{}/{}",
160            self.api_version,
161            urlencode(ecosystem),
162            urlencode(package),
163            urlencode(version),
164        );
165        let url = self
166            .base_url
167            .join(&path)
168            .map_err(|e| TransportError::InvalidUrl(format!("{}: {}", path, e)))?;
169
170        let response = self.send(Method::GET, url).await?;
171        let status = response.status();
172        let headers = response.headers().clone();
173        let body = response.text().await.map_err(TransportError::Network)?;
174
175        if !status.is_success() {
176            return Err(from_http(status.as_u16(), &headers, &body));
177        }
178
179        serde_json::from_str(&body)
180            .map_err(|e| CleanLibraryError::Parse(format!("verdict response: {}", e)))
181    }
182
183    /// Submit a packages + optional-policy request to
184    /// `POST /v1/scan` — batch-resolve verdicts for a set of packages against
185    /// the customer's active policy. Used by `cleanlib scan`. The App resolves
186    /// each package independently (partial-success: a per-package miss lands as
187    /// `ScanResult.error`, not a whole-batch failure), so the caller derives
188    /// the gating decision per `ScanResult.verdict` and aggregates the exit
189    /// code (`commands::scan`).
190    ///
191    /// Distinct endpoint from [`Self::policy_preview`]: `/v1/scan` needs no
192    /// `policy_yaml`. Routing scan through `/v1/policy/preview` produced a
193    /// `422 missing field policy_yaml`, and the old `PolicyPreviewResponse`
194    /// (`{decisions}`, `#[serde(default)]`) silently parsed the App's
195    /// `{results}` body into an empty vec → `scan_exit_code(&[]) == 0`, a
196    /// fail-open on the security gate. Both are closed here.
197    pub async fn scan(&self, req: &ScanRequest) -> Result<ScanResponse, CleanLibraryError> {
198        let path = format!("{}/scan", self.api_version);
199        let url = self
200            .base_url
201            .join(&path)
202            .map_err(|e| TransportError::InvalidUrl(format!("{}: {}", path, e)))?;
203
204        let body = serde_json::to_vec(req)
205            .map_err(|e| CleanLibraryError::Parse(format!("scan body: {}", e)))?;
206
207        let response = self
208            .send_with_body(Method::POST, url, body, "application/json")
209            .await?;
210        let status = response.status();
211        let headers = response.headers().clone();
212        let body = response.text().await.map_err(TransportError::Network)?;
213
214        if !status.is_success() {
215            return Err(from_http(status.as_u16(), &headers, &body));
216        }
217
218        let mut resp: ScanResponse = serde_json::from_str(&body)
219            .map_err(|e| CleanLibraryError::Parse(format!("scan response: {}", e)))?;
220        // CLEANLIB-480 · stamp the `x-request-id` from the response header onto
221        // the parsed body so SDK callers can `resp.request_id` for correlation
222        // debugging without a raw HTTP bypass.
223        resp.request_id = extract_request_id(&headers);
224        Ok(resp)
225    }
226
227    /// `POST /v1/policy/preview`. Used by `cleanlib policy preview` (with an
228    /// explicit candidate policy override). NOTE: `cleanlib scan` uses
229    /// [`Self::scan`] (`/v1/scan`), NOT this endpoint.
230    ///
231    /// CLEANLIB-305 DX-fix: corrected from `/v1/customer/policy/preview`
232    /// (which 404s) to `/v1/policy/preview` (which the App mounts via
233    /// `verbs_router`). Same class of bug as the cycle-14 `/v1/audit`
234    /// fix (see `audit` below): the `/customer/` prefix is used only by
235    /// `customer_verdicts_router` (`/v1/customer/verdicts/*`); the
236    /// cycle-6 verb surface (`scan`, `audit`, `policy/preview`,
237    /// `risk-accept`, `fetch/*`) mounts flat under `/v1`.
238    pub async fn policy_preview(
239        &self,
240        req: &PolicyPreviewRequest,
241    ) -> Result<PolicyPreviewResponse, CleanLibraryError> {
242        let path = format!("{}/policy/preview", self.api_version);
243        let url = self
244            .base_url
245            .join(&path)
246            .map_err(|e| TransportError::InvalidUrl(format!("{}: {}", path, e)))?;
247
248        let body = serde_json::to_vec(req)
249            .map_err(|e| CleanLibraryError::Parse(format!("policy_preview body: {}", e)))?;
250
251        let response = self.send_with_body(Method::POST, url, body, "application/json").await?;
252        let status = response.status();
253        let headers = response.headers().clone();
254        let body = response.text().await.map_err(TransportError::Network)?;
255
256        if !status.is_success() {
257            return Err(from_http(status.as_u16(), &headers, &body));
258        }
259
260        let mut resp: PolicyPreviewResponse = serde_json::from_str(&body)
261            .map_err(|e| CleanLibraryError::Parse(format!("policy_preview response: {}", e)))?;
262        // CLEANLIB-480 · stamp `x-request-id` from the response header.
263        resp.request_id = extract_request_id(&headers);
264        Ok(resp)
265    }
266
267    /// Fetch the raw artifact bytes for `(ecosystem, package, version)` via
268    /// App's unified catalog-proxy `GET /v1/fetch/{ecosystem}/{package}/{version}`
269    /// (CLEANLIB-302 / CLEANLIB-368). Returns owned `Vec<u8>` — caller decides
270    /// write target. Emits decision + reason headers to stderr for visibility
271    /// (binary stdout stays clean).
272    ///
273    /// **CLEANLIB-368 route pivot**: prior cycles built per-ecosystem registry
274    /// paths (`/npm/<pkg>/-/<pkg>-<ver>.tgz`, `/go/<pkg>/@v/<ver>.zip`, …)
275    /// against the App's cycle-3 §C.8 nested per-eco routers. Those paths are
276    /// legacy registry-mimic shapes that never surfaced the CLEANLIB-302 audit
277    /// row (`gcs_hit` / `gcs_object_path` / `bytes_served`) and hard-coded
278    /// pypi wheel-variant assumptions that diverge from the real serve path.
279    /// The App unifies both under `verbs_router` at `/v1/fetch/*` — that is
280    /// now the sole client-side route.
281    pub async fn fetch_artifact(
282        &self,
283        ecosystem: &str,
284        package: &str,
285        version: &str,
286    ) -> Result<Vec<u8>, CleanLibraryError> {
287        let url = build_fetch_url(&self.base_url, &self.api_version, ecosystem, package, version)?;
288        let response = self.send(Method::GET, url).await?;
289        let status = response.status();
290        let headers = response.headers().clone();
291
292        if !status.is_success() {
293            let body = response.text().await.map_err(TransportError::Network)?;
294            return Err(from_http(status.as_u16(), &headers, &body));
295        }
296
297        emit_decision_headers(&headers);
298
299        let bytes = response.bytes().await.map_err(TransportError::Network)?;
300        Ok(bytes.to_vec())
301    }
302
303    /// CLEANLIB-733 / step-4 Rust remediation client — fetch the sparse
304    /// remediation composite via the cleanapp CUSTOMER-BOUNDARY FACADE
305    /// (`GET /v1/customer/remediation/{eco}/{pkg}`, the same customer key that
306    /// opens the verdict surface). Sister of the sdk-py / sdk-js / sdk-go
307    /// `HttpRemediationClient`.
308    ///
309    /// Status mapping mirrors the other SDKs: `404` → [`RemediationOutcome::NotInSubstrate`]
310    /// (a true, final "no remediation data" answer — never conflated with a
311    /// transient, the [Degr≡Real] guard the facade 404-passthrough fix restored),
312    /// `5xx`/transport → [`RemediationOutcome::Transient`], `2xx` →
313    /// [`RemediationOutcome::Present`]. Internal/producer-bearer callers hit the
314    /// direct enrich host — construct the `Client` with that endpoint and use
315    /// [`Self::get_remediation_direct`]. Scoped npm packages (`@scope/name`) are
316    /// percent-encoded to a single `%2F` segment (CLEANLIB-737 guard; see
317    /// `urlencode`).
318    pub async fn get_remediation(
319        &self,
320        ecosystem: &str,
321        package: &str,
322    ) -> Result<RemediationOutcome, CleanLibraryError> {
323        self.remediation_with_mode(ecosystem, package, true).await
324    }
325
326    /// Direct-mode remediation for internal/producer-bearer callers: hits the
327    /// pre-facade `GET /api/v1/remediation/{eco}/{pkg}` path on the configured
328    /// (enrich-host) endpoint. Preserves the direct path per the un-park item-3.
329    pub async fn get_remediation_direct(
330        &self,
331        ecosystem: &str,
332        package: &str,
333    ) -> Result<RemediationOutcome, CleanLibraryError> {
334        self.remediation_with_mode(ecosystem, package, false).await
335    }
336
337    async fn remediation_with_mode(
338        &self,
339        ecosystem: &str,
340        package: &str,
341        facade: bool,
342    ) -> Result<RemediationOutcome, CleanLibraryError> {
343        let url = build_remediation_url(&self.base_url, &self.api_version, facade, ecosystem, package)?;
344        let response = self.send(Method::GET, url).await?;
345        let status = response.status();
346        if status.as_u16() == 404 {
347            return Ok(RemediationOutcome::NotInSubstrate);
348        }
349        if status.is_server_error() {
350            return Ok(RemediationOutcome::Transient(format!("HTTP {}", status.as_u16())));
351        }
352        if !status.is_success() {
353            let headers = response.headers().clone();
354            let body = response.text().await.map_err(TransportError::Network)?;
355            return Err(from_http(status.as_u16(), &headers, &body));
356        }
357        let body = response.text().await.map_err(TransportError::Network)?;
358        let json: serde_json::Value =
359            serde_json::from_str(&body).map_err(|e| CleanLibraryError::Parse(e.to_string()))?;
360        Ok(RemediationOutcome::Present(json))
361    }
362
363    /// Streaming variant of [`Self::fetch_artifact`] — writes chunks to
364    /// `writer` without buffering the full body in memory. Per Client Rev 2
365    /// amendment §9.4 cycle-4 §D.5 streaming substrate. Returns total bytes
366    /// written. Decision + reason headers surface to stderr before stream.
367    /// Hits the same unified `/v1/fetch/{ecosystem}/{package}/{version}`
368    /// proxy as [`Self::fetch_artifact`] — see the CLEANLIB-368 route pivot
369    /// note there.
370    pub async fn fetch_artifact_stream<W>(
371        &self,
372        ecosystem: &str,
373        package: &str,
374        version: &str,
375        writer: &mut W,
376    ) -> Result<u64, CleanLibraryError>
377    where
378        W: tokio::io::AsyncWrite + Unpin,
379    {
380        use futures_util::StreamExt;
381        use tokio::io::AsyncWriteExt;
382
383        let url = build_fetch_url(&self.base_url, &self.api_version, ecosystem, package, version)?;
384        let response = self.send(Method::GET, url).await?;
385        let status = response.status();
386        let headers = response.headers().clone();
387
388        if !status.is_success() {
389            let body = response.text().await.map_err(TransportError::Network)?;
390            return Err(from_http(status.as_u16(), &headers, &body));
391        }
392
393        emit_decision_headers(&headers);
394
395        let mut total: u64 = 0;
396        let mut stream = response.bytes_stream();
397        while let Some(chunk) = stream.next().await {
398            let bytes = chunk.map_err(TransportError::Network)?;
399            writer
400                .write_all(&bytes)
401                .await
402                .map_err(|e| CleanLibraryError::Parse(format!("write artifact chunk: {}", e)))?;
403            total += bytes.len() as u64;
404        }
405        writer
406            .flush()
407            .await
408            .map_err(|e| CleanLibraryError::Parse(format!("flush artifact stream: {}", e)))?;
409        Ok(total)
410    }
411
412    /// Query customer audit log via `GET /v1/audit` with optional filters.
413    /// Caller passes already-validated filter values.
414    ///
415    /// Cycle-14 DX-fix: corrected from `/v1/customer/audit` (which 404s) to
416    /// `/v1/audit` (which the App mounts via `verbs_router`). Verified live
417    /// against cleanapp.clnstrt.dev 2026-06-05 — direct probe returns 200
418    /// with `{window, records, backend_status}`.
419    ///
420    /// CLEANLIB-813: takes an [`AuditFilters`] options struct rather than
421    /// positional `Option<&str>` params. The real server
422    /// (`cleanlib-app/src/verbs.rs`) accepts a fourth filter, `until`, that
423    /// this SDK never exposed — Go's SDK already supports it. Adding it as
424    /// a 4th positional parameter would have been a breaking signature
425    /// change today AND set up another one the next time a filter is
426    /// added; an options struct absorbs `until` now and any future filter
427    /// later as a purely additive field, so every existing
428    /// `..Default::default()`-built call site keeps compiling. This IS a
429    /// breaking change for existing positional callers (Rust has no
430    /// source-compat path from 3 positional params to a struct) — the
431    /// migration cost is paid once, here, rather than deferred to the next
432    /// filter add.
433    pub async fn audit(
434        &self,
435        filters: AuditFilters<'_>,
436    ) -> Result<AuditResponse, CleanLibraryError> {
437        let path = format!("{}/audit", self.api_version);
438        let mut url = self
439            .base_url
440            .join(&path)
441            .map_err(|e| TransportError::InvalidUrl(format!("{}: {}", path, e)))?;
442        {
443            let mut q = url.query_pairs_mut();
444            if let Some(s) = filters.since {
445                q.append_pair("since", s);
446            }
447            // CLEANLIB-813: the one genuinely new filter this ticket exists
448            // for — bounds the END of the audit query's time window, mirroring
449            // Go's SDK. `cleanlib-app/src/verbs.rs` already accepts it; only
450            // this SDK's `audit()` never threaded it through.
451            if let Some(u) = filters.until {
452                q.append_pair("until", u);
453            }
454            if let Some(d) = filters.decision {
455                q.append_pair("decision", d);
456            }
457            if let Some(e) = filters.ecosystem {
458                q.append_pair("ecosystem", e);
459            }
460        }
461
462        let response = self.send(Method::GET, url).await?;
463        let status = response.status();
464        let headers = response.headers().clone();
465        let body = response.text().await.map_err(TransportError::Network)?;
466
467        if !status.is_success() {
468            return Err(from_http(status.as_u16(), &headers, &body));
469        }
470
471        let mut resp: AuditResponse = serde_json::from_str(&body)
472            .map_err(|e| CleanLibraryError::Parse(format!("audit response: {}", e)))?;
473        // CLEANLIB-480 · stamp `x-request-id` from the response header — the
474        // per-CALL identifier, distinct from `AuditEntry::request_id` (which
475        // is the record-scoped per-row id persisted in the audit log).
476        resp.request_id = extract_request_id(&headers);
477        Ok(resp)
478    }
479
480        pub async fn probe_auth(&self) -> Result<(), CleanLibraryError> {
481        let path = format!("{}/audit", self.api_version);
482        let url = self
483            .base_url
484            .join(&path)
485            .map_err(|e| TransportError::InvalidUrl(format!("{}: {}", path, e)))?;
486        let response = self.send(Method::GET, url).await?;
487        let status = response.status();
488        let headers = response.headers().clone();
489        let body = response.text().await.map_err(TransportError::Network)?;
490        if status.is_success() {
491            return Ok(());
492        }
493        Err(from_http(status.as_u16(), &headers, &body))
494    }
495
496    /// Low-level: send a request with body + content-type + auth header.
497    async fn send_with_body(
498        &self,
499        method: Method,
500        url: Url,
501        body: Vec<u8>,
502        content_type: &str,
503    ) -> Result<reqwest::Response, CleanLibraryError> {
504        let mut req = self.http.request(method, url).body(body);
505        req = req.header(header::CONTENT_TYPE, content_type);
506        if let Some(key) = &self.api_key {
507            req = req.header(header::AUTHORIZATION, format!("Bearer {}", key));
508        }
509        req.send().await.map_err(|e| {
510            if e.is_timeout() {
511                CleanLibraryError::Transport(TransportError::Timeout)
512            } else {
513                CleanLibraryError::Transport(TransportError::Network(e))
514            }
515        })
516    }
517
518    /// Low-level: send a request with auth header. Used internally by verb
519    /// methods; public for advanced consumers (future).
520    pub async fn send(
521        &self,
522        method: Method,
523        url: Url,
524    ) -> Result<reqwest::Response, CleanLibraryError> {
525        let mut req = self.http.request(method, url);
526        if let Some(key) = &self.api_key {
527            req = req.header(header::AUTHORIZATION, format!("Bearer {}", key));
528        }
529        req.send().await.map_err(|e| {
530            if e.is_timeout() {
531                CleanLibraryError::Transport(TransportError::Timeout)
532            } else {
533                CleanLibraryError::Transport(TransportError::Network(e))
534            }
535        })
536    }
537
538    /// Expose the base URL for diagnostics + integration tests.
539    pub fn base_url(&self) -> &Url {
540        &self.base_url
541    }
542
543    /// Fetch supported ecosystems from GET /health ecosystems_mounted field.
544    pub async fn get_ecosystems(&self) -> Result<Vec<String>, CleanLibraryError> {
545        let url = self.base_url.join("/health")
546            .map_err(|e| CleanLibraryError::Transport(
547                TransportError::InvalidUrl(e.to_string())
548            ))?;
549        let resp = self.http.get(url).send().await.map_err(|e| {
550            CleanLibraryError::Transport(TransportError::Network(e))
551        })?;
552        let body_text = resp.text().await.map_err(|e| {
553            CleanLibraryError::Transport(TransportError::Network(e))
554        })?;
555        let body: serde_json::Value = serde_json::from_str(&body_text)
556            .map_err(|e| CleanLibraryError::Parse(e.to_string()))?;
557        let ecosystems = body["ecosystems_mounted"]
558            .as_array()
559            .unwrap_or(&vec![])
560            .iter()
561            .filter_map(|v| v.as_str().map(|s| s.to_string()))
562            .collect();
563        Ok(ecosystems)
564    }
565}
566
567/// CLEANLIB-480 · pull the `x-request-id` header off a response. Returns
568/// `None` when the header is absent (older App builds pre-CLEANLIB-470) or
569/// when the value is not UTF-8 (defence-in-depth — App always emits ASCII
570/// ULIDs, so this is unreachable on a well-behaved server). HTTP header
571/// names are case-insensitive so `x-request-id` matches `X-Request-Id`
572/// either way. Free-standing so both the transport verbs and the tests
573/// can share it.
574pub(crate) fn extract_request_id(
575    headers: &reqwest::header::HeaderMap,
576) -> Option<String> {
577    headers
578        .get("x-request-id")
579        .and_then(|v| v.to_str().ok())
580        .map(|s| s.to_string())
581}
582
583/// Emit App's verdict + reason headers (when present) to stderr. Keeps
584/// stdout clean for binary content + scripted consumers; visible to TTY
585/// users for verdict awareness.
586fn emit_decision_headers(headers: &reqwest::header::HeaderMap) {
587    if let Some(decision) = headers
588        .get("X-CleanLibrary-Decision")
589        .and_then(|v| v.to_str().ok())
590    {
591        eprintln!("# decision: {}", decision);
592    }
593    if let Some(reason) = headers
594        .get("X-CleanLibrary-Reason")
595        .and_then(|v| v.to_str().ok())
596    {
597        eprintln!("# reason: {}", reason);
598    }
599}
600
601/// Build the App-side catalog-proxy URL for a triple. Post-CLEANLIB-368 this
602/// is the ONLY fetch URL shape the client emits: `GET /{api_version}/fetch/
603/// {ecosystem}/{package}/{version}` — the unified verb the App mounts in
604/// `verbs_router` (see `cleanlib-app/src/verbs.rs`), which returns
605/// `application/octet-stream` on catalog-hit and a structured JSON 404 on
606/// miss. Per CLEANLIB-302 this is the surface that emits the fetch-audit
607/// `AuditRow` with `gcs_hit` / `gcs_object_path` / `bytes_served`
608/// populated from the real catalog outcome — the substrate the
609/// `/v1/audit` cache-hit-ratio metric consumes.
610///
611/// Every triple component is percent-encoded so path-embedded slashes
612/// (npm-scoped `@scope/name`, go-module `github.com/…/…`) survive the
613/// App's axum `Path<(String, String, String)>` extractor as a single
614/// segment each.
615fn build_fetch_url(
616    base: &Url,
617    api_version: &str,
618    ecosystem: &str,
619    package: &str,
620    version: &str,
621) -> Result<Url, TransportError> {
622    let path = format!(
623        "{}/fetch/{}/{}/{}",
624        api_version,
625        urlencode(ecosystem),
626        urlencode(package),
627        urlencode(version),
628    );
629    base.join(&path)
630        .map_err(|e| TransportError::InvalidUrl(format!("{}: {}", path, e)))
631}
632
633/// Build the remediation URL. `facade` (customer) → `{api_version}/customer/
634/// remediation/{eco}/{pkg}` on the cleanapp base; `!facade` (internal) → the
635/// pre-facade `api/{api_version}/remediation/{eco}/{pkg}` direct path. Segments
636/// go through `urlencode`, so a scoped npm package (`@scope/name`) becomes a
637/// single `%2F`-encoded segment (CLEANLIB-737 guard) rather than a split path.
638fn build_remediation_url(
639    base: &Url,
640    api_version: &str,
641    facade: bool,
642    ecosystem: &str,
643    package: &str,
644) -> Result<Url, TransportError> {
645    let path = if facade {
646        format!(
647            "{}/customer/remediation/{}/{}",
648            api_version,
649            urlencode(ecosystem),
650            urlencode(package),
651        )
652    } else {
653        format!(
654            "api/{}/remediation/{}/{}",
655            api_version,
656            urlencode(ecosystem),
657            urlencode(package),
658        )
659    };
660    base.join(&path)
661        .map_err(|e| TransportError::InvalidUrl(format!("{}: {}", path, e)))
662}
663
664/// URL-encode a path segment. Covers `@` + `/` + reserved chars + unicode.
665/// Avoids pulling the full `url` crate as a separate dep (re-export is via
666/// `reqwest::Url`).
667fn urlencode(s: &str) -> String {
668    let mut out = String::with_capacity(s.len());
669    for c in s.chars() {
670        match c {
671            'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => out.push(c),
672            _ => {
673                let mut buf = [0u8; 4];
674                let encoded = c.encode_utf8(&mut buf);
675                for b in encoded.bytes() {
676                    out.push_str(&format!("%{:02X}", b));
677                }
678            }
679        }
680    }
681    out
682}
683
684#[cfg(test)]
685mod tests {
686    use super::*;
687    use crate::config::{Config, EndpointConfig};
688
689    fn cfg(url: &str) -> Config {
690        let mut c = Config::default();
691        c.endpoint = EndpointConfig {
692            url: url.to_string(),
693            api_version: "v1".to_string(),
694        };
695        c
696    }
697
698    #[test]
699    fn refuses_remote_plaintext() {
700        let err = Client::from_config(&cfg("http://cleanapp.clnstrt.dev")).unwrap_err();
701        assert!(matches!(
702            err,
703            CleanLibraryError::Transport(TransportError::TlsRequired(_))
704        ));
705    }
706
707    #[test]
708    fn allows_localhost_plaintext_for_testing() {
709        let client = Client::new("http://localhost:8080", None).unwrap();
710        assert_eq!(client.base_url().host_str(), Some("localhost"));
711    }
712
713    #[test]
714    fn allows_127_loopback_plaintext() {
715        let client = Client::new("http://127.0.0.1:8080", None).unwrap();
716        assert_eq!(client.base_url().host_str(), Some("127.0.0.1"));
717    }
718
719    #[test]
720    fn accepts_https_endpoint() {
721        let client = Client::from_config(&cfg("https://cleanapp.clnstrt.dev")).unwrap();
722        assert_eq!(client.base_url().as_str(), "https://cleanapp.clnstrt.dev/");
723    }
724
725    #[test]
726    fn rejects_invalid_url() {
727        let err = Client::from_config(&cfg("not a url")).unwrap_err();
728        assert!(matches!(
729            err,
730            CleanLibraryError::Transport(TransportError::InvalidUrl(_))
731        ));
732    }
733
734    #[test]
735    fn urlencode_npm_scoped_pkg() {
736        // @scope/pkg → %40scope%2Fpkg for safe URL embedding
737        assert_eq!(urlencode("@my-org/foo"), "%40my-org%2Ffoo");
738    }
739
740    // ─── CLEANLIB-733 Rust remediation client (step 4) ──────────────────────
741    #[test]
742    fn remediation_url_facade_is_customer_path() {
743        let base = Url::parse("https://cleanapp.clnstrt.dev").unwrap();
744        let u = build_remediation_url(&base, "v1", true, "npm", "cors").unwrap();
745        assert_eq!(u.path(), "/v1/customer/remediation/npm/cors");
746    }
747
748    #[test]
749    fn remediation_url_direct_is_api_v1_path() {
750        // Internal/producer-bearer mode preserves the pre-facade path (item-3).
751        let base = Url::parse("https://cleanlib-enrich.clnstrt.dev").unwrap();
752        let u = build_remediation_url(&base, "v1", false, "npm", "cors").unwrap();
753        assert_eq!(u.path(), "/api/v1/remediation/npm/cors");
754    }
755
756    #[test]
757    fn remediation_url_scoped_package_slash_is_percent_encoded() {
758        // CLEANLIB-737 guard: a scoped npm package reaches the wire as a SINGLE
759        // %2F-encoded segment, never a bare "/" that would split into an extra
760        // segment and bind the wrong upstream coordinate.
761        let base = Url::parse("https://cleanapp.clnstrt.dev").unwrap();
762        let u = build_remediation_url(&base, "v1", true, "npm", "@babel/core").unwrap();
763        assert!(u.as_str().contains("%2F"), "scope slash not encoded: {u}");
764        assert!(
765            u.as_str().ends_with("/npm/%40babel%2Fcore"),
766            "want a single %2F-encoded scoped segment, got {u}"
767        );
768    }
769
770    #[test]
771    fn urlencode_passes_simple() {
772        assert_eq!(urlencode("lodash"), "lodash");
773        assert_eq!(urlencode("4.17.21"), "4.17.21");
774        assert_eq!(urlencode("github.com/sirupsen/logrus"), "github.com%2Fsirupsen%2Flogrus");
775    }
776
777    #[test]
778    fn urlencode_maven_coordinate_encodes_colon() {
779        // CLEANLIB S3 / A3 Maven contract: maven package = groupId:artifactId;
780        // the colon must percent-encode to %3A so the wire coordinate is
781        // byte-identical across all 4 SDKs (sdk-go escapePathSeg / js
782        // encodeURIComponent / py quote(safe='')). The allowlist urlencode
783        // already does this — this test locks it against regression.
784        assert_eq!(
785            urlencode("org.springframework:spring-beans"),
786            "org.springframework%3Aspring-beans"
787        );
788        assert_eq!(
789            urlencode("org.apache.logging.log4j:log4j-core"),
790            "org.apache.logging.log4j%3Alog4j-core"
791        );
792    }
793
794    #[test]
795    fn urlencode_handles_unicode() {
796        // UTF-8 multi-byte chars get percent-encoded byte-by-byte
797        assert_eq!(urlencode("é"), "%C3%A9");
798    }
799
800    fn base() -> Url {
801        Url::parse("https://cleanapp.clnstrt.dev").unwrap()
802    }
803
804    // CLEANLIB-368 route pivot: every ecosystem now resolves to the App's
805    // unified `/{api_version}/fetch/{ecosystem}/{package}/{version}` catalog
806    // proxy. The client no longer emits registry-mimic per-ecosystem paths
807    // (`/npm/…/-/….tgz`, `/go/…/@v/….zip`, `/pypi/…/…-….tar.gz`) — those
808    // URL-shape assertions are retired because they are the pre-pivot bug.
809    #[test]
810    fn fetch_url_routes_through_app_v1_fetch_for_npm_bare() {
811        let url = build_fetch_url(&base(), "v1", "npm", "lodash", "4.17.21").unwrap();
812        assert_eq!(
813            url.as_str(),
814            "https://cleanapp.clnstrt.dev/v1/fetch/npm/lodash/4.17.21"
815        );
816    }
817
818    #[test]
819    fn fetch_url_npm_scoped_encodes_at_and_slash() {
820        // `@my-org/foo` must survive the App's Path extractor as a single
821        // `{package}` segment — so `@` → `%40` and `/` → `%2F`.
822        let url = build_fetch_url(&base(), "v1", "npm", "@my-org/foo", "1.0.0").unwrap();
823        assert_eq!(
824            url.as_str(),
825            "https://cleanapp.clnstrt.dev/v1/fetch/npm/%40my-org%2Ffoo/1.0.0"
826        );
827    }
828
829    #[test]
830    fn fetch_url_go_module_path_slashes_encoded() {
831        // `github.com/sirupsen/logrus` is one package identifier; its
832        // internal `/`s must not split into extra path segments.
833        let url = build_fetch_url(&base(), "v1", "go", "github.com/sirupsen/logrus", "v1.9.0")
834            .unwrap();
835        assert_eq!(
836            url.as_str(),
837            "https://cleanapp.clnstrt.dev/v1/fetch/go/github.com%2Fsirupsen%2Flogrus/v1.9.0"
838        );
839    }
840
841    #[test]
842    fn fetch_url_pypi_routes_through_v1_fetch_not_registry_mimic() {
843        let url = build_fetch_url(&base(), "v1", "pypi", "requests", "2.31.0").unwrap();
844        assert_eq!(
845            url.as_str(),
846            "https://cleanapp.clnstrt.dev/v1/fetch/pypi/requests/2.31.0"
847        );
848        // Explicitly assert the legacy pypi shape is NOT emitted.
849        assert!(!url.as_str().contains("/pypi/requests/requests-"));
850    }
851
852    #[test]
853    fn fetch_url_ecosystem_client_side_unopinionated() {
854        // Post-pivot the client no longer rejects any ecosystem name; the
855        // App owns catalog-lookup + returns a structured 404 on miss. This
856        // closes the CLEANLIB-368 gap that had maven/crates/nuget/rubygems
857        // failing at the client with `Phase 1 Tier A` before even hitting
858        // the wire.
859        for eco in ["maven", "crates", "nuget", "rubygems", "composer"] {
860            let url = build_fetch_url(&base(), "v1", eco, "somepkg", "1.0.0").unwrap();
861            assert_eq!(
862                url.as_str(),
863                format!("https://cleanapp.clnstrt.dev/v1/fetch/{}/somepkg/1.0.0", eco)
864            );
865        }
866    }
867
868    #[test]
869    fn fetch_url_maven_coordinates_group_id_encoded() {
870        // Maven coordinates use `group:artifact` (no `/`), but the `:` is a
871        // reserved char in URL segments — urlencode covers it.
872        let url = build_fetch_url(&base(), "v1", "maven", "junit:junit", "4.13.2").unwrap();
873        assert_eq!(
874            url.as_str(),
875            "https://cleanapp.clnstrt.dev/v1/fetch/maven/junit%3Ajunit/4.13.2"
876        );
877    }
878
879    #[test]
880    fn fetch_url_honors_configured_api_version() {
881        // Future-proof: if the config carries `api_version = "v2"`, the
882        // client picks it up — no hard-coded `v1` in transport.
883        let url = build_fetch_url(&base(), "v2", "npm", "lodash", "4.17.21").unwrap();
884        assert_eq!(
885            url.as_str(),
886            "https://cleanapp.clnstrt.dev/v2/fetch/npm/lodash/4.17.21"
887        );
888    }
889
890    // ── CLEANLIB-480 · x-request-id header extraction ────────────────────
891
892    fn header_map(pairs: &[(&str, &str)]) -> reqwest::header::HeaderMap {
893        let mut m = reqwest::header::HeaderMap::new();
894        for (k, v) in pairs {
895            m.insert(
896                reqwest::header::HeaderName::from_bytes(k.as_bytes()).unwrap(),
897                reqwest::header::HeaderValue::from_str(v).unwrap(),
898            );
899        }
900        m
901    }
902
903    #[test]
904    fn cleanlib_480_extract_request_id_reads_lowercase_header() {
905        // App emit is lowercase per CLEANLIB-470 wire; extract must match.
906        let h = header_map(&[("x-request-id", "01M1KWQ41SPRAP551FGW5ZN4RF")]);
907        assert_eq!(
908            extract_request_id(&h).as_deref(),
909            Some("01M1KWQ41SPRAP551FGW5ZN4RF")
910        );
911    }
912
913    #[test]
914    fn cleanlib_480_extract_request_id_reads_mixed_case_header() {
915        // HTTP header names are case-insensitive per RFC; the client must
916        // read `X-Request-Id` identically to `x-request-id` so a future App
917        // header casing change doesn't silently drop the id.
918        let h = header_map(&[("X-Request-Id", "01ABC")]);
919        assert_eq!(extract_request_id(&h).as_deref(), Some("01ABC"));
920    }
921
922    #[test]
923    fn cleanlib_480_extract_request_id_absent_returns_none() {
924        // Older App builds pre-CLEANLIB-470 emit no header — SDK callers
925        // see `None`, not an error.
926        let h = header_map(&[("content-type", "application/json")]);
927        assert!(extract_request_id(&h).is_none());
928    }
929
930    #[test]
931    fn cleanlib_480_extract_request_id_non_utf8_value_returns_none() {
932        // Defence-in-depth: a non-UTF-8 header value (never emitted by the
933        // App, but a proxy could inject one) must not panic or corrupt the
934        // returned Option — the client returns None and the SDK caller
935        // falls back to a null request_id.
936        let mut h = reqwest::header::HeaderMap::new();
937        h.insert(
938            reqwest::header::HeaderName::from_static("x-request-id"),
939            reqwest::header::HeaderValue::from_bytes(&[0x80, 0xFF]).unwrap(),
940        );
941        assert!(extract_request_id(&h).is_none());
942    }
943}