Skip to main content

forge_connectors/vault/
client.rs

1//! The Vaultwarden HTTP client. Stateless about auth: every authed call takes
2//! the access token the workspace's `VaultSession` holds, so the session is the
3//! one owner of the tokens and the client can be shared across seats.
4
5use 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
17/// The client version every request carries. `GET /api/sync` omits SSH keys
18/// unless this is at least 2024.12.0, so the value is load-bearing rather than
19/// decorative.
20pub const CLIENT_VERSION: &str = "2026.6.0";
21
22/// The OAuth client id Vaultwarden's token endpoint expects for a password
23/// grant. There is no secret: the password grant is a public client.
24pub const CLIENT_ID: &str = "cli";
25
26/// Bounds for one ordinary vault call. A stalled server must fail the call
27/// rather than hold the unlock or refresh gate for ever, which would queue
28/// every later vault call behind it.
29pub 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
33/// A download is opaque ciphertext up to the attachment cap, so its idle read
34/// bound and total bound are longer than an API call's.
35pub const DOWNLOAD_READ_TIMEOUT: Duration = Duration::from_secs(10 * 60);
36pub const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(60 * 60);
37
38/// The user agent every vault request carries.
39pub 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    /// Build a client for `base`, which may carry a trailing slash. The
53    /// workspace's production session builds its clients through
54    /// [`VaultClient::with_clients`] so the machine's TLS roots and
55    /// `https_only` apply; this constructor is for local servers and tests.
56    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    /// Build a client over callers' own HTTP clients. The download client is
73    /// separate because its read and total bounds are longer than an API
74    /// call's.
75    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    /// The base URL this client talks to.
94    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    /// Read one response body, mapping a non-2xx status to its named error.
109    /// The body is zeroized on every path: a token response's body carries
110    /// the access and refresh tokens. `fields` scopes which error fields may
111    /// be surfaced, so the identity route never reads the API's pointers.
112    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    /// Read one response as JSON. An empty success body is `Value::Null`.
129    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    /// Parse one response straight into `T`, zeroizing the raw text after the
138    /// parse rather than leaving a token-bearing `Value` to drop unzeroized.
139    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    /// The identity endpoints' own parse: an error there is read through the
147    /// OAuth pair only, never the API's wider pointer set.
148    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    /// `POST /identity/accounts/prelogin`, which is where Vaultwarden mounts it
165    /// (the `/api/accounts/prelogin` forms answer 404).
166    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    /// `POST /identity/connect/token` with the password grant.
175    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    /// `POST /identity/connect/token` with the refresh grant.
204    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    /// `GET /api/sync`: the profile and every cipher and folder.
216    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    /// The soft delete. `POST /api/ciphers/{id}/delete` and
248    /// `DELETE /api/ciphers/{id}` are permanent and are never called here.
249    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    /// Create the v2 attachment slot, which records the wrapped key and the
300    /// declared size the server later checks against the upload.
301    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    /// Upload the encrypted blob. Exactly one `data` part: v2 stored the key at
314    /// slot time, and a second part leaks multipart boundary bytes into the
315    /// stored blob through the server's parser.
316    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    /// The download metadata: the wrapped key and the signed URL.
338    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    /// Delete one attachment. Used only by a replace, which names the old and
355    /// the new file in one approval; it is never its own agent-callable tool.
356    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    /// Download a signed attachment URL. The bytes are opaque ciphertext, so the
373    /// request asks the proxy not to re-encode them. A relative URL is the API
374    /// route and takes the bearer; an absolute one is signed and does not, and
375    /// may only name this vault's own host. `max_bytes` bounds the body while it
376    /// streams, so an endless response cannot buffer past the cap.
377    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        // A leading slash is this vault's own path, so appending it cannot
385        // change the authority. Anything else is resolved by the URL parser:
386        // joined as a path, never concatenated as `base` followed by userinfo.
387        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
441/// Percent-encode one id for a path segment. An empty id is refused rather
442/// than sent as a route with a hole in it.
443fn 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    // `byte_serialize` leaves dots alone, and a `.` or `..` segment is a
448    // traversal rather than an id once a router normalizes the path.
449    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
455/// Send one request, dropping the URL from any transport error: a signed
456/// download URL carries its token in the query string.
457async fn send_request(request: reqwest::RequestBuilder) -> Result<reqwest::Response, VaultError> {
458    request.send().await.map_err(|error| VaultError::Http(error.without_url()))
459}
460
461/// The server's `Retry-After` hint, when it sends a whole number of seconds.
462fn 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/// Which fields of an error body a route may surface.
470///
471/// **The identity endpoints read the OAuth pair and nothing else.** They take
472/// the master-password hash in a form body, so a proxy that re-encodes that
473/// form into an error-named field must not reach a tool result or the audit
474/// file through a pointer the API routes need.
475#[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
485/// The server's own error text and nothing else. Vaultwarden states an API
486/// refusal in `message`, Rocket's catcher nests its reason under `error`, and
487/// the identity endpoint uses the OAuth `error` pair. A body that does not
488/// parse contributes nothing.
489fn 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
504/// Map a non-2xx response to the error the facade branches on.
505fn 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    /// The trash verb is the soft delete and must stay a PUT to `/delete`; the
598    /// POST and DELETE forms are permanent.
599    #[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    /// `GET /api/folders` answers a list envelope, not a bare array; parsing the
613    /// array shape fails every folder read, and folder delete names its card
614    /// from this list.
615    #[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    /// A proxy echoing the token request must not put the password hash in a
644    /// tool result or the audit file; only the server's error field survives.
645    #[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    /// Vaultwarden states a refusal in `message`; Rocket's own catcher nests
662    /// its reason under `error`. Reading only the OAuth pair turns both into
663    /// an empty reason, and a 422 then cannot be told from any other.
664    #[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    /// A body that echoes the request carries secrets in fields the client
682    /// never reads; none of them may reach a tool result or the audit. A
683    /// body it cannot parse is not error text either.
684    #[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    /// Rocket's catcher can answer `reason` with no description; the pointer
694    /// must stand on its own rather than only behind a sibling.
695    #[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    /// The identity route keeps only its OAuth pair: the API's `message` and
702    /// nested pointers are not read there, so a proxy that re-encodes the
703    /// token form into one of them cannot reach the error text.
704    #[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    /// A relative download is the API route and takes the bearer; an absolute
724    /// one is signed and must not.
725    #[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    /// `base@host` is userinfo plus a foreign host when the base and the URL
750    /// are concatenated. Resolved as a path, it stays this vault's route.
751    #[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    /// An absolute URL naming another host is refused before any bearer is
775    /// attached or any request is sent.
776    #[tokio::test]
777    async fn a_download_to_another_host_is_refused() {
778        let (address, _server) = serve_once("200 OK", "bytes", "").await;
779        // A port that was bound and released refuses quickly if the guard is
780        // gone, so this fails on its own assertion rather than hanging.
781        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        // Same host as the base, different port: `Url::join` accepts it, so
788        // only the client's own host/port check can refuse it.
789        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    /// The declared length is checked before the body is read, and the stream
798    /// is bounded as it arrives for a response that declares no length.
799    #[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    /// A response that declares no length is bounded as it arrives, which is
814    /// the property the pre-check cannot provide.
815    #[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    /// A body that fails mid-read carries the failure class and not the URL.
830    #[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    /// A `.` or `..` segment is a traversal once a router normalizes it, not
847    /// an id, and `byte_serialize` would leave it untouched.
848    #[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    /// A signed URL's token lives in its query string, so no transport error
862    /// may print the URL it was sent to.
863    #[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    /// The same holds for an ordinary call's transport error.
878    #[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    /// An id is one path segment: a slash, a query marker or a space in it
889    /// cannot change the route the client asks for.
890    #[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    /// The v2 upload sends exactly one `data` part. A second part leaks
915    /// boundary bytes into the stored blob through the live hop.
916    #[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}