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