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