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