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}