1use 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
16const DEFAULT_TIMEOUT_SECS: u64 = 30;
19
20#[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 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 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 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 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 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 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 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 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 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 pub fn base_url(&self) -> &Url {
291 &self.base_url
292 }
293}
294
295fn 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
313fn 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 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 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
357fn 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 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 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}