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::config::Config;
13use crate::errors::{from_http, CleanLibraryError, TransportError};
14use crate::types::{AuditResponse, PolicyPreviewRequest, PolicyPreviewResponse, Verdict};
15
16/// Default HTTP timeout per Rev 2 amendment §4.1 — allows 1-5s GCS-catalog
17/// first-request-ingest + buffer.
18const DEFAULT_TIMEOUT_SECS: u64 = 30;
19
20/// `Client` is the cleanlib-client HTTP transport handle. Cheap to clone;
21/// share across verbs in the same process.
22#[derive(Debug, Clone)]
23pub struct Client {
24    http: ReqwestClient,
25    base_url: Url,
26    api_key: Option<String>,
27    api_version: String,
28}
29
30impl Client {
31    /// Construct from a loaded `Config`. TLS required for non-localhost
32    /// endpoints. Returns `TlsRequired` for `http://` on remote hosts.
33    pub fn from_config(config: &Config) -> Result<Self, CleanLibraryError> {
34        Self::build(&config.endpoint.url, config.auth.api_key.clone(), &config.endpoint.api_version)
35    }
36
37    /// Construct with explicit endpoint + api_key. Intended for integration
38    /// tests + ad-hoc invocations (e.g., CLI `--endpoint=` flag future).
39    pub fn new(endpoint: &str, api_key: Option<String>) -> Result<Self, CleanLibraryError> {
40        Self::build(endpoint, api_key, "v1")
41    }
42
43    fn build(
44        endpoint: &str,
45        api_key: Option<String>,
46        api_version: &str,
47    ) -> Result<Self, CleanLibraryError> {
48        let base_url = Url::parse(endpoint)
49            .map_err(|e| TransportError::InvalidUrl(format!("{}: {}", endpoint, e)))?;
50
51        let is_localhost = matches!(
52            base_url.host_str(),
53            Some("localhost") | Some("127.0.0.1") | Some("::1")
54        );
55        if base_url.scheme() != "https" && !is_localhost {
56            return Err(TransportError::TlsRequired(endpoint.to_string()).into());
57        }
58
59        let http = ReqwestClient::builder()
60            .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
61            .user_agent(concat!("cleanlib-cli/", env!("CARGO_PKG_VERSION")))
62            .build()
63            .map_err(TransportError::Network)?;
64
65        Ok(Self {
66            http,
67            base_url,
68            api_key,
69            api_version: api_version.to_string(),
70        })
71    }
72
73    /// Fetch a single verdict per App Rev 4 §9.3 +
74    /// `GET /v1/customer/verdicts/{ecosystem}/{package}/{version}`.
75    pub async fn fetch_verdict(
76        &self,
77        ecosystem: &str,
78        package: &str,
79        version: &str,
80    ) -> Result<Verdict, CleanLibraryError> {
81        let path = format!(
82            "{}/customer/verdicts/{}/{}/{}",
83            self.api_version,
84            urlencode(ecosystem),
85            urlencode(package),
86            urlencode(version),
87        );
88        let url = self
89            .base_url
90            .join(&path)
91            .map_err(|e| TransportError::InvalidUrl(format!("{}: {}", path, e)))?;
92
93        let response = self.send(Method::GET, url).await?;
94        let status = response.status();
95        let headers = response.headers().clone();
96        let body = response.text().await.map_err(TransportError::Network)?;
97
98        if !status.is_success() {
99            return Err(from_http(status.as_u16(), &headers, &body));
100        }
101
102        serde_json::from_str(&body)
103            .map_err(|e| CleanLibraryError::Parse(format!("verdict response: {}", e)))
104    }
105
106    /// Submit a packages + optional-policy request to
107    /// `POST /v1/customer/policy/preview`. Used by `cleanlib scan` (no
108    /// policy override; preview against active customer policy) and
109    /// `cleanlib policy preview` (with explicit policy override).
110    pub async fn policy_preview(
111        &self,
112        req: &PolicyPreviewRequest,
113    ) -> Result<PolicyPreviewResponse, CleanLibraryError> {
114        let path = format!("{}/customer/policy/preview", self.api_version);
115        let url = self
116            .base_url
117            .join(&path)
118            .map_err(|e| TransportError::InvalidUrl(format!("{}: {}", path, e)))?;
119
120        let body = serde_json::to_vec(req)
121            .map_err(|e| CleanLibraryError::Parse(format!("policy_preview body: {}", e)))?;
122
123        let response = self.send_with_body(Method::POST, url, body, "application/json").await?;
124        let status = response.status();
125        let headers = response.headers().clone();
126        let body = response.text().await.map_err(TransportError::Network)?;
127
128        if !status.is_success() {
129            return Err(from_http(status.as_u16(), &headers, &body));
130        }
131
132        serde_json::from_str(&body)
133            .map_err(|e| CleanLibraryError::Parse(format!("policy_preview response: {}", e)))
134    }
135
136    /// Fetch the raw artifact bytes for `(ecosystem, package, version)` via
137    /// App's per-ecosystem registry proxy (App Rev 4 §5.4 + cycle-3 §C.8).
138    /// Returns owned `Vec<u8>` — caller decides write target. Emits decision
139    /// + reason headers to stderr for visibility (binary stdout stays clean).
140    pub async fn fetch_artifact(
141        &self,
142        ecosystem: &str,
143        package: &str,
144        version: &str,
145    ) -> Result<Vec<u8>, CleanLibraryError> {
146        let url = build_fetch_url(&self.base_url, ecosystem, package, version)?;
147        let response = self.send(Method::GET, url).await?;
148        let status = response.status();
149        let headers = response.headers().clone();
150
151        if !status.is_success() {
152            let body = response.text().await.map_err(TransportError::Network)?;
153            return Err(from_http(status.as_u16(), &headers, &body));
154        }
155
156        emit_decision_headers(&headers);
157
158        let bytes = response.bytes().await.map_err(TransportError::Network)?;
159        Ok(bytes.to_vec())
160    }
161
162    /// Streaming variant of [`Self::fetch_artifact`] — writes chunks to
163    /// `writer` without buffering the full body in memory. Per Client Rev 2
164    /// amendment §9.4 cycle-4 §D.5 streaming substrate. Returns total bytes
165    /// written. Decision + reason headers surface to stderr before stream.
166    pub async fn fetch_artifact_stream<W>(
167        &self,
168        ecosystem: &str,
169        package: &str,
170        version: &str,
171        writer: &mut W,
172    ) -> Result<u64, CleanLibraryError>
173    where
174        W: tokio::io::AsyncWrite + Unpin,
175    {
176        use futures_util::StreamExt;
177        use tokio::io::AsyncWriteExt;
178
179        let url = build_fetch_url(&self.base_url, ecosystem, package, version)?;
180        let response = self.send(Method::GET, url).await?;
181        let status = response.status();
182        let headers = response.headers().clone();
183
184        if !status.is_success() {
185            let body = response.text().await.map_err(TransportError::Network)?;
186            return Err(from_http(status.as_u16(), &headers, &body));
187        }
188
189        emit_decision_headers(&headers);
190
191        let mut total: u64 = 0;
192        let mut stream = response.bytes_stream();
193        while let Some(chunk) = stream.next().await {
194            let bytes = chunk.map_err(TransportError::Network)?;
195            writer
196                .write_all(&bytes)
197                .await
198                .map_err(|e| CleanLibraryError::Parse(format!("write artifact chunk: {}", e)))?;
199            total += bytes.len() as u64;
200        }
201        writer
202            .flush()
203            .await
204            .map_err(|e| CleanLibraryError::Parse(format!("flush artifact stream: {}", e)))?;
205        Ok(total)
206    }
207
208    /// Query customer audit log via `GET /v1/customer/audit` with optional
209    /// filters. Caller passes already-validated filter values.
210    pub async fn audit(
211        &self,
212        since: Option<&str>,
213        decision: Option<&str>,
214        ecosystem: Option<&str>,
215    ) -> Result<AuditResponse, CleanLibraryError> {
216        let path = format!("{}/customer/audit", self.api_version);
217        let mut url = self
218            .base_url
219            .join(&path)
220            .map_err(|e| TransportError::InvalidUrl(format!("{}: {}", path, e)))?;
221        {
222            let mut q = url.query_pairs_mut();
223            if let Some(s) = since {
224                q.append_pair("since", s);
225            }
226            if let Some(d) = decision {
227                q.append_pair("decision", d);
228            }
229            if let Some(e) = ecosystem {
230                q.append_pair("ecosystem", e);
231            }
232        }
233
234        let response = self.send(Method::GET, url).await?;
235        let status = response.status();
236        let headers = response.headers().clone();
237        let body = response.text().await.map_err(TransportError::Network)?;
238
239        if !status.is_success() {
240            return Err(from_http(status.as_u16(), &headers, &body));
241        }
242
243        serde_json::from_str(&body)
244            .map_err(|e| CleanLibraryError::Parse(format!("audit response: {}", e)))
245    }
246
247    /// Low-level: send a request with body + content-type + auth header.
248    async fn send_with_body(
249        &self,
250        method: Method,
251        url: Url,
252        body: Vec<u8>,
253        content_type: &str,
254    ) -> Result<reqwest::Response, CleanLibraryError> {
255        let mut req = self.http.request(method, url).body(body);
256        req = req.header(header::CONTENT_TYPE, content_type);
257        if let Some(key) = &self.api_key {
258            req = req.header(header::AUTHORIZATION, format!("Bearer {}", key));
259        }
260        req.send().await.map_err(|e| {
261            if e.is_timeout() {
262                CleanLibraryError::Transport(TransportError::Timeout)
263            } else {
264                CleanLibraryError::Transport(TransportError::Network(e))
265            }
266        })
267    }
268
269    /// Low-level: send a request with auth header. Used internally by verb
270    /// methods; public for advanced consumers (future).
271    pub async fn send(
272        &self,
273        method: Method,
274        url: Url,
275    ) -> Result<reqwest::Response, CleanLibraryError> {
276        let mut req = self.http.request(method, url);
277        if let Some(key) = &self.api_key {
278            req = req.header(header::AUTHORIZATION, format!("Bearer {}", key));
279        }
280        req.send().await.map_err(|e| {
281            if e.is_timeout() {
282                CleanLibraryError::Transport(TransportError::Timeout)
283            } else {
284                CleanLibraryError::Transport(TransportError::Network(e))
285            }
286        })
287    }
288
289    /// Expose the base URL for diagnostics + integration tests.
290    pub fn base_url(&self) -> &Url {
291        &self.base_url
292    }
293}
294
295/// Emit App's verdict + reason headers (when present) to stderr. Keeps
296/// stdout clean for binary content + scripted consumers; visible to TTY
297/// users for verdict awareness.
298fn emit_decision_headers(headers: &reqwest::header::HeaderMap) {
299    if let Some(decision) = headers
300        .get("X-CleanLibrary-Decision")
301        .and_then(|v| v.to_str().ok())
302    {
303        eprintln!("# decision: {}", decision);
304    }
305    if let Some(reason) = headers
306        .get("X-CleanLibrary-Reason")
307        .and_then(|v| v.to_str().ok())
308    {
309        eprintln!("# reason: {}", reason);
310    }
311}
312
313/// Build per-ecosystem artifact-fetch URL per App Rev 4 §5.4 + cycle-3 §C.8
314/// ecosystem composition. Phase 1 Tier A: npm + pypi + go. Tier B/C
315/// (maven + crates + nuget + rubygems) added cycle-3 §B.23/§B.24 but their
316/// fetch paths are deferred — return `InvalidUrl` for now.
317fn build_fetch_url(
318    base: &Url,
319    ecosystem: &str,
320    package: &str,
321    version: &str,
322) -> Result<Url, TransportError> {
323    let path = match ecosystem {
324        "npm" => {
325            // npm tarball path: /npm/<pkg>/-/<pkg>-<ver>.tgz
326            // Scoped: /npm/@scope/<name>/-/<name>-<ver>.tgz (name only in filename)
327            if let Some(stripped) = package.strip_prefix('@') {
328                let (scope, name) = stripped.split_once('/').ok_or_else(|| {
329                    TransportError::InvalidUrl(format!(
330                        "npm scoped package missing /name: {}",
331                        package
332                    ))
333                })?;
334                format!("npm/@{}/{}/-/{}-{}.tgz", scope, name, name, version)
335            } else {
336                format!("npm/{}/-/{}-{}.tgz", package, package, version)
337            }
338        }
339        "go" => format!("go/{}/@v/{}.zip", package, version),
340        "pypi" => {
341            // Phase 1 MVP shape — App-side serves simplified path; wheel-
342            // variant resolution deferred (would need PEP 425 platform tag
343            // selection client-side OR App-side variant negotiation).
344            format!("pypi/{}/{}-{}.tar.gz", package, package, version)
345        }
346        other => {
347            return Err(TransportError::InvalidUrl(format!(
348                "ecosystem not yet supported by `cleanlib fetch`: {} (Phase 1 Tier A: npm, pypi, go)",
349                other
350            )))
351        }
352    };
353    base.join(&path)
354        .map_err(|e| TransportError::InvalidUrl(format!("{}: {}", path, e)))
355}
356
357/// URL-encode a path segment. Covers `@` + `/` + reserved chars + unicode.
358/// Avoids pulling the full `url` crate as a separate dep (re-export is via
359/// `reqwest::Url`).
360fn urlencode(s: &str) -> String {
361    let mut out = String::with_capacity(s.len());
362    for c in s.chars() {
363        match c {
364            'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => out.push(c),
365            _ => {
366                let mut buf = [0u8; 4];
367                let encoded = c.encode_utf8(&mut buf);
368                for b in encoded.bytes() {
369                    out.push_str(&format!("%{:02X}", b));
370                }
371            }
372        }
373    }
374    out
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380    use crate::config::{Config, EndpointConfig};
381
382    fn cfg(url: &str) -> Config {
383        let mut c = Config::default();
384        c.endpoint = EndpointConfig {
385            url: url.to_string(),
386            api_version: "v1".to_string(),
387        };
388        c
389    }
390
391    #[test]
392    fn refuses_remote_plaintext() {
393        let err = Client::from_config(&cfg("http://cleanapp.clnstrt.dev")).unwrap_err();
394        assert!(matches!(
395            err,
396            CleanLibraryError::Transport(TransportError::TlsRequired(_))
397        ));
398    }
399
400    #[test]
401    fn allows_localhost_plaintext_for_testing() {
402        let client = Client::new("http://localhost:8080", None).unwrap();
403        assert_eq!(client.base_url().host_str(), Some("localhost"));
404    }
405
406    #[test]
407    fn allows_127_loopback_plaintext() {
408        let client = Client::new("http://127.0.0.1:8080", None).unwrap();
409        assert_eq!(client.base_url().host_str(), Some("127.0.0.1"));
410    }
411
412    #[test]
413    fn accepts_https_endpoint() {
414        let client = Client::from_config(&cfg("https://cleanapp.clnstrt.dev")).unwrap();
415        assert_eq!(client.base_url().as_str(), "https://cleanapp.clnstrt.dev/");
416    }
417
418    #[test]
419    fn rejects_invalid_url() {
420        let err = Client::from_config(&cfg("not a url")).unwrap_err();
421        assert!(matches!(
422            err,
423            CleanLibraryError::Transport(TransportError::InvalidUrl(_))
424        ));
425    }
426
427    #[test]
428    fn urlencode_npm_scoped_pkg() {
429        // @scope/pkg → %40scope%2Fpkg for safe URL embedding
430        assert_eq!(urlencode("@my-org/foo"), "%40my-org%2Ffoo");
431    }
432
433    #[test]
434    fn urlencode_passes_simple() {
435        assert_eq!(urlencode("lodash"), "lodash");
436        assert_eq!(urlencode("4.17.21"), "4.17.21");
437        assert_eq!(urlencode("github.com/sirupsen/logrus"), "github.com%2Fsirupsen%2Flogrus");
438    }
439
440    #[test]
441    fn urlencode_handles_unicode() {
442        // UTF-8 multi-byte chars get percent-encoded byte-by-byte
443        assert_eq!(urlencode("é"), "%C3%A9");
444    }
445
446    fn base() -> Url {
447        Url::parse("https://cleanapp.clnstrt.dev").unwrap()
448    }
449
450    #[test]
451    fn fetch_url_npm_bare() {
452        let url = build_fetch_url(&base(), "npm", "lodash", "4.17.21").unwrap();
453        assert_eq!(
454            url.as_str(),
455            "https://cleanapp.clnstrt.dev/npm/lodash/-/lodash-4.17.21.tgz"
456        );
457    }
458
459    #[test]
460    fn fetch_url_npm_scoped() {
461        let url = build_fetch_url(&base(), "npm", "@my-org/foo", "1.0.0").unwrap();
462        assert_eq!(
463            url.as_str(),
464            "https://cleanapp.clnstrt.dev/npm/@my-org/foo/-/foo-1.0.0.tgz"
465        );
466    }
467
468    #[test]
469    fn fetch_url_npm_scoped_malformed() {
470        let err = build_fetch_url(&base(), "npm", "@my-org", "1.0.0").unwrap_err();
471        assert!(matches!(err, TransportError::InvalidUrl(_)));
472    }
473
474    #[test]
475    fn fetch_url_go() {
476        let url = build_fetch_url(&base(), "go", "github.com/sirupsen/logrus", "v1.9.0").unwrap();
477        assert_eq!(
478            url.as_str(),
479            "https://cleanapp.clnstrt.dev/go/github.com/sirupsen/logrus/@v/v1.9.0.zip"
480        );
481    }
482
483    #[test]
484    fn fetch_url_pypi() {
485        let url = build_fetch_url(&base(), "pypi", "requests", "2.31.0").unwrap();
486        assert_eq!(
487            url.as_str(),
488            "https://cleanapp.clnstrt.dev/pypi/requests/requests-2.31.0.tar.gz"
489        );
490    }
491
492    #[test]
493    fn fetch_url_unknown_ecosystem_errors() {
494        let err = build_fetch_url(&base(), "maven", "junit:junit", "4.13.2").unwrap_err();
495        match err {
496            TransportError::InvalidUrl(msg) => {
497                assert!(msg.contains("Phase 1 Tier A"));
498            }
499            other => panic!("expected InvalidUrl, got {:?}", other),
500        }
501    }
502}