1use std::time::Duration;
6
7use futures::StreamExt;
8use serde::de::DeserializeOwned;
9use serde_json::Value;
10use zeroize::Zeroize;
11
12use crate::vault::VaultError;
13use crate::vault::models::{
14 AttachmentMeta, Folder, FolderList, Prelogin, SyncData, TokenResponse, TwoFactorCode,
15};
16
17pub const CLIENT_VERSION: &str = "2026.6.0";
21
22pub const CLIENT_ID: &str = "cli";
25
26pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
30pub const READ_TIMEOUT: Duration = Duration::from_secs(60);
31pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(5 * 60);
32
33pub const DOWNLOAD_READ_TIMEOUT: Duration = Duration::from_secs(10 * 60);
36pub const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(60 * 60);
37
38pub fn user_agent() -> String {
40 format!("forge-vault/{}", env!("CARGO_PKG_VERSION"))
41}
42
43pub struct VaultClient {
44 http: reqwest::Client,
45 download_http: reqwest::Client,
46 base: String,
47 base_url: url::Url,
48 device_id: String,
49}
50
51impl VaultClient {
52 pub fn new(base: &str) -> Result<Self, VaultError> {
57 let http = reqwest::Client::builder()
58 .user_agent(user_agent())
59 .connect_timeout(CONNECT_TIMEOUT)
60 .read_timeout(READ_TIMEOUT)
61 .timeout(REQUEST_TIMEOUT)
62 .build()?;
63 let download_http = reqwest::Client::builder()
64 .user_agent(user_agent())
65 .connect_timeout(CONNECT_TIMEOUT)
66 .read_timeout(DOWNLOAD_READ_TIMEOUT)
67 .timeout(DOWNLOAD_TIMEOUT)
68 .build()?;
69 Self::with_clients(base, http, download_http)
70 }
71
72 pub fn with_clients(
76 base: &str,
77 http: reqwest::Client,
78 download_http: reqwest::Client,
79 ) -> Result<Self, VaultError> {
80 let base = base.trim_end_matches('/').to_owned();
81 let base_url = url::Url::parse(&base).map_err(|error| {
82 VaultError::InvalidInput(format!("the vault URL does not parse: {error}"))
83 })?;
84 Ok(Self {
85 http,
86 download_http,
87 base,
88 base_url,
89 device_id: uuid::Uuid::new_v4().to_string(),
90 })
91 }
92
93 pub fn base(&self) -> &str {
95 &self.base
96 }
97
98 fn unauth(&self, method: reqwest::Method, path: &str) -> reqwest::RequestBuilder {
99 self.http
100 .request(method, format!("{}{}", self.base, path))
101 .header("Bitwarden-Client-Version", CLIENT_VERSION)
102 }
103
104 fn authed(&self, method: reqwest::Method, path: &str, token: &str) -> reqwest::RequestBuilder {
105 self.unauth(method, path).bearer_auth(token)
106 }
107
108 async fn send_body(
113 &self,
114 resp: reqwest::Response,
115 fields: ErrorFields,
116 ) -> Result<zeroize::Zeroizing<String>, VaultError> {
117 let status = resp.status();
118 let retry_after = retry_after(&resp);
119 let body = zeroize::Zeroizing::new(
120 resp.text().await.map_err(|error| VaultError::Http(error.without_url()))?,
121 );
122 if !status.is_success() {
123 return Err(api_error(status.as_u16(), retry_after, safe_error(&body, fields)));
124 }
125 Ok(body)
126 }
127
128 async fn send_json(&self, resp: reqwest::Response) -> Result<Value, VaultError> {
130 let body = self.send_body(resp, ErrorFields::Api).await?;
131 if body.is_empty() {
132 return Ok(Value::Null);
133 }
134 serde_json::from_str(&body).map_err(|e| VaultError::InvalidResponse(e.to_string()))
135 }
136
137 async fn send_typed<T: DeserializeOwned>(
140 &self,
141 resp: reqwest::Response,
142 ) -> Result<T, VaultError> {
143 self.send_typed_with(resp, ErrorFields::Api).await
144 }
145
146 async fn send_typed_oauth<T: DeserializeOwned>(
149 &self,
150 resp: reqwest::Response,
151 ) -> Result<T, VaultError> {
152 self.send_typed_with(resp, ErrorFields::Oauth).await
153 }
154
155 async fn send_typed_with<T: DeserializeOwned>(
156 &self,
157 resp: reqwest::Response,
158 fields: ErrorFields,
159 ) -> Result<T, VaultError> {
160 let body = self.send_body(resp, fields).await?;
161 serde_json::from_str(&body).map_err(|e| VaultError::InvalidResponse(e.to_string()))
162 }
163
164 pub async fn prelogin(&self, email: &str) -> Result<Prelogin, VaultError> {
167 let request = self
168 .unauth(reqwest::Method::POST, "/identity/accounts/prelogin")
169 .json(&serde_json::json!({ "email": email }));
170 let resp = send_request(request).await?;
171 self.send_typed_oauth(resp).await
172 }
173
174 pub async fn login_password(
176 &self,
177 email: &str,
178 master_password_hash: &str,
179 two_factor: Option<&TwoFactorCode>,
180 ) -> Result<TokenResponse, VaultError> {
181 let mut form = vec![
182 ("grant_type".to_owned(), "password".to_owned()),
183 ("client_id".to_owned(), CLIENT_ID.to_owned()),
184 ("username".to_owned(), email.to_owned()),
185 ("password".to_owned(), master_password_hash.to_owned()),
186 ("scope".to_owned(), "api offline_access".to_owned()),
187 ("device_type".to_owned(), "8".to_owned()),
188 ("device_identifier".to_owned(), self.device_id.clone()),
189 ("device_name".to_owned(), "forge".to_owned()),
190 ];
191 if let Some(code) = two_factor {
192 form.push(("two_factor_token".to_owned(), code.token.clone()));
193 form.push(("two_factor_provider".to_owned(), code.provider.to_string()));
194 }
195 let request = self.unauth(reqwest::Method::POST, "/identity/connect/token").form(&form);
196 let sent = send_request(request).await;
197 for (_, value) in &mut form {
198 value.zeroize();
199 }
200 self.send_typed_oauth(sent?).await
201 }
202
203 pub async fn refresh(&self, refresh_token: &str) -> Result<TokenResponse, VaultError> {
205 let form = [
206 ("grant_type", "refresh_token"),
207 ("client_id", CLIENT_ID),
208 ("refresh_token", refresh_token),
209 ];
210 let request = self.unauth(reqwest::Method::POST, "/identity/connect/token").form(&form);
211 let resp = send_request(request).await?;
212 self.send_typed_oauth(resp).await
213 }
214
215 pub async fn sync(&self, token: &str) -> Result<SyncData, VaultError> {
217 let request = self.authed(reqwest::Method::GET, "/api/sync", token);
218 let resp = send_request(request).await?;
219 self.send_typed(resp).await
220 }
221
222 pub async fn get_cipher(&self, token: &str, id: &str) -> Result<Value, VaultError> {
223 let path = format!("/api/ciphers/{}", path_segment(id)?);
224 let request = self.authed(reqwest::Method::GET, &path, token);
225 let resp = send_request(request).await?;
226 self.send_json(resp).await
227 }
228
229 pub async fn create_cipher(&self, token: &str, body: &Value) -> Result<Value, VaultError> {
230 let request = self.authed(reqwest::Method::POST, "/api/ciphers", token).json(body);
231 let resp = send_request(request).await?;
232 self.send_json(resp).await
233 }
234
235 pub async fn update_cipher(
236 &self,
237 token: &str,
238 id: &str,
239 body: &Value,
240 ) -> Result<Value, VaultError> {
241 let path = format!("/api/ciphers/{}", path_segment(id)?);
242 let request = self.authed(reqwest::Method::PUT, &path, token).json(body);
243 let resp = send_request(request).await?;
244 self.send_json(resp).await
245 }
246
247 pub async fn trash_cipher(&self, token: &str, id: &str) -> Result<Value, VaultError> {
250 let path = format!("/api/ciphers/{}/delete", path_segment(id)?);
251 let request = self.authed(reqwest::Method::PUT, &path, token);
252 let resp = send_request(request).await?;
253 self.send_json(resp).await
254 }
255
256 pub async fn restore_cipher(&self, token: &str, id: &str) -> Result<Value, VaultError> {
257 let path = format!("/api/ciphers/{}/restore", path_segment(id)?);
258 let request = self.authed(reqwest::Method::PUT, &path, token);
259 let resp = send_request(request).await?;
260 self.send_json(resp).await
261 }
262
263 pub async fn list_folders(&self, token: &str) -> Result<Vec<Folder>, VaultError> {
264 let request = self.authed(reqwest::Method::GET, "/api/folders", token);
265 let resp = send_request(request).await?;
266 let list: FolderList = self.send_typed(resp).await?;
267 Ok(list.data)
268 }
269
270 pub async fn create_folder(&self, token: &str, name: &str) -> Result<Value, VaultError> {
271 let request = self
272 .authed(reqwest::Method::POST, "/api/folders", token)
273 .json(&serde_json::json!({ "name": name }));
274 let resp = send_request(request).await?;
275 self.send_json(resp).await
276 }
277
278 pub async fn update_folder(
279 &self,
280 token: &str,
281 id: &str,
282 name: &str,
283 ) -> Result<Value, VaultError> {
284 let path = format!("/api/folders/{}", path_segment(id)?);
285 let request = self
286 .authed(reqwest::Method::PUT, &path, token)
287 .json(&serde_json::json!({ "name": name }));
288 let resp = send_request(request).await?;
289 self.send_json(resp).await
290 }
291
292 pub async fn delete_folder(&self, token: &str, id: &str) -> Result<Value, VaultError> {
293 let path = format!("/api/folders/{}", path_segment(id)?);
294 let request = self.authed(reqwest::Method::DELETE, &path, token);
295 let resp = send_request(request).await?;
296 self.send_json(resp).await
297 }
298
299 pub async fn create_attachment_slot(
302 &self,
303 token: &str,
304 cipher_id: &str,
305 body: &Value,
306 ) -> Result<Value, VaultError> {
307 let path = format!("/api/ciphers/{}/attachment/v2", path_segment(cipher_id)?);
308 let request = self.authed(reqwest::Method::POST, &path, token).json(body);
309 let resp = send_request(request).await?;
310 self.send_json(resp).await
311 }
312
313 pub async fn upload_attachment(
317 &self,
318 token: &str,
319 cipher_id: &str,
320 attachment_id: &str,
321 data: &[u8],
322 ) -> Result<Value, VaultError> {
323 let part = reqwest::multipart::Part::bytes(data.to_vec())
324 .file_name("blob")
325 .mime_str("application/octet-stream")?;
326 let form = reqwest::multipart::Form::new().part("data", part);
327 let path = format!(
328 "/api/ciphers/{}/attachment/{}",
329 path_segment(cipher_id)?,
330 path_segment(attachment_id)?
331 );
332 let request = self.authed(reqwest::Method::POST, &path, token).multipart(form);
333 let resp = send_request(request).await?;
334 self.send_json(resp).await
335 }
336
337 pub async fn get_attachment_meta(
339 &self,
340 token: &str,
341 cipher_id: &str,
342 attachment_id: &str,
343 ) -> Result<AttachmentMeta, VaultError> {
344 let path = format!(
345 "/api/ciphers/{}/attachment/{}",
346 path_segment(cipher_id)?,
347 path_segment(attachment_id)?
348 );
349 let request = self.authed(reqwest::Method::GET, &path, token);
350 let resp = send_request(request).await?;
351 self.send_typed(resp).await
352 }
353
354 pub async fn delete_attachment(
357 &self,
358 token: &str,
359 cipher_id: &str,
360 attachment_id: &str,
361 ) -> Result<Value, VaultError> {
362 let path = format!(
363 "/api/ciphers/{}/attachment/{}",
364 path_segment(cipher_id)?,
365 path_segment(attachment_id)?
366 );
367 let request = self.authed(reqwest::Method::DELETE, &path, token);
368 let resp = send_request(request).await?;
369 self.send_json(resp).await
370 }
371
372 pub async fn download_attachment(
378 &self,
379 token: &str,
380 url: &str,
381 max_bytes: u64,
382 ) -> Result<Vec<u8>, VaultError> {
383 let absolute = url.starts_with("http://") || url.starts_with("https://");
384 let target = if url.starts_with('/') {
388 url::Url::parse(&format!("{}{}", self.base, url))
389 .map_err(|error| VaultError::InvalidUrl(error.to_string()))?
390 } else if absolute {
391 url::Url::parse(url).map_err(|error| VaultError::InvalidUrl(error.to_string()))?
392 } else {
393 self.base_url.join(url).map_err(|error| VaultError::InvalidUrl(error.to_string()))?
394 };
395 if target.scheme() != self.base_url.scheme()
396 || target.host_str() != self.base_url.host_str()
397 || target.port_or_known_default() != self.base_url.port_or_known_default()
398 {
399 return Err(VaultError::InvalidUrl(
400 "the attachment URL points at a different host".to_owned(),
401 ));
402 }
403 let mut request = self
404 .download_http
405 .get(target)
406 .header("Bitwarden-Client-Version", CLIENT_VERSION)
407 .header(reqwest::header::ACCEPT_ENCODING, "identity");
408 if !absolute {
409 request = request.bearer_auth(token);
410 }
411 let resp = send_request(request).await?;
412 let status = resp.status();
413 let retry_after = retry_after(&resp);
414 if !status.is_success() {
415 let body = resp.text().await.map_err(|error| VaultError::Http(error.without_url()))?;
416 return Err(api_error(
417 status.as_u16(),
418 retry_after,
419 safe_error(&body, ErrorFields::Api),
420 ));
421 }
422 if let Some(declared) = resp.content_length()
423 && declared > max_bytes
424 {
425 return Err(VaultError::AttachmentTooLarge { size: declared, cap: max_bytes });
426 }
427 let mut body = Vec::new();
428 let mut stream = resp.bytes_stream();
429 while let Some(chunk) = stream.next().await {
430 let chunk = chunk.map_err(|error| VaultError::Http(error.without_url()))?;
431 let size = body.len() as u64 + chunk.len() as u64;
432 if size > max_bytes {
433 return Err(VaultError::AttachmentTooLarge { size, cap: max_bytes });
434 }
435 body.extend_from_slice(&chunk);
436 }
437 Ok(body)
438 }
439}
440
441fn path_segment(id: &str) -> Result<String, VaultError> {
444 if id.trim().is_empty() {
445 return Err(VaultError::InvalidInput("an empty id was passed".to_owned()));
446 }
447 if id == "." || id == ".." {
450 return Err(VaultError::InvalidInput(format!("'{id}' is not an id")));
451 }
452 Ok(url::form_urlencoded::byte_serialize(id.as_bytes()).collect::<String>().replace('+', "%20"))
453}
454
455async fn send_request(request: reqwest::RequestBuilder) -> Result<reqwest::Response, VaultError> {
458 request.send().await.map_err(|error| VaultError::Http(error.without_url()))
459}
460
461fn retry_after(resp: &reqwest::Response) -> Option<u64> {
463 resp.headers()
464 .get(reqwest::header::RETRY_AFTER)
465 .and_then(|value| value.to_str().ok())
466 .and_then(|value| value.trim().parse::<u64>().ok())
467}
468
469#[derive(Clone, Copy)]
476enum ErrorFields {
477 Oauth,
478 Api,
479}
480
481const OAUTH_ERROR_POINTERS: [&str; 2] = ["/error", "/error_description"];
482const API_ERROR_POINTERS: [&str; 5] =
483 ["/error", "/error_description", "/message", "/error/description", "/error/reason"];
484
485fn safe_error(body: &str, fields: ErrorFields) -> String {
490 let Ok(value) = serde_json::from_str::<Value>(body) else { return String::new() };
491 let pointers: &[&str] = match fields {
492 ErrorFields::Oauth => &OAUTH_ERROR_POINTERS,
493 ErrorFields::Api => &API_ERROR_POINTERS,
494 };
495 pointers
496 .iter()
497 .find_map(|pointer| {
498 value.pointer(pointer).and_then(Value::as_str).filter(|text| !text.is_empty())
499 })
500 .unwrap_or_default()
501 .to_owned()
502}
503
504fn api_error(status: u16, retry_after: Option<u64>, body: String) -> VaultError {
506 match status {
507 401 => VaultError::Unauthorized,
508 403 => VaultError::Forbidden(body),
509 404 => VaultError::NotFound(body),
510 429 => VaultError::RateLimited { retry_after },
511 other => VaultError::Api { status: other, body },
512 }
513}
514
515#[cfg(test)]
516mod tests {
517 use super::*;
518 use tokio::io::{AsyncReadExt, AsyncWriteExt};
519
520 fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
521 haystack.windows(needle.len()).position(|window| window == needle)
522 }
523
524 async fn read_request(socket: &mut tokio::net::TcpStream) -> Vec<u8> {
525 let mut buf = Vec::new();
526 let mut chunk = [0u8; 8192];
527 loop {
528 let read = socket.read(&mut chunk).await.unwrap_or(0);
529 if read == 0 {
530 break;
531 }
532 buf.extend_from_slice(&chunk[..read]);
533 if let Some(end) = find(&buf, b"\r\n\r\n") {
534 let headers = String::from_utf8_lossy(&buf[..end]).to_lowercase();
535 let length: usize = headers
536 .lines()
537 .find_map(|line| line.strip_prefix("content-length:"))
538 .and_then(|value| value.trim().parse().ok())
539 .unwrap_or(0);
540 if buf.len() >= end + 4 + length {
541 break;
542 }
543 }
544 }
545 buf
546 }
547
548 async fn serve_raw(response: Vec<u8>) -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) {
549 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
550 let address = listener.local_addr().unwrap();
551 let handle = tokio::spawn(async move {
552 let (mut socket, _) = listener.accept().await.unwrap();
553 let _request = read_request(&mut socket).await;
554 let _ = socket.write_all(&response).await;
555 let _ = socket.shutdown().await;
556 });
557 (address, handle)
558 }
559
560 async fn serve_once(
561 status: &str,
562 body: &str,
563 headers: &str,
564 ) -> (std::net::SocketAddr, tokio::task::JoinHandle<Vec<u8>>) {
565 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
566 let address = listener.local_addr().unwrap();
567 let response = format!(
568 "HTTP/1.1 {status}\r\nContent-Length: {}\r\nContent-Type: application/json\r\n{headers}\r\n{body}",
569 body.len()
570 );
571 let handle = tokio::spawn(async move {
572 let (mut socket, _) = listener.accept().await.unwrap();
573 let request = read_request(&mut socket).await;
574 let _ = socket.write_all(response.as_bytes()).await;
575 request
576 });
577 (address, handle)
578 }
579
580 #[tokio::test]
581 async fn prelogin_posts_to_the_identity_path_with_the_email() {
582 let (address, server) =
583 serve_once("200 OK", r#"{"kdf":0,"kdfIterations":1000000}"#, "").await;
584 let client = VaultClient::new(&format!("http://{address}")).unwrap();
585
586 let pre = client.prelogin("hi@vedhavyas.dev").await.unwrap();
587
588 assert_eq!(pre.kdf, 0);
589 assert_eq!(pre.kdf_iterations, 1_000_000);
590 let request = server.await.unwrap();
591 let text = String::from_utf8_lossy(&request);
592 assert!(text.starts_with("POST /identity/accounts/prelogin "), "{text}");
593 assert!(text.contains("hi@vedhavyas.dev"), "{text}");
594 assert!(text.contains("bitwarden-client-version: 2026.6.0"), "{text}");
595 }
596
597 #[tokio::test]
600 async fn trash_is_a_put_to_the_soft_delete_path() {
601 let (address, server) = serve_once("200 OK", "{}", "").await;
602 let client = VaultClient::new(&format!("http://{address}")).unwrap();
603
604 client.trash_cipher("token", "cipher-id").await.unwrap();
605
606 let request = server.await.unwrap();
607 let text = String::from_utf8_lossy(&request);
608 assert!(text.starts_with("PUT /api/ciphers/cipher-id/delete "), "{text}");
609 assert!(text.contains("authorization: Bearer token"), "{text}");
610 }
611
612 #[tokio::test]
616 async fn list_folders_parses_the_list_envelope() {
617 let body = r#"{"data":[{"id":"folder-1","name":"2.abc|def|ghi"}],"object":"list","continuationToken":null}"#;
618 let (address, server) = serve_once("200 OK", body, "").await;
619 let client = VaultClient::new(&format!("http://{address}")).unwrap();
620
621 let folders = client.list_folders("token").await.expect("the folder list");
622
623 assert_eq!(
624 folders,
625 vec![Folder { id: "folder-1".to_owned(), name: "2.abc|def|ghi".to_owned() }]
626 );
627 let request = server.await.unwrap();
628 let text = String::from_utf8_lossy(&request);
629 assert!(text.starts_with("GET /api/folders "), "{text}");
630 }
631
632 #[tokio::test]
633 async fn a_401_is_the_unauthorized_refresh_trigger() {
634 let (address, server) = serve_once("401 Unauthorized", "{}", "").await;
635 let client = VaultClient::new(&format!("http://{address}")).unwrap();
636
637 let error = client.sync("stale").await.unwrap_err();
638
639 assert!(matches!(error, VaultError::Unauthorized));
640 server.await.unwrap();
641 }
642
643 #[tokio::test]
646 async fn a_token_error_keeps_the_error_field_and_not_the_body() {
647 let (address, server) =
648 serve_once("400 Bad Request", r#"{"error":"invalid_grant","password":"hunter2"}"#, "")
649 .await;
650 let client = VaultClient::new(&format!("http://{address}")).unwrap();
651
652 let error =
653 client.login_password("hi@vedhavyas.dev", "the-password-hash", None).await.unwrap_err();
654 let text = error.to_string();
655
656 assert!(text.contains("invalid_grant"), "{text}");
657 assert!(!text.contains("hunter2"), "the raw body reached the error: {text}");
658 server.await.unwrap();
659 }
660
661 #[tokio::test]
665 async fn an_error_body_keeps_the_reason_the_server_stated() {
666 let vaultwarden = r#"{"message":"Invalid user cipher","validationErrors":{"":["Invalid user cipher"]},"error":"","error_description":"","object":"error"}"#;
667 let (address, server) = serve_once("422 Unprocessable Entity", vaultwarden, "").await;
668 let client = VaultClient::new(&format!("http://{address}")).unwrap();
669 let error = client.sync("token").await.unwrap_err();
670 assert!(error.to_string().contains("Invalid user cipher"), "{error}");
671 server.await.unwrap();
672
673 let rocket = r#"{"error":{"code":422,"reason":"Unprocessable Entity","description":"The request was well-formed but was unable to be followed due to semantic errors."}}"#;
674 let (address, server) = serve_once("422 Unprocessable Entity", rocket, "").await;
675 let client = VaultClient::new(&format!("http://{address}")).unwrap();
676 let error = client.sync("token").await.unwrap_err();
677 assert!(error.to_string().contains("well-formed"), "{error}");
678 server.await.unwrap();
679 }
680
681 #[test]
685 fn an_echoed_body_contributes_no_error_text() {
686 assert_eq!(
687 safe_error(r#"{"password":"hunter2","username":"hi@example.com"}"#, ErrorFields::Api),
688 ""
689 );
690 assert_eq!(safe_error("<html>password=hunter2</html>", ErrorFields::Api), "");
691 }
692
693 #[test]
696 fn an_api_error_keeps_a_reason_with_no_description() {
697 let body = r#"{"error":{"code":404,"reason":"Not Found","description":""}}"#;
698 assert_eq!(safe_error(body, ErrorFields::Api), "Not Found");
699 }
700
701 #[tokio::test]
705 async fn an_identity_error_body_keeps_only_the_oauth_pair() {
706 let body =
707 r#"{"message":"invalid_grant","error":"","error_description":"","password":"hunter2"}"#;
708 let (address, server) = serve_once("400 Bad Request", body, "").await;
709 let client = VaultClient::new(&format!("http://{address}")).unwrap();
710
711 let error =
712 client.login_password("hi@vedhavyas.dev", "the-password-hash", None).await.unwrap_err();
713 let text = error.to_string();
714
715 assert!(
716 !text.contains("invalid_grant"),
717 "an API pointer reached the identity error: {text}"
718 );
719 assert!(!text.contains("hunter2"), "the echoed form reached the identity error: {text}");
720 server.await.unwrap();
721 }
722
723 #[tokio::test]
726 async fn a_relative_download_carries_the_bearer_and_an_absolute_one_does_not() {
727 let (address, server) = serve_once("200 OK", "bytes", "").await;
728 let client = VaultClient::new(&format!("http://{address}")).unwrap();
729 let downloaded =
730 client.download_attachment("token", "/attachments/c/a", 1024).await.unwrap();
731 assert_eq!(downloaded, b"bytes");
732 let request = server.await.unwrap();
733 let text = String::from_utf8_lossy(&request);
734 assert!(text.contains("authorization: Bearer token"), "{text}");
735 assert!(text.contains("accept-encoding: identity"), "{text}");
736
737 let (address, server) = serve_once("200 OK", "bytes", "").await;
738 let client = VaultClient::new(&format!("http://{address}")).unwrap();
739 let signed = format!("http://{address}/signed?token=x");
740 client.download_attachment("token", &signed, 1024).await.unwrap();
741 let request = server.await.unwrap();
742 let text = String::from_utf8_lossy(&request);
743 assert!(
744 !text.to_lowercase().contains("authorization:"),
745 "a signed URL carries its own token: {text}",
746 );
747 }
748
749 #[tokio::test]
752 async fn a_relative_url_cannot_smuggle_the_bearer_to_another_host() {
753 let (address, server) = serve_once("200 OK", "bytes", "").await;
754 let elsewhere = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
755 let other = elsewhere.local_addr().unwrap();
756 let client = VaultClient::new(&format!("http://{address}")).unwrap();
757
758 let downloaded =
759 client.download_attachment("token", &format!("@{other}/x"), 1024).await.unwrap();
760
761 assert_eq!(downloaded, b"bytes");
762 let request = server.await.unwrap();
763 let text = String::from_utf8_lossy(&request);
764 assert!(text.starts_with(&format!("GET /@{other}/x ")), "{text}");
765 assert!(text.contains("authorization: Bearer token"), "{text}");
766 assert!(
767 tokio::time::timeout(std::time::Duration::from_millis(100), elsewhere.accept())
768 .await
769 .is_err(),
770 "no request reached the foreign host",
771 );
772 }
773
774 #[tokio::test]
777 async fn a_download_to_another_host_is_refused() {
778 let (address, _server) = serve_once("200 OK", "bytes", "").await;
779 let closed = {
782 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
783 listener.local_addr().unwrap()
784 };
785 let client = VaultClient::new(&format!("http://{address}")).unwrap();
786
787 let error = client
790 .download_attachment("token", &format!("http://{closed}/x"), 1024)
791 .await
792 .unwrap_err();
793
794 assert!(matches!(error, VaultError::InvalidUrl(_)), "{error}");
795 }
796
797 #[tokio::test]
800 async fn a_download_over_the_cap_is_refused() {
801 let (address, server) = serve_once("200 OK", "far more than ten bytes", "").await;
802 let client = VaultClient::new(&format!("http://{address}")).unwrap();
803
804 let error = client.download_attachment("token", "/a", 10).await.unwrap_err();
805
806 assert!(
807 matches!(error, VaultError::AttachmentTooLarge { size, cap: 10 } if size > 10),
808 "{error}"
809 );
810 server.await.unwrap();
811 }
812
813 #[tokio::test]
816 async fn a_chunked_download_over_the_cap_is_refused_while_streaming() {
817 let response =
818 b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n8\r\nxxxxxxxx\r\n0\r\n\r\n"
819 .to_vec();
820 let (address, server) = serve_raw(response).await;
821 let client = VaultClient::new(&format!("http://{address}")).unwrap();
822
823 let error = client.download_attachment("token", "/a", 4).await.unwrap_err();
824
825 assert!(matches!(error, VaultError::AttachmentTooLarge { .. }), "{error}");
826 server.await.unwrap();
827 }
828
829 #[tokio::test]
831 async fn a_body_error_does_not_carry_the_url() {
832 let response =
833 b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\nContent-Type: application/json\r\n\r\n{\"a\":"
834 .to_vec();
835 let (address, server) = serve_raw(response).await;
836 let client = VaultClient::new(&format!("http://{address}")).unwrap();
837
838 let error = client.sync("token").await.unwrap_err();
839 let text = error.to_string();
840
841 assert!(matches!(&error, VaultError::Http(_)), "{error}");
842 assert!(!text.contains("127.0.0.1"), "the URL reached the error: {text}");
843 server.await.unwrap();
844 }
845
846 #[tokio::test]
849 async fn a_dot_segment_id_is_refused() {
850 let client = VaultClient::new("http://127.0.0.1:1").unwrap();
851
852 for id in [".", ".."] {
853 let error = client.get_cipher("token", id).await.unwrap_err();
854 assert!(
855 matches!(error, VaultError::InvalidInput(_)),
856 "'{id}' must be refused: {error}"
857 );
858 }
859 }
860
861 #[tokio::test]
864 async fn a_download_error_does_not_carry_the_signed_url() {
865 let client = VaultClient::new("http://127.0.0.1:1").unwrap();
866
867 let error = client
868 .download_attachment("token", "http://127.0.0.1:1/signed?token=secret", 1024)
869 .await
870 .unwrap_err();
871 let text = error.to_string();
872
873 assert!(!text.contains("secret"), "the signed URL reached the error: {text}");
874 assert!(!text.contains("127.0.0.1"), "the URL reached the error: {text}");
875 }
876
877 #[tokio::test]
879 async fn a_send_error_does_not_carry_the_url() {
880 let client = VaultClient::new("http://127.0.0.1:1").unwrap();
881
882 let error = client.sync("token").await.unwrap_err();
883 let text = error.to_string();
884
885 assert!(!text.contains("127.0.0.1"), "the URL reached the error: {text}");
886 }
887
888 #[tokio::test]
891 async fn an_id_is_percent_encoded_as_one_path_segment() {
892 let (address, server) = serve_once("200 OK", "{}", "").await;
893 let client = VaultClient::new(&format!("http://{address}")).unwrap();
894
895 client.get_cipher("token", "a/b?c d").await.unwrap();
896
897 let request = server.await.unwrap();
898 let text = String::from_utf8_lossy(&request);
899 assert!(text.starts_with("GET /api/ciphers/a%2Fb%3Fc%20d "), "{text}");
900 }
901
902 #[tokio::test]
903 async fn a_429_carries_the_retry_after_hint() {
904 let (address, server) =
905 serve_once("429 Too Many Requests", "{}", "Retry-After: 7\r\n").await;
906 let client = VaultClient::new(&format!("http://{address}")).unwrap();
907
908 let error = client.sync("token").await.unwrap_err();
909
910 assert!(matches!(error, VaultError::RateLimited { retry_after: Some(7) }));
911 server.await.unwrap();
912 }
913
914 #[tokio::test]
917 async fn the_v2_upload_sends_one_data_part() {
918 let (address, server) = serve_once("200 OK", "{}", "").await;
919 let client = VaultClient::new(&format!("http://{address}")).unwrap();
920 let blob: Vec<u8> = (0u8..97).collect();
921
922 client.upload_attachment("token", "cipher-id", "attachment-id", &blob).await.unwrap();
923
924 let request = server.await.unwrap();
925 let text = String::from_utf8_lossy(&request);
926 assert_eq!(text.matches("name=\"data\"").count(), 1, "{text}");
927 assert!(!text.contains("name=\"key\""), "{text}");
928 assert!(
929 request.windows(blob.len()).any(|window| window == blob.as_slice()),
930 "the multipart request did not carry the blob bytes unmodified"
931 );
932 }
933}