1use 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
19const DEFAULT_TIMEOUT_SECS: u64 = 30;
22
23#[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#[derive(Debug, Clone)]
37pub enum RemediationOutcome {
38 Present(serde_json::Value),
41 NotInSubstrate,
44 Transient(String),
46}
47
48#[derive(Debug, Clone, Copy, Default)]
63pub struct AuditFilters<'a> {
64 pub since: Option<&'a str>,
66 pub until: Option<&'a str>,
69 pub decision: Option<&'a str>,
73 pub ecosystem: Option<&'a str>,
75}
76
77impl Client {
78 pub fn from_config(config: &Config) -> Result<Self, CleanLibraryError> {
81 Self::build(&config.endpoint.url, config.auth.api_key.clone(), &config.endpoint.api_version)
82 }
83
84 pub fn new(endpoint: &str, api_key: Option<String>) -> Result<Self, CleanLibraryError> {
87 Self::build(endpoint, api_key, "v1")
88 }
89
90 fn build(
91 endpoint: &str,
92 api_key: Option<String>,
93 api_version: &str,
94 ) -> Result<Self, CleanLibraryError> {
95 let base_url = Url::parse(endpoint)
96 .map_err(|e| TransportError::InvalidUrl(format!("{}: {}", endpoint, e)))?;
97
98 let is_localhost = matches!(
99 base_url.host_str(),
100 Some("localhost") | Some("127.0.0.1") | Some("::1")
101 );
102 if base_url.scheme() != "https" && !is_localhost {
103 return Err(TransportError::TlsRequired(endpoint.to_string()).into());
104 }
105
106 let http = ReqwestClient::builder()
107 .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
108 .user_agent(concat!("cleanlib-cli/", env!("CARGO_PKG_VERSION")))
109 .build()
110 .map_err(TransportError::Network)?;
111
112 Ok(Self {
113 http,
114 base_url,
115 api_key,
116 api_version: api_version.to_string(),
117 })
118 }
119
120 pub async fn verify_attestation(&self, verdict: &Verdict) -> Result<(), CleanLibraryError> {
138 let envelope = verdict.attestation.as_ref().ok_or_else(|| {
139 CleanLibraryError::AttestationInvalid {
140 reason_code: "ATTESTATION_ABSENT".to_string(),
141 message: "verdict carries no attestation (attestation_status = \
142 signature_absent, or a v1/pre-attestation response)"
143 .to_string(),
144 }
145 })?;
146 let lookup = PinnedKeyMap::default();
147 crate::attestation_verify::verify_attestation(envelope, &lookup).await
148 }
149
150 pub async fn fetch_verdict(
153 &self,
154 ecosystem: &str,
155 package: &str,
156 version: &str,
157 ) -> Result<Verdict, CleanLibraryError> {
158 let path = format!(
159 "{}/customer/verdicts/{}/{}/{}",
160 self.api_version,
161 urlencode(ecosystem),
162 urlencode(package),
163 urlencode(version),
164 );
165 let url = self
166 .base_url
167 .join(&path)
168 .map_err(|e| TransportError::InvalidUrl(format!("{}: {}", path, e)))?;
169
170 let response = self.send(Method::GET, url).await?;
171 let status = response.status();
172 let headers = response.headers().clone();
173 let body = response.text().await.map_err(TransportError::Network)?;
174
175 if !status.is_success() {
176 return Err(from_http(status.as_u16(), &headers, &body));
177 }
178
179 serde_json::from_str(&body)
180 .map_err(|e| CleanLibraryError::Parse(format!("verdict response: {}", e)))
181 }
182
183 pub async fn scan(&self, req: &ScanRequest) -> Result<ScanResponse, CleanLibraryError> {
198 let path = format!("{}/scan", self.api_version);
199 let url = self
200 .base_url
201 .join(&path)
202 .map_err(|e| TransportError::InvalidUrl(format!("{}: {}", path, e)))?;
203
204 let body = serde_json::to_vec(req)
205 .map_err(|e| CleanLibraryError::Parse(format!("scan body: {}", e)))?;
206
207 let response = self
208 .send_with_body(Method::POST, url, body, "application/json")
209 .await?;
210 let status = response.status();
211 let headers = response.headers().clone();
212 let body = response.text().await.map_err(TransportError::Network)?;
213
214 if !status.is_success() {
215 return Err(from_http(status.as_u16(), &headers, &body));
216 }
217
218 let mut resp: ScanResponse = serde_json::from_str(&body)
219 .map_err(|e| CleanLibraryError::Parse(format!("scan response: {}", e)))?;
220 resp.request_id = extract_request_id(&headers);
224 Ok(resp)
225 }
226
227 pub async fn policy_preview(
239 &self,
240 req: &PolicyPreviewRequest,
241 ) -> Result<PolicyPreviewResponse, CleanLibraryError> {
242 let path = format!("{}/policy/preview", self.api_version);
243 let url = self
244 .base_url
245 .join(&path)
246 .map_err(|e| TransportError::InvalidUrl(format!("{}: {}", path, e)))?;
247
248 let body = serde_json::to_vec(req)
249 .map_err(|e| CleanLibraryError::Parse(format!("policy_preview body: {}", e)))?;
250
251 let response = self.send_with_body(Method::POST, url, body, "application/json").await?;
252 let status = response.status();
253 let headers = response.headers().clone();
254 let body = response.text().await.map_err(TransportError::Network)?;
255
256 if !status.is_success() {
257 return Err(from_http(status.as_u16(), &headers, &body));
258 }
259
260 let mut resp: PolicyPreviewResponse = serde_json::from_str(&body)
261 .map_err(|e| CleanLibraryError::Parse(format!("policy_preview response: {}", e)))?;
262 resp.request_id = extract_request_id(&headers);
264 Ok(resp)
265 }
266
267 pub async fn fetch_artifact(
282 &self,
283 ecosystem: &str,
284 package: &str,
285 version: &str,
286 ) -> Result<Vec<u8>, CleanLibraryError> {
287 let url = build_fetch_url(&self.base_url, &self.api_version, ecosystem, package, version)?;
288 let response = self.send(Method::GET, url).await?;
289 let status = response.status();
290 let headers = response.headers().clone();
291
292 if !status.is_success() {
293 let body = response.text().await.map_err(TransportError::Network)?;
294 return Err(from_http(status.as_u16(), &headers, &body));
295 }
296
297 emit_decision_headers(&headers);
298
299 let bytes = response.bytes().await.map_err(TransportError::Network)?;
300 Ok(bytes.to_vec())
301 }
302
303 pub async fn get_remediation(
319 &self,
320 ecosystem: &str,
321 package: &str,
322 ) -> Result<RemediationOutcome, CleanLibraryError> {
323 self.remediation_with_mode(ecosystem, package, true).await
324 }
325
326 pub async fn get_remediation_direct(
330 &self,
331 ecosystem: &str,
332 package: &str,
333 ) -> Result<RemediationOutcome, CleanLibraryError> {
334 self.remediation_with_mode(ecosystem, package, false).await
335 }
336
337 async fn remediation_with_mode(
338 &self,
339 ecosystem: &str,
340 package: &str,
341 facade: bool,
342 ) -> Result<RemediationOutcome, CleanLibraryError> {
343 let url = build_remediation_url(&self.base_url, &self.api_version, facade, ecosystem, package)?;
344 let response = self.send(Method::GET, url).await?;
345 let status = response.status();
346 if status.as_u16() == 404 {
347 return Ok(RemediationOutcome::NotInSubstrate);
348 }
349 if status.is_server_error() {
350 return Ok(RemediationOutcome::Transient(format!("HTTP {}", status.as_u16())));
351 }
352 if !status.is_success() {
353 let headers = response.headers().clone();
354 let body = response.text().await.map_err(TransportError::Network)?;
355 return Err(from_http(status.as_u16(), &headers, &body));
356 }
357 let body = response.text().await.map_err(TransportError::Network)?;
358 let json: serde_json::Value =
359 serde_json::from_str(&body).map_err(|e| CleanLibraryError::Parse(e.to_string()))?;
360 Ok(RemediationOutcome::Present(json))
361 }
362
363 pub async fn fetch_artifact_stream<W>(
371 &self,
372 ecosystem: &str,
373 package: &str,
374 version: &str,
375 writer: &mut W,
376 ) -> Result<u64, CleanLibraryError>
377 where
378 W: tokio::io::AsyncWrite + Unpin,
379 {
380 use futures_util::StreamExt;
381 use tokio::io::AsyncWriteExt;
382
383 let url = build_fetch_url(&self.base_url, &self.api_version, ecosystem, package, version)?;
384 let response = self.send(Method::GET, url).await?;
385 let status = response.status();
386 let headers = response.headers().clone();
387
388 if !status.is_success() {
389 let body = response.text().await.map_err(TransportError::Network)?;
390 return Err(from_http(status.as_u16(), &headers, &body));
391 }
392
393 emit_decision_headers(&headers);
394
395 let mut total: u64 = 0;
396 let mut stream = response.bytes_stream();
397 while let Some(chunk) = stream.next().await {
398 let bytes = chunk.map_err(TransportError::Network)?;
399 writer
400 .write_all(&bytes)
401 .await
402 .map_err(|e| CleanLibraryError::Parse(format!("write artifact chunk: {}", e)))?;
403 total += bytes.len() as u64;
404 }
405 writer
406 .flush()
407 .await
408 .map_err(|e| CleanLibraryError::Parse(format!("flush artifact stream: {}", e)))?;
409 Ok(total)
410 }
411
412 pub async fn audit(
434 &self,
435 filters: AuditFilters<'_>,
436 ) -> Result<AuditResponse, CleanLibraryError> {
437 let path = format!("{}/audit", self.api_version);
438 let mut url = self
439 .base_url
440 .join(&path)
441 .map_err(|e| TransportError::InvalidUrl(format!("{}: {}", path, e)))?;
442 {
443 let mut q = url.query_pairs_mut();
444 if let Some(s) = filters.since {
445 q.append_pair("since", s);
446 }
447 if let Some(u) = filters.until {
452 q.append_pair("until", u);
453 }
454 if let Some(d) = filters.decision {
455 q.append_pair("decision", d);
456 }
457 if let Some(e) = filters.ecosystem {
458 q.append_pair("ecosystem", e);
459 }
460 }
461
462 let response = self.send(Method::GET, url).await?;
463 let status = response.status();
464 let headers = response.headers().clone();
465 let body = response.text().await.map_err(TransportError::Network)?;
466
467 if !status.is_success() {
468 return Err(from_http(status.as_u16(), &headers, &body));
469 }
470
471 let mut resp: AuditResponse = serde_json::from_str(&body)
472 .map_err(|e| CleanLibraryError::Parse(format!("audit response: {}", e)))?;
473 resp.request_id = extract_request_id(&headers);
477 Ok(resp)
478 }
479
480 pub async fn probe_auth(&self) -> Result<(), CleanLibraryError> {
481 let path = format!("{}/audit", self.api_version);
482 let url = self
483 .base_url
484 .join(&path)
485 .map_err(|e| TransportError::InvalidUrl(format!("{}: {}", path, e)))?;
486 let response = self.send(Method::GET, url).await?;
487 let status = response.status();
488 let headers = response.headers().clone();
489 let body = response.text().await.map_err(TransportError::Network)?;
490 if status.is_success() {
491 return Ok(());
492 }
493 Err(from_http(status.as_u16(), &headers, &body))
494 }
495
496 async fn send_with_body(
498 &self,
499 method: Method,
500 url: Url,
501 body: Vec<u8>,
502 content_type: &str,
503 ) -> Result<reqwest::Response, CleanLibraryError> {
504 let mut req = self.http.request(method, url).body(body);
505 req = req.header(header::CONTENT_TYPE, content_type);
506 if let Some(key) = &self.api_key {
507 req = req.header(header::AUTHORIZATION, format!("Bearer {}", key));
508 }
509 req.send().await.map_err(|e| {
510 if e.is_timeout() {
511 CleanLibraryError::Transport(TransportError::Timeout)
512 } else {
513 CleanLibraryError::Transport(TransportError::Network(e))
514 }
515 })
516 }
517
518 pub async fn send(
521 &self,
522 method: Method,
523 url: Url,
524 ) -> Result<reqwest::Response, CleanLibraryError> {
525 let mut req = self.http.request(method, url);
526 if let Some(key) = &self.api_key {
527 req = req.header(header::AUTHORIZATION, format!("Bearer {}", key));
528 }
529 req.send().await.map_err(|e| {
530 if e.is_timeout() {
531 CleanLibraryError::Transport(TransportError::Timeout)
532 } else {
533 CleanLibraryError::Transport(TransportError::Network(e))
534 }
535 })
536 }
537
538 pub fn base_url(&self) -> &Url {
540 &self.base_url
541 }
542
543 pub async fn get_ecosystems(&self) -> Result<Vec<String>, CleanLibraryError> {
545 let url = self.base_url.join("/health")
546 .map_err(|e| CleanLibraryError::Transport(
547 TransportError::InvalidUrl(e.to_string())
548 ))?;
549 let resp = self.http.get(url).send().await.map_err(|e| {
550 CleanLibraryError::Transport(TransportError::Network(e))
551 })?;
552 let body_text = resp.text().await.map_err(|e| {
553 CleanLibraryError::Transport(TransportError::Network(e))
554 })?;
555 let body: serde_json::Value = serde_json::from_str(&body_text)
556 .map_err(|e| CleanLibraryError::Parse(e.to_string()))?;
557 let ecosystems = body["ecosystems_mounted"]
558 .as_array()
559 .unwrap_or(&vec![])
560 .iter()
561 .filter_map(|v| v.as_str().map(|s| s.to_string()))
562 .collect();
563 Ok(ecosystems)
564 }
565}
566
567pub(crate) fn extract_request_id(
575 headers: &reqwest::header::HeaderMap,
576) -> Option<String> {
577 headers
578 .get("x-request-id")
579 .and_then(|v| v.to_str().ok())
580 .map(|s| s.to_string())
581}
582
583fn emit_decision_headers(headers: &reqwest::header::HeaderMap) {
587 if let Some(decision) = headers
588 .get("X-CleanLibrary-Decision")
589 .and_then(|v| v.to_str().ok())
590 {
591 eprintln!("# decision: {}", decision);
592 }
593 if let Some(reason) = headers
594 .get("X-CleanLibrary-Reason")
595 .and_then(|v| v.to_str().ok())
596 {
597 eprintln!("# reason: {}", reason);
598 }
599}
600
601fn build_fetch_url(
616 base: &Url,
617 api_version: &str,
618 ecosystem: &str,
619 package: &str,
620 version: &str,
621) -> Result<Url, TransportError> {
622 let path = format!(
623 "{}/fetch/{}/{}/{}",
624 api_version,
625 urlencode(ecosystem),
626 urlencode(package),
627 urlencode(version),
628 );
629 base.join(&path)
630 .map_err(|e| TransportError::InvalidUrl(format!("{}: {}", path, e)))
631}
632
633fn build_remediation_url(
639 base: &Url,
640 api_version: &str,
641 facade: bool,
642 ecosystem: &str,
643 package: &str,
644) -> Result<Url, TransportError> {
645 let path = if facade {
646 format!(
647 "{}/customer/remediation/{}/{}",
648 api_version,
649 urlencode(ecosystem),
650 urlencode(package),
651 )
652 } else {
653 format!(
654 "api/{}/remediation/{}/{}",
655 api_version,
656 urlencode(ecosystem),
657 urlencode(package),
658 )
659 };
660 base.join(&path)
661 .map_err(|e| TransportError::InvalidUrl(format!("{}: {}", path, e)))
662}
663
664fn urlencode(s: &str) -> String {
668 let mut out = String::with_capacity(s.len());
669 for c in s.chars() {
670 match c {
671 'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => out.push(c),
672 _ => {
673 let mut buf = [0u8; 4];
674 let encoded = c.encode_utf8(&mut buf);
675 for b in encoded.bytes() {
676 out.push_str(&format!("%{:02X}", b));
677 }
678 }
679 }
680 }
681 out
682}
683
684#[cfg(test)]
685mod tests {
686 use super::*;
687 use crate::config::{Config, EndpointConfig};
688
689 fn cfg(url: &str) -> Config {
690 let mut c = Config::default();
691 c.endpoint = EndpointConfig {
692 url: url.to_string(),
693 api_version: "v1".to_string(),
694 };
695 c
696 }
697
698 #[test]
699 fn refuses_remote_plaintext() {
700 let err = Client::from_config(&cfg("http://cleanapp.clnstrt.dev")).unwrap_err();
701 assert!(matches!(
702 err,
703 CleanLibraryError::Transport(TransportError::TlsRequired(_))
704 ));
705 }
706
707 #[test]
708 fn allows_localhost_plaintext_for_testing() {
709 let client = Client::new("http://localhost:8080", None).unwrap();
710 assert_eq!(client.base_url().host_str(), Some("localhost"));
711 }
712
713 #[test]
714 fn allows_127_loopback_plaintext() {
715 let client = Client::new("http://127.0.0.1:8080", None).unwrap();
716 assert_eq!(client.base_url().host_str(), Some("127.0.0.1"));
717 }
718
719 #[test]
720 fn accepts_https_endpoint() {
721 let client = Client::from_config(&cfg("https://cleanapp.clnstrt.dev")).unwrap();
722 assert_eq!(client.base_url().as_str(), "https://cleanapp.clnstrt.dev/");
723 }
724
725 #[test]
726 fn rejects_invalid_url() {
727 let err = Client::from_config(&cfg("not a url")).unwrap_err();
728 assert!(matches!(
729 err,
730 CleanLibraryError::Transport(TransportError::InvalidUrl(_))
731 ));
732 }
733
734 #[test]
735 fn urlencode_npm_scoped_pkg() {
736 assert_eq!(urlencode("@my-org/foo"), "%40my-org%2Ffoo");
738 }
739
740 #[test]
742 fn remediation_url_facade_is_customer_path() {
743 let base = Url::parse("https://cleanapp.clnstrt.dev").unwrap();
744 let u = build_remediation_url(&base, "v1", true, "npm", "cors").unwrap();
745 assert_eq!(u.path(), "/v1/customer/remediation/npm/cors");
746 }
747
748 #[test]
749 fn remediation_url_direct_is_api_v1_path() {
750 let base = Url::parse("https://cleanlib-enrich.clnstrt.dev").unwrap();
752 let u = build_remediation_url(&base, "v1", false, "npm", "cors").unwrap();
753 assert_eq!(u.path(), "/api/v1/remediation/npm/cors");
754 }
755
756 #[test]
757 fn remediation_url_scoped_package_slash_is_percent_encoded() {
758 let base = Url::parse("https://cleanapp.clnstrt.dev").unwrap();
762 let u = build_remediation_url(&base, "v1", true, "npm", "@babel/core").unwrap();
763 assert!(u.as_str().contains("%2F"), "scope slash not encoded: {u}");
764 assert!(
765 u.as_str().ends_with("/npm/%40babel%2Fcore"),
766 "want a single %2F-encoded scoped segment, got {u}"
767 );
768 }
769
770 #[test]
771 fn urlencode_passes_simple() {
772 assert_eq!(urlencode("lodash"), "lodash");
773 assert_eq!(urlencode("4.17.21"), "4.17.21");
774 assert_eq!(urlencode("github.com/sirupsen/logrus"), "github.com%2Fsirupsen%2Flogrus");
775 }
776
777 #[test]
778 fn urlencode_maven_coordinate_encodes_colon() {
779 assert_eq!(
785 urlencode("org.springframework:spring-beans"),
786 "org.springframework%3Aspring-beans"
787 );
788 assert_eq!(
789 urlencode("org.apache.logging.log4j:log4j-core"),
790 "org.apache.logging.log4j%3Alog4j-core"
791 );
792 }
793
794 #[test]
795 fn urlencode_handles_unicode() {
796 assert_eq!(urlencode("é"), "%C3%A9");
798 }
799
800 fn base() -> Url {
801 Url::parse("https://cleanapp.clnstrt.dev").unwrap()
802 }
803
804 #[test]
810 fn fetch_url_routes_through_app_v1_fetch_for_npm_bare() {
811 let url = build_fetch_url(&base(), "v1", "npm", "lodash", "4.17.21").unwrap();
812 assert_eq!(
813 url.as_str(),
814 "https://cleanapp.clnstrt.dev/v1/fetch/npm/lodash/4.17.21"
815 );
816 }
817
818 #[test]
819 fn fetch_url_npm_scoped_encodes_at_and_slash() {
820 let url = build_fetch_url(&base(), "v1", "npm", "@my-org/foo", "1.0.0").unwrap();
823 assert_eq!(
824 url.as_str(),
825 "https://cleanapp.clnstrt.dev/v1/fetch/npm/%40my-org%2Ffoo/1.0.0"
826 );
827 }
828
829 #[test]
830 fn fetch_url_go_module_path_slashes_encoded() {
831 let url = build_fetch_url(&base(), "v1", "go", "github.com/sirupsen/logrus", "v1.9.0")
834 .unwrap();
835 assert_eq!(
836 url.as_str(),
837 "https://cleanapp.clnstrt.dev/v1/fetch/go/github.com%2Fsirupsen%2Flogrus/v1.9.0"
838 );
839 }
840
841 #[test]
842 fn fetch_url_pypi_routes_through_v1_fetch_not_registry_mimic() {
843 let url = build_fetch_url(&base(), "v1", "pypi", "requests", "2.31.0").unwrap();
844 assert_eq!(
845 url.as_str(),
846 "https://cleanapp.clnstrt.dev/v1/fetch/pypi/requests/2.31.0"
847 );
848 assert!(!url.as_str().contains("/pypi/requests/requests-"));
850 }
851
852 #[test]
853 fn fetch_url_ecosystem_client_side_unopinionated() {
854 for eco in ["maven", "crates", "nuget", "rubygems", "composer"] {
860 let url = build_fetch_url(&base(), "v1", eco, "somepkg", "1.0.0").unwrap();
861 assert_eq!(
862 url.as_str(),
863 format!("https://cleanapp.clnstrt.dev/v1/fetch/{}/somepkg/1.0.0", eco)
864 );
865 }
866 }
867
868 #[test]
869 fn fetch_url_maven_coordinates_group_id_encoded() {
870 let url = build_fetch_url(&base(), "v1", "maven", "junit:junit", "4.13.2").unwrap();
873 assert_eq!(
874 url.as_str(),
875 "https://cleanapp.clnstrt.dev/v1/fetch/maven/junit%3Ajunit/4.13.2"
876 );
877 }
878
879 #[test]
880 fn fetch_url_honors_configured_api_version() {
881 let url = build_fetch_url(&base(), "v2", "npm", "lodash", "4.17.21").unwrap();
884 assert_eq!(
885 url.as_str(),
886 "https://cleanapp.clnstrt.dev/v2/fetch/npm/lodash/4.17.21"
887 );
888 }
889
890 fn header_map(pairs: &[(&str, &str)]) -> reqwest::header::HeaderMap {
893 let mut m = reqwest::header::HeaderMap::new();
894 for (k, v) in pairs {
895 m.insert(
896 reqwest::header::HeaderName::from_bytes(k.as_bytes()).unwrap(),
897 reqwest::header::HeaderValue::from_str(v).unwrap(),
898 );
899 }
900 m
901 }
902
903 #[test]
904 fn cleanlib_480_extract_request_id_reads_lowercase_header() {
905 let h = header_map(&[("x-request-id", "01M1KWQ41SPRAP551FGW5ZN4RF")]);
907 assert_eq!(
908 extract_request_id(&h).as_deref(),
909 Some("01M1KWQ41SPRAP551FGW5ZN4RF")
910 );
911 }
912
913 #[test]
914 fn cleanlib_480_extract_request_id_reads_mixed_case_header() {
915 let h = header_map(&[("X-Request-Id", "01ABC")]);
919 assert_eq!(extract_request_id(&h).as_deref(), Some("01ABC"));
920 }
921
922 #[test]
923 fn cleanlib_480_extract_request_id_absent_returns_none() {
924 let h = header_map(&[("content-type", "application/json")]);
927 assert!(extract_request_id(&h).is_none());
928 }
929
930 #[test]
931 fn cleanlib_480_extract_request_id_non_utf8_value_returns_none() {
932 let mut h = reqwest::header::HeaderMap::new();
937 h.insert(
938 reqwest::header::HeaderName::from_static("x-request-id"),
939 reqwest::header::HeaderValue::from_bytes(&[0x80, 0xFF]).unwrap(),
940 );
941 assert!(extract_request_id(&h).is_none());
942 }
943}