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            partial_data: None,
148            id: TEST_CIPHER_ID.parse().ok(),
149            name: Some(TEST_CIPHER_NAME.parse().unwrap()),
150            r#type: CipherType::Login,
151            attachments: Some(vec![Attachment {
152                id: Some(TEST_ATTACHMENT_ID.to_string()),
153                url: Some(TEST_FALLBACK_URL.to_string()),
154                file_name: Some(TEST_FILE_NAME.parse().unwrap()),
155                key: None,
156                size: Some("65".to_string()),
157                size_name: Some("65 Bytes".to_string()),
158            }]),
159            organization_id: None,
160            folder_id: None,
161            collection_ids: vec![],
162            key: None,
163            notes: None,
164            login: None,
165            identity: None,
166            card: None,
167            secure_note: None,
168            ssh_key: None,
169            bank_account: None,
170            drivers_license: None,
171            passport: None,
172            favorite: false,
173            reprompt: CipherRepromptType::None,
174            organization_use_totp: true,
175            edit: true,
176            permissions: None,
177            view_password: true,
178            local_data: None,
179            fields: None,
180            password_history: None,
181            creation_date: "2024-05-31T11:20:58.4566667Z".parse().unwrap(),
182            deleted_date: None,
183            revision_date: "2024-05-31T11:20:58.4566667Z".parse().unwrap(),
184            archived_date: None,
185            data: None,
186        }
187    }
188
189    fn not_found_response() -> bitwarden_api_api::ApiError {
190        bitwarden_api_api::ApiError::Response(bitwarden_api_api::ResponseContent {
191            status: StatusCode::NOT_FOUND,
192            message: String::new(),
193        })
194    }
195
196    #[tokio::test]
197    async fn returns_url_from_api_response() {
198        let api_client = ApiClient::new_mocked(|mock| {
199            mock.ciphers_api
200                .expect_get_attachment_data()
201                .returning(|id, attachment_id| {
202                    assert_eq!(&id.to_string(), TEST_CIPHER_ID);
203                    assert_eq!(attachment_id, TEST_ATTACHMENT_ID);
204                    Ok(AttachmentResponseModel {
205                        id: Some(TEST_ATTACHMENT_ID.to_string()),
206                        url: Some(TEST_API_URL.to_string()),
207                        ..Default::default()
208                    })
209                });
210        });
211
212        let client = client_with_api_and_repo(api_client, MemoryRepository::<Cipher>::default());
213        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
214
215        let url = client
216            .get_attachment_download_url(cipher_id, TEST_ATTACHMENT_ID.to_string(), None)
217            .await
218            .unwrap();
219
220        assert_eq!(url, TEST_API_URL);
221    }
222
223    #[tokio::test]
224    async fn returns_missing_field_when_response_has_no_url() {
225        let api_client = ApiClient::new_mocked(|mock| {
226            mock.ciphers_api
227                .expect_get_attachment_data()
228                .returning(|_id, _attachment_id| {
229                    Ok(AttachmentResponseModel {
230                        id: Some(TEST_ATTACHMENT_ID.to_string()),
231                        url: None,
232                        ..Default::default()
233                    })
234                });
235        });
236
237        let client = client_with_api_and_repo(api_client, MemoryRepository::<Cipher>::default());
238        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
239
240        let err = client
241            .get_attachment_download_url(cipher_id, TEST_ATTACHMENT_ID.to_string(), None)
242            .await
243            .unwrap_err();
244
245        assert!(matches!(
246            err,
247            CipherGetAttachmentDownloadUrlError::MissingField(_)
248        ));
249    }
250
251    #[tokio::test]
252    async fn falls_back_to_repository_url_on_404() {
253        let api_client = ApiClient::new_mocked(|mock| {
254            mock.ciphers_api
255                .expect_get_attachment_data()
256                .returning(|_id, _attachment_id| Err(not_found_response()));
257        });
258
259        let repository = MemoryRepository::<Cipher>::default();
260        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
261        repository.set(cipher_id, test_cipher()).await.unwrap();
262
263        let client = client_with_api_and_repo(api_client, repository);
264
265        let url = client
266            .get_attachment_download_url(cipher_id, TEST_ATTACHMENT_ID.to_string(), None)
267            .await
268            .unwrap();
269
270        assert_eq!(url, TEST_FALLBACK_URL);
271    }
272
273    #[tokio::test]
274    async fn returns_not_found_on_404_when_cipher_missing_from_repository() {
275        let api_client = ApiClient::new_mocked(|mock| {
276            mock.ciphers_api
277                .expect_get_attachment_data()
278                .returning(|_id, _attachment_id| Err(not_found_response()));
279        });
280
281        let client = client_with_api_and_repo(api_client, MemoryRepository::<Cipher>::default());
282        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
283
284        let err = client
285            .get_attachment_download_url(cipher_id, TEST_ATTACHMENT_ID.to_string(), None)
286            .await
287            .unwrap_err();
288
289        assert!(matches!(err, CipherGetAttachmentDownloadUrlError::NotFound));
290    }
291
292    #[tokio::test]
293    async fn returns_not_found_on_404_when_attachment_has_no_stored_url() {
294        let api_client = ApiClient::new_mocked(|mock| {
295            mock.ciphers_api
296                .expect_get_attachment_data()
297                .returning(|_id, _attachment_id| Err(not_found_response()));
298        });
299
300        let repository = MemoryRepository::<Cipher>::default();
301        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
302        let mut cipher = test_cipher();
303        if let Some(attachments) = cipher.attachments.as_mut() {
304            for attachment in attachments {
305                attachment.url = None;
306            }
307        }
308        repository.set(cipher_id, cipher).await.unwrap();
309
310        let client = client_with_api_and_repo(api_client, repository);
311
312        let err = client
313            .get_attachment_download_url(cipher_id, TEST_ATTACHMENT_ID.to_string(), None)
314            .await
315            .unwrap_err();
316
317        assert!(matches!(err, CipherGetAttachmentDownloadUrlError::NotFound));
318    }
319
320    #[tokio::test]
321    async fn propagates_non_404_api_errors() {
322        let api_client = ApiClient::new_mocked(|mock| {
323            mock.ciphers_api
324                .expect_get_attachment_data()
325                .returning(|_id, _attachment_id| {
326                    Err(bitwarden_api_api::ApiError::Response(
327                        bitwarden_api_api::ResponseContent {
328                            status: StatusCode::INTERNAL_SERVER_ERROR,
329                            message: "bitwarden".to_string(),
330                        },
331                    ))
332                });
333        });
334
335        let client = client_with_api_and_repo(api_client, MemoryRepository::<Cipher>::default());
336        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
337
338        let err = client
339            .get_attachment_download_url(cipher_id, TEST_ATTACHMENT_ID.to_string(), None)
340            .await
341            .unwrap_err();
342
343        assert!(matches!(err, CipherGetAttachmentDownloadUrlError::Api(_)));
344    }
345
346    #[tokio::test]
347    async fn emergency_access_returns_url_from_api_response() {
348        let api_client = ApiClient::new_mocked(|mock| {
349            mock.emergency_access_api
350                .expect_get_attachment_data()
351                .returning(|ea_id, cipher_id, attachment_id| {
352                    assert_eq!(&ea_id.to_string(), TEST_EMERGENCY_ACCESS_ID);
353                    assert_eq!(&cipher_id.to_string(), TEST_CIPHER_ID);
354                    assert_eq!(attachment_id, TEST_ATTACHMENT_ID);
355                    Ok(AttachmentResponseModel {
356                        id: Some(TEST_ATTACHMENT_ID.to_string()),
357                        url: Some(TEST_API_URL.to_string()),
358                        ..Default::default()
359                    })
360                });
361        });
362
363        let client = client_with_api_and_repo(api_client, MemoryRepository::<Cipher>::default());
364        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
365
366        let url = client
367            .get_attachment_download_url(
368                cipher_id,
369                TEST_ATTACHMENT_ID.to_string(),
370                Some(TEST_EMERGENCY_ACCESS_ID.to_string()),
371            )
372            .await
373            .unwrap();
374
375        assert_eq!(url, TEST_API_URL);
376    }
377
378    #[tokio::test]
379    async fn emergency_access_returns_missing_field_when_response_has_no_url() {
380        let api_client = ApiClient::new_mocked(|mock| {
381            mock.emergency_access_api
382                .expect_get_attachment_data()
383                .returning(|_ea_id, _cipher_id, _attachment_id| {
384                    Ok(AttachmentResponseModel {
385                        id: Some(TEST_ATTACHMENT_ID.to_string()),
386                        url: None,
387                        ..Default::default()
388                    })
389                });
390        });
391
392        let client = client_with_api_and_repo(api_client, MemoryRepository::<Cipher>::default());
393        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
394
395        let err = client
396            .get_attachment_download_url(
397                cipher_id,
398                TEST_ATTACHMENT_ID.to_string(),
399                Some(TEST_EMERGENCY_ACCESS_ID.to_string()),
400            )
401            .await
402            .unwrap_err();
403
404        assert!(matches!(
405            err,
406            CipherGetAttachmentDownloadUrlError::MissingField(_)
407        ));
408    }
409
410    #[tokio::test]
411    async fn emergency_access_returns_not_found_on_404() {
412        let api_client = ApiClient::new_mocked(|mock| {
413            mock.emergency_access_api
414                .expect_get_attachment_data()
415                .returning(|_ea_id, _cipher_id, _attachment_id| Err(not_found_response()));
416        });
417
418        let client = client_with_api_and_repo(api_client, MemoryRepository::<Cipher>::default());
419        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
420
421        let err = client
422            .get_attachment_download_url(
423                cipher_id,
424                TEST_ATTACHMENT_ID.to_string(),
425                Some(TEST_EMERGENCY_ACCESS_ID.to_string()),
426            )
427            .await
428            .unwrap_err();
429
430        assert!(matches!(err, CipherGetAttachmentDownloadUrlError::NotFound));
431    }
432
433    #[tokio::test]
434    async fn emergency_access_propagates_non_404_api_errors() {
435        let api_client = ApiClient::new_mocked(|mock| {
436            mock.emergency_access_api
437                .expect_get_attachment_data()
438                .returning(|_ea_id, _cipher_id, _attachment_id| {
439                    Err(bitwarden_api_api::ApiError::Response(
440                        bitwarden_api_api::ResponseContent {
441                            status: StatusCode::INTERNAL_SERVER_ERROR,
442                            message: "bitwarden".to_string(),
443                        },
444                    ))
445                });
446        });
447
448        let client = client_with_api_and_repo(api_client, MemoryRepository::<Cipher>::default());
449        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
450
451        let err = client
452            .get_attachment_download_url(
453                cipher_id,
454                TEST_ATTACHMENT_ID.to_string(),
455                Some(TEST_EMERGENCY_ACCESS_ID.to_string()),
456            )
457            .await
458            .unwrap_err();
459
460        assert!(matches!(err, CipherGetAttachmentDownloadUrlError::Api(_)));
461    }
462
463    #[tokio::test]
464    async fn returns_invalid_emergency_access_id_when_parse_fails() {
465        let api_client = ApiClient::new_mocked(|_mock| {});
466        let client = client_with_api_and_repo(api_client, MemoryRepository::<Cipher>::default());
467        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
468
469        let err = client
470            .get_attachment_download_url(
471                cipher_id,
472                TEST_ATTACHMENT_ID.to_string(),
473                Some("not-a-uuid".to_string()),
474            )
475            .await
476            .unwrap_err();
477
478        assert!(matches!(
479            err,
480            CipherGetAttachmentDownloadUrlError::InvalidEmergencyAccessId
481        ));
482    }
483}