Skip to main content

bitwarden_vault/cipher/attachment_client/
download_url.rs

1use bitwarden_core::{ApiError, MissingFieldError};
2use bitwarden_error::bitwarden_error;
3use bitwarden_state::repository::{RepositoryError, RepositoryOption};
4use reqwest::StatusCode;
5use thiserror::Error;
6#[cfg(feature = "wasm")]
7use wasm_bindgen::prelude::wasm_bindgen;
8
9use crate::{AttachmentsClient, CipherId};
10
11#[allow(missing_docs)]
12#[bitwarden_error(flat)]
13#[derive(Debug, Error)]
14pub enum CipherGetAttachmentDownloadUrlError {
15    #[error(transparent)]
16    Api(#[from] ApiError),
17    #[error(transparent)]
18    Repository(#[from] RepositoryError),
19    #[error(transparent)]
20    MissingField(#[from] MissingFieldError),
21    #[error("Cipher or attachment not found")]
22    NotFound,
23    #[error("Invalid emergency access ID")]
24    InvalidEmergencyAccessId,
25}
26
27impl<T> From<bitwarden_api_api::apis::Error<T>> for CipherGetAttachmentDownloadUrlError {
28    fn from(value: bitwarden_api_api::apis::Error<T>) -> Self {
29        Self::Api(value.into())
30    }
31}
32
33#[cfg_attr(feature = "wasm", wasm_bindgen)]
34impl AttachmentsClient {
35    /// Returns the attachment download URL.
36    ///
37    /// With `emergency_access_id`, uses the emergency-access endpoint and never falls back.
38    /// Otherwise uses the cipher endpoint and falls back to the local repository on 404.
39    pub async fn get_attachment_download_url(
40        &self,
41        cipher_id: CipherId,
42        attachment_id: String,
43        emergency_access_id: Option<String>,
44    ) -> Result<String, CipherGetAttachmentDownloadUrlError> {
45        if let Some(emergency_access_id) = emergency_access_id {
46            return self
47                .get_emergency_access_attachment_download_url(
48                    &emergency_access_id,
49                    cipher_id,
50                    &attachment_id,
51                )
52                .await;
53        }
54
55        match self
56            .api_configurations
57            .api_client
58            .ciphers_api()
59            .get_attachment_data(cipher_id.into(), &attachment_id)
60            .await
61        {
62            Ok(response) => response.url.ok_or_else(|| MissingFieldError("url").into()),
63            Err(bitwarden_api_api::apis::Error::Response(content))
64                if content.status == StatusCode::NOT_FOUND =>
65            {
66                let repository = self.repository.require()?;
67                let cipher = repository
68                    .get(cipher_id)
69                    .await?
70                    .ok_or(CipherGetAttachmentDownloadUrlError::NotFound)?;
71
72                cipher
73                    .attachments
74                    .and_then(|attachments| {
75                        attachments
76                            .into_iter()
77                            .find(|a| a.id.as_deref() == Some(&attachment_id))
78                    })
79                    .and_then(|attachment| attachment.url)
80                    .ok_or(CipherGetAttachmentDownloadUrlError::NotFound)
81            }
82            Err(e) => Err(e.into()),
83        }
84    }
85}
86
87impl AttachmentsClient {
88    /// Fetches an attachment download URL via the emergency-access endpoint.
89    async fn get_emergency_access_attachment_download_url(
90        &self,
91        emergency_access_id: &str,
92        cipher_id: CipherId,
93        attachment_id: &str,
94    ) -> Result<String, CipherGetAttachmentDownloadUrlError> {
95        let emergency_access_id = emergency_access_id
96            .parse::<uuid::Uuid>()
97            .map_err(|_| CipherGetAttachmentDownloadUrlError::InvalidEmergencyAccessId)?;
98
99        let response = self
100            .api_configurations
101            .api_client
102            .emergency_access_api()
103            .get_attachment_data(emergency_access_id, cipher_id.into(), attachment_id)
104            .await
105            .map_err(|e| match e {
106                bitwarden_api_api::apis::Error::Response(content)
107                    if content.status == StatusCode::NOT_FOUND =>
108                {
109                    CipherGetAttachmentDownloadUrlError::NotFound
110                }
111                other => other.into(),
112            })?;
113
114        response.url.ok_or_else(|| MissingFieldError("url").into())
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use std::sync::Arc;
121
122    use bitwarden_api_api::{apis::ApiClient, models::AttachmentResponseModel};
123    use bitwarden_core::{client::ApiConfigurations, key_management::KeySlotIds};
124    use bitwarden_crypto::KeyStore;
125    use bitwarden_state::repository::Repository;
126    use bitwarden_test::MemoryRepository;
127
128    use super::*;
129    use crate::{Attachment, Cipher, CipherRepromptType, CipherType};
130
131    const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097";
132    const TEST_ATTACHMENT_ID: &str = "uf7bkexzag04d3cw04jsbqqkbpbwhxs0";
133    const TEST_CIPHER_NAME: &str = "2.pMS6/icTQABtulw52pq2lg==|XXbxKxDTh+mWiN1HjH2N1w==|Q6PkuT+KX/axrgN9ubD5Ajk2YNwxQkgs3WJM0S0wtG8=";
134    const TEST_FILE_NAME: &str = "2.mV50WiLq6duhwGbhM1TO0A==|dTufWNH8YTPP0EMlNLIpFA==|QHp+7OM8xHtEmCfc9QPXJ0Ro2BeakzvLgxJZ7NdLuDc=";
135    const TEST_API_URL: &str = "http://localhost:4000/attachments/test/api";
136    const TEST_FALLBACK_URL: &str = "http://localhost:4000/attachments/test/fallback";
137    const TEST_EMERGENCY_ACCESS_ID: &str = "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d";
138
139    fn client_with_api_and_repo(
140        api_client: ApiClient,
141        repository: MemoryRepository<Cipher>,
142    ) -> AttachmentsClient {
143        AttachmentsClient {
144            key_store: KeyStore::<KeySlotIds>::default(),
145            api_configurations: Arc::new(ApiConfigurations::from_api_client(api_client)),
146            repository: Some(Arc::new(repository)),
147            http_client: reqwest::Client::new(),
148        }
149    }
150
151    fn test_cipher() -> Cipher {
152        Cipher {
153            id: TEST_CIPHER_ID.parse().ok(),
154            name: Some(TEST_CIPHER_NAME.parse().unwrap()),
155            r#type: CipherType::Login,
156            attachments: Some(vec![Attachment {
157                id: Some(TEST_ATTACHMENT_ID.to_string()),
158                url: Some(TEST_FALLBACK_URL.to_string()),
159                file_name: Some(TEST_FILE_NAME.parse().unwrap()),
160                key: None,
161                size: Some("65".to_string()),
162                size_name: Some("65 Bytes".to_string()),
163            }]),
164            organization_id: None,
165            folder_id: None,
166            collection_ids: vec![],
167            key: None,
168            notes: None,
169            login: None,
170            identity: None,
171            card: None,
172            secure_note: None,
173            ssh_key: None,
174            bank_account: None,
175            drivers_license: None,
176            passport: None,
177            favorite: false,
178            reprompt: CipherRepromptType::None,
179            organization_use_totp: true,
180            edit: true,
181            permissions: None,
182            view_password: true,
183            local_data: None,
184            fields: None,
185            password_history: None,
186            creation_date: "2024-05-31T11:20:58.4566667Z".parse().unwrap(),
187            deleted_date: None,
188            revision_date: "2024-05-31T11:20:58.4566667Z".parse().unwrap(),
189            archived_date: None,
190            data: None,
191        }
192    }
193
194    fn not_found_response() -> bitwarden_api_api::apis::Error<()> {
195        bitwarden_api_api::apis::Error::Response(bitwarden_api_api::apis::ResponseContent {
196            status: StatusCode::NOT_FOUND,
197            message: String::new(),
198        })
199    }
200
201    #[tokio::test]
202    async fn returns_url_from_api_response() {
203        let api_client = ApiClient::new_mocked(|mock| {
204            mock.ciphers_api
205                .expect_get_attachment_data()
206                .returning(|id, attachment_id| {
207                    assert_eq!(&id.to_string(), TEST_CIPHER_ID);
208                    assert_eq!(attachment_id, TEST_ATTACHMENT_ID);
209                    Ok(AttachmentResponseModel {
210                        id: Some(TEST_ATTACHMENT_ID.to_string()),
211                        url: Some(TEST_API_URL.to_string()),
212                        ..Default::default()
213                    })
214                });
215        });
216
217        let client = client_with_api_and_repo(api_client, MemoryRepository::<Cipher>::default());
218        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
219
220        let url = client
221            .get_attachment_download_url(cipher_id, TEST_ATTACHMENT_ID.to_string(), None)
222            .await
223            .unwrap();
224
225        assert_eq!(url, TEST_API_URL);
226    }
227
228    #[tokio::test]
229    async fn returns_missing_field_when_response_has_no_url() {
230        let api_client = ApiClient::new_mocked(|mock| {
231            mock.ciphers_api
232                .expect_get_attachment_data()
233                .returning(|_id, _attachment_id| {
234                    Ok(AttachmentResponseModel {
235                        id: Some(TEST_ATTACHMENT_ID.to_string()),
236                        url: None,
237                        ..Default::default()
238                    })
239                });
240        });
241
242        let client = client_with_api_and_repo(api_client, MemoryRepository::<Cipher>::default());
243        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
244
245        let err = client
246            .get_attachment_download_url(cipher_id, TEST_ATTACHMENT_ID.to_string(), None)
247            .await
248            .unwrap_err();
249
250        assert!(matches!(
251            err,
252            CipherGetAttachmentDownloadUrlError::MissingField(_)
253        ));
254    }
255
256    #[tokio::test]
257    async fn falls_back_to_repository_url_on_404() {
258        let api_client = ApiClient::new_mocked(|mock| {
259            mock.ciphers_api
260                .expect_get_attachment_data()
261                .returning(|_id, _attachment_id| Err(not_found_response()));
262        });
263
264        let repository = MemoryRepository::<Cipher>::default();
265        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
266        repository.set(cipher_id, test_cipher()).await.unwrap();
267
268        let client = client_with_api_and_repo(api_client, repository);
269
270        let url = client
271            .get_attachment_download_url(cipher_id, TEST_ATTACHMENT_ID.to_string(), None)
272            .await
273            .unwrap();
274
275        assert_eq!(url, TEST_FALLBACK_URL);
276    }
277
278    #[tokio::test]
279    async fn returns_not_found_on_404_when_cipher_missing_from_repository() {
280        let api_client = ApiClient::new_mocked(|mock| {
281            mock.ciphers_api
282                .expect_get_attachment_data()
283                .returning(|_id, _attachment_id| Err(not_found_response()));
284        });
285
286        let client = client_with_api_and_repo(api_client, MemoryRepository::<Cipher>::default());
287        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
288
289        let err = client
290            .get_attachment_download_url(cipher_id, TEST_ATTACHMENT_ID.to_string(), None)
291            .await
292            .unwrap_err();
293
294        assert!(matches!(err, CipherGetAttachmentDownloadUrlError::NotFound));
295    }
296
297    #[tokio::test]
298    async fn returns_not_found_on_404_when_attachment_has_no_stored_url() {
299        let api_client = ApiClient::new_mocked(|mock| {
300            mock.ciphers_api
301                .expect_get_attachment_data()
302                .returning(|_id, _attachment_id| Err(not_found_response()));
303        });
304
305        let repository = MemoryRepository::<Cipher>::default();
306        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
307        let mut cipher = test_cipher();
308        if let Some(attachments) = cipher.attachments.as_mut() {
309            for attachment in attachments {
310                attachment.url = None;
311            }
312        }
313        repository.set(cipher_id, cipher).await.unwrap();
314
315        let client = client_with_api_and_repo(api_client, repository);
316
317        let err = client
318            .get_attachment_download_url(cipher_id, TEST_ATTACHMENT_ID.to_string(), None)
319            .await
320            .unwrap_err();
321
322        assert!(matches!(err, CipherGetAttachmentDownloadUrlError::NotFound));
323    }
324
325    #[tokio::test]
326    async fn propagates_non_404_api_errors() {
327        let api_client = ApiClient::new_mocked(|mock| {
328            mock.ciphers_api
329                .expect_get_attachment_data()
330                .returning(|_id, _attachment_id| {
331                    Err(bitwarden_api_api::apis::Error::Response(
332                        bitwarden_api_api::apis::ResponseContent {
333                            status: StatusCode::INTERNAL_SERVER_ERROR,
334                            message: "bitwarden".to_string(),
335                        },
336                    ))
337                });
338        });
339
340        let client = client_with_api_and_repo(api_client, MemoryRepository::<Cipher>::default());
341        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
342
343        let err = client
344            .get_attachment_download_url(cipher_id, TEST_ATTACHMENT_ID.to_string(), None)
345            .await
346            .unwrap_err();
347
348        assert!(matches!(err, CipherGetAttachmentDownloadUrlError::Api(_)));
349    }
350
351    #[tokio::test]
352    async fn emergency_access_returns_url_from_api_response() {
353        let api_client = ApiClient::new_mocked(|mock| {
354            mock.emergency_access_api
355                .expect_get_attachment_data()
356                .returning(|ea_id, cipher_id, attachment_id| {
357                    assert_eq!(&ea_id.to_string(), TEST_EMERGENCY_ACCESS_ID);
358                    assert_eq!(&cipher_id.to_string(), TEST_CIPHER_ID);
359                    assert_eq!(attachment_id, TEST_ATTACHMENT_ID);
360                    Ok(AttachmentResponseModel {
361                        id: Some(TEST_ATTACHMENT_ID.to_string()),
362                        url: Some(TEST_API_URL.to_string()),
363                        ..Default::default()
364                    })
365                });
366        });
367
368        let client = client_with_api_and_repo(api_client, MemoryRepository::<Cipher>::default());
369        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
370
371        let url = client
372            .get_attachment_download_url(
373                cipher_id,
374                TEST_ATTACHMENT_ID.to_string(),
375                Some(TEST_EMERGENCY_ACCESS_ID.to_string()),
376            )
377            .await
378            .unwrap();
379
380        assert_eq!(url, TEST_API_URL);
381    }
382
383    #[tokio::test]
384    async fn emergency_access_returns_missing_field_when_response_has_no_url() {
385        let api_client = ApiClient::new_mocked(|mock| {
386            mock.emergency_access_api
387                .expect_get_attachment_data()
388                .returning(|_ea_id, _cipher_id, _attachment_id| {
389                    Ok(AttachmentResponseModel {
390                        id: Some(TEST_ATTACHMENT_ID.to_string()),
391                        url: None,
392                        ..Default::default()
393                    })
394                });
395        });
396
397        let client = client_with_api_and_repo(api_client, MemoryRepository::<Cipher>::default());
398        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
399
400        let err = client
401            .get_attachment_download_url(
402                cipher_id,
403                TEST_ATTACHMENT_ID.to_string(),
404                Some(TEST_EMERGENCY_ACCESS_ID.to_string()),
405            )
406            .await
407            .unwrap_err();
408
409        assert!(matches!(
410            err,
411            CipherGetAttachmentDownloadUrlError::MissingField(_)
412        ));
413    }
414
415    #[tokio::test]
416    async fn emergency_access_returns_not_found_on_404() {
417        let api_client = ApiClient::new_mocked(|mock| {
418            mock.emergency_access_api
419                .expect_get_attachment_data()
420                .returning(|_ea_id, _cipher_id, _attachment_id| Err(not_found_response()));
421        });
422
423        let client = client_with_api_and_repo(api_client, MemoryRepository::<Cipher>::default());
424        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
425
426        let err = client
427            .get_attachment_download_url(
428                cipher_id,
429                TEST_ATTACHMENT_ID.to_string(),
430                Some(TEST_EMERGENCY_ACCESS_ID.to_string()),
431            )
432            .await
433            .unwrap_err();
434
435        assert!(matches!(err, CipherGetAttachmentDownloadUrlError::NotFound));
436    }
437
438    #[tokio::test]
439    async fn emergency_access_propagates_non_404_api_errors() {
440        let api_client = ApiClient::new_mocked(|mock| {
441            mock.emergency_access_api
442                .expect_get_attachment_data()
443                .returning(|_ea_id, _cipher_id, _attachment_id| {
444                    Err(bitwarden_api_api::apis::Error::Response(
445                        bitwarden_api_api::apis::ResponseContent {
446                            status: StatusCode::INTERNAL_SERVER_ERROR,
447                            message: "bitwarden".to_string(),
448                        },
449                    ))
450                });
451        });
452
453        let client = client_with_api_and_repo(api_client, MemoryRepository::<Cipher>::default());
454        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
455
456        let err = client
457            .get_attachment_download_url(
458                cipher_id,
459                TEST_ATTACHMENT_ID.to_string(),
460                Some(TEST_EMERGENCY_ACCESS_ID.to_string()),
461            )
462            .await
463            .unwrap_err();
464
465        assert!(matches!(err, CipherGetAttachmentDownloadUrlError::Api(_)));
466    }
467
468    #[tokio::test]
469    async fn returns_invalid_emergency_access_id_when_parse_fails() {
470        let api_client = ApiClient::new_mocked(|_mock| {});
471        let client = client_with_api_and_repo(api_client, MemoryRepository::<Cipher>::default());
472        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
473
474        let err = client
475            .get_attachment_download_url(
476                cipher_id,
477                TEST_ATTACHMENT_ID.to_string(),
478                Some("not-a-uuid".to_string()),
479            )
480            .await
481            .unwrap_err();
482
483        assert!(matches!(
484            err,
485            CipherGetAttachmentDownloadUrlError::InvalidEmergencyAccessId
486        ));
487    }
488}