dragonfly_client_rs/client/
methods.rs1use super::{models, ScanResultSerializer};
2
3use reqwest::blocking::Client;
4
5pub fn fetch_bulk_job(
6 http_client: &Client,
7 base_url: &str,
8 n_jobs: usize,
9) -> reqwest::Result<Vec<models::Job>> {
10 http_client
11 .post(format!("{base_url}/jobs"))
12 .query(&[("batch", n_jobs)])
13 .send()?
14 .error_for_status()?
15 .json()
16}
17
18pub fn fetch_rules(http_client: &Client, base_url: &str) -> reqwest::Result<models::RulesResponse> {
19 http_client
20 .get(format!("{base_url}/rules"))
21 .send()?
22 .error_for_status()?
23 .json()
24}
25
26#[cfg(test)]
27pub fn send_result(
28 http_client: &Client,
29 base_url: &str,
30 body: models::ScanResult,
31) -> reqwest::Result<()> {
32 send_result_with_metrics(http_client, base_url, body, None)
33}
34
35pub fn send_result_with_metrics(
36 http_client: &Client,
37 base_url: &str,
38 body: models::ScanResult,
39 stats: Option<&crate::reuse_cache::CacheStats>,
40) -> reqwest::Result<()> {
41 #[derive(serde::Serialize)]
42 struct Report<'a> {
43 #[serde(flatten)]
44 result: ScanResultSerializer,
45 #[serde(skip_serializing_if = "Option::is_none")]
46 scan_reuse: Option<&'a crate::reuse_cache::CacheStats>,
47 }
48 let body = Report {
49 result: body.into(),
50 scan_reuse: stats,
51 };
52 http_client
53 .put(format!("{base_url}/package"))
54 .json(&body)
55 .send()?
56 .error_for_status()?;
57 Ok(())
58}
59
60#[cfg(test)]
61mod tests {
62 use super::{fetch_bulk_job, fetch_rules, send_result};
63 use crate::client::{build_api_http_client, SubmitJobResultsError};
64 use std::{
65 io::{Read, Write},
66 net::TcpListener,
67 sync::mpsc,
68 thread,
69 };
70
71 const CLIENT_ID: &str = "test-client.access";
72 const CLIENT_SECRET: &str = "test-secret";
73
74 fn serve_once(response_body: &str) -> (String, mpsc::Receiver<String>) {
75 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
76 let address = listener.local_addr().unwrap();
77 let response_body = response_body.to_owned();
78 let (sender, receiver) = mpsc::channel();
79
80 thread::spawn(move || {
81 let (mut stream, _) = listener.accept().unwrap();
82 let mut request = Vec::new();
83 let mut buffer = [0_u8; 4096];
84
85 loop {
86 let bytes_read = stream.read(&mut buffer).unwrap();
87 if bytes_read == 0 {
88 break;
89 }
90 request.extend_from_slice(&buffer[..bytes_read]);
91
92 let request_text = String::from_utf8_lossy(&request);
93 let Some(headers_end) = request_text.find("\r\n\r\n") else {
94 continue;
95 };
96 let content_length = request_text[..headers_end]
97 .lines()
98 .find_map(|line| {
99 line.to_ascii_lowercase()
100 .strip_prefix("content-length: ")
101 .and_then(|value| value.parse::<usize>().ok())
102 })
103 .unwrap_or_default();
104 if request.len() >= headers_end + 4 + content_length {
105 break;
106 }
107 }
108
109 sender.send(String::from_utf8(request).unwrap()).unwrap();
110 write!(
111 stream,
112 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
113 response_body.len(),
114 response_body,
115 )
116 .unwrap();
117 });
118
119 (format!("http://{address}"), receiver)
120 }
121
122 fn assert_cloudflare_access_headers(request: &str) {
123 let lowercase_request = request.to_ascii_lowercase();
124 assert!(lowercase_request.contains(&format!(
125 "\r\ncf-access-client-id: {}\r\n",
126 CLIENT_ID.to_ascii_lowercase()
127 )));
128 assert!(lowercase_request
129 .contains(&format!("\r\ncf-access-client-secret: {CLIENT_SECRET}\r\n")));
130 assert!(!lowercase_request.contains("\r\nauthorization:"));
131 }
132
133 #[test]
134 fn telemetry_is_flattened_into_success_and_failure_wire_payloads() {
135 use crate::{
136 client::SubmitJobResultsSuccess,
137 reuse_cache::{CacheMode, CacheStats},
138 };
139 for success in [true, false] {
140 let (base_url, request) = serve_once("");
141 let client = build_api_http_client(CLIENT_ID, CLIENT_SECRET).unwrap();
142 let result = if success {
143 Ok(SubmitJobResultsSuccess {
144 name: "example".into(),
145 version: "1".into(),
146 attempt: 1,
147 assignment_id: "lease".into(),
148 score: 0,
149 inspector_url: None,
150 rules_matched: Vec::new(),
151 commit: "rules".into(),
152 })
153 } else {
154 Err(SubmitJobResultsError {
155 name: "example".into(),
156 version: "1".into(),
157 attempt: 1,
158 assignment_id: "lease".into(),
159 reason: "failure".into(),
160 })
161 };
162 let mut stats = CacheStats::new("yara", CacheMode::Reuse);
163 stats.reused_files = 7;
164 stats.engine_us = 123;
165 super::send_result_with_metrics(&client, &base_url, result, Some(&stats)).unwrap();
166 let request = request.recv().unwrap();
167 assert!(request.starts_with("PUT /package HTTP/1.1\r\n"));
168 assert_cloudflare_access_headers(&request);
169 let body: serde_json::Value =
170 serde_json::from_str(request.split_once("\r\n\r\n").unwrap().1).unwrap();
171 assert_eq!(body["name"], "example");
172 assert_eq!(body["assignment_id"], "lease");
173 assert_eq!(body["scan_reuse"]["mode"], "reuse");
174 assert_eq!(body["scan_reuse"]["reused_files"], 7);
175 assert_eq!(body["scan_reuse"]["engine_us"], 123);
176 assert_eq!(body.get("commit").is_some(), success);
177 assert_eq!(body.get("reason").is_some(), !success);
178 assert!(body.get("result").is_none());
179 }
180 }
181
182 #[test]
183 fn jobs_route_uses_cloudflare_access_service_token() {
184 let (base_url, request) = serve_once("[]");
185 let client = build_api_http_client(CLIENT_ID, CLIENT_SECRET).unwrap();
186
187 let jobs = fetch_bulk_job(&client, &base_url, 3).unwrap();
188
189 assert!(jobs.is_empty());
190 let request = request.recv().unwrap();
191 assert!(request.starts_with("POST /jobs?batch=3 HTTP/1.1\r\n"));
192 assert_cloudflare_access_headers(&request);
193 }
194
195 #[test]
196 fn rules_route_uses_cloudflare_access_service_token() {
197 let (base_url, request) = serve_once(r#"{"hash":"abc123","rules":{}}"#);
198 let client = build_api_http_client(CLIENT_ID, CLIENT_SECRET).unwrap();
199
200 let rules = fetch_rules(&client, &base_url).unwrap();
201
202 assert_eq!(rules.hash, "abc123");
203 let request = request.recv().unwrap();
204 assert!(request.starts_with("GET /rules HTTP/1.1\r\n"));
205 assert_cloudflare_access_headers(&request);
206 }
207
208 #[test]
209 fn package_route_uses_cloudflare_access_service_token() {
210 let (base_url, request) = serve_once("");
211 let client = build_api_http_client(CLIENT_ID, CLIENT_SECRET).unwrap();
212 let result = Err(SubmitJobResultsError {
213 name: "example".to_owned(),
214 version: "1.0.0".to_owned(),
215 attempt: 2,
216 assignment_id: "0962b72e-f197-41c3-a059-e10fdc149cce".to_owned(),
217 reason: "test failure".to_owned(),
218 });
219
220 send_result(&client, &base_url, result).unwrap();
221
222 let request = request.recv().unwrap();
223 assert!(request.starts_with("PUT /package HTTP/1.1\r\n"));
224 assert_cloudflare_access_headers(&request);
225 assert!(request.contains(
226 r#"{"name":"example","version":"1.0.0","attempt":2,"assignment_id":"0962b72e-f197-41c3-a059-e10fdc149cce","reason":"test failure"}"#
227 ));
228 }
229}