1use std::collections::HashMap;
15
16use reqwest::blocking::Client;
17use reqwest::header::{HeaderMap, HeaderValue, USER_AGENT};
18use tracing::debug;
19use url::Url;
20
21use crate::{coordinate::Coordinate, package::Package};
22
23const PRODUCTION_API_BASE: &str = "https://ossindex.sonatype.org/api/v3/";
24
25pub struct OSSIndexClient {
26 url_maker: UrlMaker,
27}
28
29struct UrlMaker {
30 api_base: String,
31 api_key: String,
32}
33
34impl OSSIndexClient {
35 pub fn new(key: String) -> OSSIndexClient {
36 #[cfg(not(test))]
37 let ossindex_api_base = PRODUCTION_API_BASE;
38
39 #[cfg(test)]
40 let ossindex_api_base = &mockito::server_url();
41
42 debug!("Value for ossindex_api_base: {}", ossindex_api_base);
43
44 let url_maker = UrlMaker::new(ossindex_api_base.to_owned(), key);
45
46 OSSIndexClient { url_maker }
47 }
48
49 fn construct_headers(&self) -> HeaderMap {
50 const VERSION: &'static str = env!("CARGO_PKG_VERSION");
51
52 let mut headers = HeaderMap::new();
53 headers.insert(
54 USER_AGENT,
55 HeaderValue::from_str(&format!("cargo-pants/{}", VERSION)).expect(&format!(
56 "version could not be converted to a header: {}",
57 VERSION
58 )),
59 );
60 headers
61 }
62
63 pub fn post_coordinates(&self, purls: Vec<Package>) -> Vec<Coordinate> {
64 let url = self.url_maker.component_report_url();
65 let coordinates: Vec<Coordinate> =
66 self.post_json(url.to_string(), purls).unwrap_or_default();
67 return coordinates;
68 }
69
70 fn post_json(
71 &self,
72 url: String,
73 packages: Vec<Package>,
74 ) -> Result<Vec<Coordinate>, reqwest::Error> {
75 let mut purls: HashMap<String, Vec<String>> = HashMap::new();
77
78 purls.insert(
79 "coordinates".to_string(),
80 packages.iter().map(|x| x.as_purl()).collect(),
81 );
82 let client = Client::new();
83
84 let response = client
85 .post(&url)
86 .json(&purls)
87 .headers(self.construct_headers())
88 .send()?;
89
90 response.json()
91 }
92}
93
94impl UrlMaker {
95 pub fn new(api_base: String, api_key: String) -> UrlMaker {
96 UrlMaker { api_base, api_key }
97 }
98
99 fn build_url(&self, path: &str) -> Result<Url, url::ParseError> {
100 let mut url = Url::parse(&self.api_base)?.join(path)?;
101 url.query_pairs_mut()
102 .append_pair(&"api_key".to_string(), &self.api_key);
103 Ok(url)
104 }
105
106 pub fn component_report_url(&self) -> Url {
107 self.build_url("component-report").expect(&format!(
108 "Could not construct component-report URL {}",
109 self.api_base
110 ))
111 }
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117 use env_logger::builder;
118 use mockito::mock;
119
120 fn init_logger() {
121 let _ = builder().is_test(true).try_init();
122 }
123
124 #[test]
125 fn new_ossindexclient() {
126 let key = String::from("ALL_YOUR_KEY");
127 let client = OSSIndexClient::new(key);
128 assert_eq!(client.url_maker.api_key, "ALL_YOUR_KEY");
129 }
130
131 #[test]
132 fn new_urlmaker() {
133 let api_base = "https://allyourbase.api/api/v3/";
134 let api_key = "ALL_YOUR_KEY";
135 let urlmaker = UrlMaker::new(api_base.to_string(), api_key.to_string());
136 assert_eq!(urlmaker.api_base, api_base);
137 assert_eq!(urlmaker.api_key, api_key);
138 }
139
140 #[test]
141 fn component_report_url_with_empty_apikey() {
142 let api_base = "https://allyourbase.api/api/v3/";
143 let api_key = "";
144 let urlmaker = UrlMaker::new(api_base.to_string(), api_key.to_string());
145 let report_url = urlmaker.component_report_url();
146 assert_eq!(
147 report_url.as_str(),
148 "https://allyourbase.api/api/v3/component-report?api_key="
149 );
150 }
151
152 #[test]
153 fn test_parse_bytes_as_value() {
154 let raw_json: &[u8] = r##"{
155 "coordinates": "pkg:pypi/rust@0.1.1",
156 "description": "Ribo-Seq Unit Step Transformation",
157 "reference": "https://ossindex.sonatype.org/component/pkg:pypi/rust@0.1.1",
158 "vulnerabilities": [],
159 "source": "registry+https://github.com/rust-lang/crates.io-index"
160 }"##
161 .as_bytes();
162 let value: serde_json::Value =
163 serde_json::from_slice(raw_json).expect("Failed to parse JSON");
164 assert_eq!(value["coordinates"], "pkg:pypi/rust@0.1.1");
165 assert_eq!(value["description"], "Ribo-Seq Unit Step Transformation");
166 }
167
168 fn test_package_data() -> Package {
169 let package_data = r##"{
170 "name": "claxon",
171 "version": "0.3.0",
172 "package_id": ""
173 }"##
174 .as_bytes();
175 serde_json::from_slice::<Package>(package_data).expect("Failed to parse package data")
176 }
177
178 #[test]
179 fn test_post_json() {
180 init_logger();
181
182 let raw_json: &[u8] = r##"{
183 "coordinates": "pkg:cargo/claxon@0.3.0",
184 "description": "A FLAC decoding library",
185 "reference": "https://ossindex.sonatype.org/component/pkg:cargo/claxon@0.3.0",
186 "vulnerabilities":
187 [
188 {
189 "title": "CWE-200: Information Exposure",
190 "description": "An information exposure is the intentional or unintentional disclosure of information to an actor that is not explicitly authorized to have access to that information.",
191 "cvssScore": 4.3,
192 "cvssVector": "CVSS:3.0/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N",
193 "reference": "https://ossindex.sonatype.org/vuln/bd1aacf1-bc91-441d-aaf8-44f40513200d"
194 }
195 ],
196 "source": "registry+https://github.com/rust-lang/crates.io-index"
197 }"##.as_bytes();
198 let packages: Vec<Package> = vec![test_package_data()];
199 let mock = mock("POST", "/component-report?api_key=ALL_YOUR_KEY")
200 .with_header("CONTENT_TYPE", "application/json")
201 .with_body(raw_json)
202 .create();
203
204 {
205 let key = String::from("ALL_YOUR_KEY");
206 let client = OSSIndexClient::new(key);
207 client.post_coordinates(packages);
208 }
209 mock.assert();
210 }
211
212 fn test_component_report_json() -> &'static [u8] {
213 return r##"[{
214 "coordinates": "pkg:cargo/claxon@0.3.0",
215 "description": "A FLAC decoding library",
216 "reference": "https://ossindex.sonatype.org/component/pkg:cargo/claxon@0.3.0",
217 "vulnerabilities": [{
218 "id": "bd1aacf1-bc91-441d-aaf8-44f40513200d",
219 "title": "CWE-200: Information Exposure",
220 "description": "An information exposure is the intentional or unintentional disclosure of information to an actor that is not explicitly authorized to have access to that information.",
221 "cvssScore": 4.3,
222 "cvssVector": "CVSS:3.0/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N",
223 "cwe": "CWE-200",
224 "reference": "https://ossindex.sonatype.org/vuln/bd1aacf1-bc91-441d-aaf8-44f40513200d"
225 }]
226 }, {
227 "coordinates": "pkg:cargo/arrayfire@3.5.0",
228 "description": "ArrayFire is a high performance software library for parallel computing with an easy-to-use API. Its array based function set makes parallel programming simple. ArrayFire's multiple backends (CUDA, OpenCL and native CPU) make it platform independent and highly portable. A few lines of code in ArrayFire can replace dozens of lines of parallel computing code, saving you valuable time and lowering development costs. This crate provides Rust bindings for ArrayFire library.",
229 "reference": "https://ossindex.sonatype.org/component/pkg:cargo/arrayfire@3.5.0",
230 "vulnerabilities": [{
231 "id": "bb99215c-ee5f-4539-98a5-f1257429c3a0",
232 "title": "CWE-119: Improper Restriction of Operations within the Bounds of a Memory Buffer",
233 "description": "The software performs operations on a memory buffer, but it can read from or write to a memory location that is outside of the intended boundary of the buffer.",
234 "cvssScore": 8.6,
235 "cvssVector": "CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:H",
236 "cwe": "CWE-119",
237 "reference": "https://ossindex.sonatype.org/vuln/bb99215c-ee5f-4539-98a5-f1257429c3a0"
238 }]
239 }]"##.as_bytes();
240 }
241}