Skip to main content

bitwarden_vault/cipher/attachment_client/
create.rs

1use bitwarden_api_api::models::AttachmentRequestModel;
2use bitwarden_core::{ApiError, MissingFieldError};
3use bitwarden_crypto::EncString;
4use bitwarden_error::bitwarden_error;
5use bitwarden_state::repository::{RepositoryError, RepositoryOption};
6use chrono::{DateTime, SecondsFormat, Utc};
7use serde::{Deserialize, Serialize};
8use thiserror::Error;
9#[cfg(feature = "wasm")]
10use {tsify::Tsify, wasm_bindgen::prelude::*};
11
12use crate::{AttachmentsClient, Cipher, CipherId, VaultParseError, cipher::cipher::PartialCipher};
13
14#[allow(missing_docs)]
15#[bitwarden_error(flat)]
16#[derive(Debug, Error)]
17pub enum CipherCreateAttachmentError {
18    #[error(transparent)]
19    Api(#[from] ApiError),
20    #[error(transparent)]
21    Repository(#[from] RepositoryError),
22    #[error(transparent)]
23    MissingField(#[from] MissingFieldError),
24    #[error(transparent)]
25    VaultParse(#[from] VaultParseError),
26    #[error("Server returned an unsupported file upload type")]
27    UnsupportedFileUploadType,
28}
29
30/// Where attachment bytes should be uploaded.
31#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
32#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
33pub enum AttachmentFileUploadType {
34    /// Upload directly to the Bitwarden server.
35    Direct,
36    /// Upload to Azure Blob storage via the returned presigned URL.
37    Azure,
38}
39
40impl TryFrom<bitwarden_api_api::models::FileUploadType> for AttachmentFileUploadType {
41    type Error = CipherCreateAttachmentError;
42
43    fn try_from(value: bitwarden_api_api::models::FileUploadType) -> Result<Self, Self::Error> {
44        match value {
45            bitwarden_api_api::models::FileUploadType::Direct => Ok(Self::Direct),
46            bitwarden_api_api::models::FileUploadType::Azure => Ok(Self::Azure),
47            bitwarden_api_api::models::FileUploadType::__Unknown(_) => {
48                Err(CipherCreateAttachmentError::UnsupportedFileUploadType)
49            }
50        }
51    }
52}
53
54/// Metadata for opening a new attachment slot on the server.
55///
56/// The caller pre-encrypts the key, file name, and contents; the SDK only opens the slot and
57/// the caller uploads. See `upgrade_attachment` for the alternative where the SDK owns the
58/// encryption and upload.
59#[derive(Clone, Debug, Serialize, Deserialize)]
60#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
61#[serde(rename_all = "camelCase")]
62pub struct CreateAttachmentRequest {
63    /// Encrypted attachment key.
64    pub key: EncString,
65    /// Encrypted file name.
66    pub file_name: EncString,
67    /// Encrypted file size in byte
68    pub file_size: u64,
69    /// Cipher revision date
70    pub last_known_revision_date: DateTime<Utc>,
71    /// Uses the admin auth scope. The server returns a
72    /// `CipherMiniResponseModel`, and the local repository is not updated.
73    pub as_admin: bool,
74}
75
76impl From<CreateAttachmentRequest> for AttachmentRequestModel {
77    fn from(value: CreateAttachmentRequest) -> Self {
78        Self {
79            key: Some(value.key.to_string()),
80            file_name: Some(value.file_name.to_string()),
81            file_size: Some(value.file_size as i64),
82            admin_request: Some(value.as_admin),
83            last_known_revision_date: Some(
84                value
85                    .last_known_revision_date
86                    .to_rfc3339_opts(SecondsFormat::Millis, true),
87            ),
88        }
89    }
90}
91
92/// Server data for a newly created attachment slot. The caller uploads the
93/// encrypted bytes to [`Self::upload_url`] using [`Self::file_upload_type`]
94#[derive(Clone, Debug, Serialize, Deserialize)]
95#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
96#[serde(rename_all = "camelCase")]
97pub struct CreatedAttachment {
98    /// Server-assigned attachment ID.
99    pub attachment_id: String,
100    /// Upload target for the encrypted bytes
101    pub upload_url: String,
102    /// Bitwarden server or Azure Blob Storage.
103    pub file_upload_type: AttachmentFileUploadType,
104    /// Cipher returned by the server. For non-admin requests, this is also
105    /// written to the local repository
106    pub cipher: Cipher,
107}
108
109#[cfg_attr(feature = "wasm", wasm_bindgen)]
110impl AttachmentsClient {
111    /// Creates a new attachment slot on the server and updates local repository
112    /// state with the merged cipher returned by the server.
113    ///
114    /// The caller must upload the encrypted bytes to [`CreatedAttachment::upload_url`]
115    /// using the transport in [`CreatedAttachment::file_upload_type`].
116    ///
117    /// If a later step fails after slot creation, the SDK best-effort deletes the
118    /// orphaned slot and returns the original error.
119    pub async fn create_attachment(
120        &self,
121        cipher_id: CipherId,
122        request: CreateAttachmentRequest,
123    ) -> Result<CreatedAttachment, CipherCreateAttachmentError> {
124        let as_admin = request.as_admin;
125        let repository = self.repository.require()?;
126        let existing_cipher = if as_admin {
127            None
128        } else {
129            repository.get(cipher_id).await?
130        };
131
132        let api_client = &self.api_configurations.api_client;
133        let response = api_client
134            .ciphers_api()
135            .post_attachment(cipher_id.into(), Some(request.into()))
136            .await?;
137
138        // Read the attachment ID first so we can best-effort roll back
139        // if anything else fails.
140        let new_attachment_id = response
141            .attachment_id
142            .clone()
143            .ok_or(MissingFieldError("attachment_id"))?;
144
145        let result = self
146            .finalize_create(response, existing_cipher, cipher_id, as_admin)
147            .await;
148
149        if result.is_err() {
150            let rollback = if as_admin {
151                api_client
152                    .ciphers_api()
153                    .delete_attachment_admin(cipher_id.into(), &new_attachment_id)
154                    .await
155                    .map(|_| ())
156                    .map_err(|e| format!("{e:?}"))
157            } else {
158                api_client
159                    .ciphers_api()
160                    .delete_attachment(cipher_id.into(), &new_attachment_id)
161                    .await
162                    .map(|_| ())
163                    .map_err(|e| format!("{e:?}"))
164            };
165
166            if let Err(rollback_err) = rollback {
167                tracing::warn!(
168                    "failed to roll back orphaned attachment slot {new_attachment_id} on cipher {cipher_id}: {rollback_err}",
169                );
170            }
171        }
172
173        result
174    }
175
176    async fn finalize_create(
177        &self,
178        response: bitwarden_api_api::models::AttachmentUploadDataResponseModel,
179        existing_cipher: Option<Cipher>,
180        cipher_id: CipherId,
181        as_admin: bool,
182    ) -> Result<CreatedAttachment, CipherCreateAttachmentError> {
183        let cipher = if as_admin {
184            let cipher_mini = response
185                .cipher_mini_response
186                .ok_or(MissingFieldError("cipher_mini_response"))?;
187            (*cipher_mini).merge_with_cipher(existing_cipher)?
188        } else {
189            let cipher_response = response
190                .cipher_response
191                .ok_or(MissingFieldError("cipher_response"))?;
192            let merged = (*cipher_response).merge_with_cipher(existing_cipher)?;
193            self.repository
194                .require()?
195                .set(cipher_id, merged.clone())
196                .await?;
197            merged
198        };
199
200        let attachment_id = response
201            .attachment_id
202            .ok_or(MissingFieldError("attachment_id"))?;
203        let upload_url = response.url.ok_or(MissingFieldError("url"))?;
204        let file_upload_type: AttachmentFileUploadType = response
205            .file_upload_type
206            .ok_or(MissingFieldError("file_upload_type"))?
207            .try_into()?;
208
209        Ok(CreatedAttachment {
210            attachment_id,
211            upload_url,
212            file_upload_type,
213            cipher,
214        })
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use std::sync::Arc;
221
222    use bitwarden_api_api::{
223        apis::ApiClient,
224        models::{
225            AttachmentUploadDataResponseModel, CipherMiniResponseModel, CipherResponseModel,
226            DeleteAttachmentResponseModel,
227        },
228    };
229    use bitwarden_core::{client::ApiConfigurations, key_management::KeySlotIds};
230    use bitwarden_crypto::KeyStore;
231    use bitwarden_state::repository::Repository;
232    use bitwarden_test::MemoryRepository;
233
234    use super::*;
235    use crate::{CipherRepromptType, CipherType};
236
237    const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097";
238    const NEW_ATTACHMENT_ID: &str = "newatt9999999999999999999999999";
239    const TEST_CIPHER_NAME: &str = "2.pMS6/icTQABtulw52pq2lg==|XXbxKxDTh+mWiN1HjH2N1w==|Q6PkuT+KX/axrgN9ubD5Ajk2YNwxQkgs3WJM0S0wtG8=";
240    const TEST_FILE_NAME: &str = "2.mV50WiLq6duhwGbhM1TO0A==|dTufWNH8YTPP0EMlNLIpFA==|QHp+7OM8xHtEmCfc9QPXJ0Ro2BeakzvLgxJZ7NdLuDc=";
241    const TEST_KEY: &str = "2.6TPEiYULFg/4+3CpDRwCqw==|6swweBHCJcd5CHdwBBWuRN33XRV22VoroDFDUmiM4OzjPEAhgZK57IZS1KkBlCcFvT+t+YbsmDcdv+Lqr+iJ3MmzfJ40MCB5TfYy+22HVRA=|rkgFDh2IWTfPC1Y66h68Diiab/deyi1p/X0Fwkva0NQ=";
242
243    fn client_with_api_and_repo(
244        api_client: ApiClient,
245        repository: MemoryRepository<Cipher>,
246    ) -> (AttachmentsClient, Arc<MemoryRepository<Cipher>>) {
247        let repo_arc = Arc::new(repository);
248        let client = AttachmentsClient {
249            key_store: KeyStore::<KeySlotIds>::default(),
250            api_configurations: Arc::new(ApiConfigurations::from_api_client(api_client)),
251            repository: Some(repo_arc.clone()),
252            http_client: reqwest::Client::new(),
253        };
254        (client, repo_arc)
255    }
256
257    fn test_request() -> CreateAttachmentRequest {
258        CreateAttachmentRequest {
259            key: TEST_KEY.parse().unwrap(),
260            file_name: TEST_FILE_NAME.parse().unwrap(),
261            file_size: 65,
262            last_known_revision_date: "2024-05-31T11:20:58.456Z".parse().unwrap(),
263            as_admin: false,
264        }
265    }
266
267    fn admin_request() -> CreateAttachmentRequest {
268        CreateAttachmentRequest {
269            as_admin: true,
270            ..test_request()
271        }
272    }
273
274    fn test_cipher() -> Cipher {
275        Cipher {
276            id: TEST_CIPHER_ID.parse().ok(),
277            name: Some(TEST_CIPHER_NAME.parse().unwrap()),
278            r#type: CipherType::Login,
279            attachments: None,
280            organization_id: None,
281            folder_id: None,
282            collection_ids: vec![],
283            key: None,
284            notes: None,
285            login: None,
286            identity: None,
287            card: None,
288            secure_note: None,
289            ssh_key: None,
290            bank_account: None,
291            drivers_license: None,
292            passport: None,
293            favorite: false,
294            reprompt: CipherRepromptType::None,
295            organization_use_totp: true,
296            edit: true,
297            permissions: None,
298            view_password: true,
299            local_data: None,
300            fields: None,
301            password_history: None,
302            creation_date: "2024-05-31T11:20:58.4566667Z".parse().unwrap(),
303            deleted_date: None,
304            revision_date: "2024-05-31T11:20:58.4566667Z".parse().unwrap(),
305            archived_date: None,
306            data: None,
307        }
308    }
309
310    fn server_cipher_response() -> CipherResponseModel {
311        CipherResponseModel {
312            id: Some(TEST_CIPHER_ID.try_into().unwrap()),
313            name: Some(TEST_CIPHER_NAME.to_string()),
314            r#type: Some(bitwarden_api_api::models::CipherType::Login),
315            creation_date: Some("2024-05-31T11:20:58.4566667Z".to_string()),
316            revision_date: Some("2024-05-31T11:20:58.4566667Z".to_string()),
317            ..Default::default()
318        }
319    }
320
321    #[tokio::test]
322    async fn returns_created_attachment_on_success() {
323        let api_client = ApiClient::new_mocked(|mock| {
324            mock.ciphers_api
325                .expect_post_attachment()
326                .returning(|_id, _req| {
327                    Ok(AttachmentUploadDataResponseModel {
328                        attachment_id: Some(NEW_ATTACHMENT_ID.to_string()),
329                        url: Some("http://example.com/upload".to_string()),
330                        file_upload_type: Some(bitwarden_api_api::models::FileUploadType::Direct),
331                        cipher_response: Some(Box::new(server_cipher_response())),
332                        cipher_mini_response: None,
333                        ..Default::default()
334                    })
335                });
336            mock.ciphers_api.expect_delete_attachment().never();
337        });
338
339        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
340        let repository = MemoryRepository::<Cipher>::default();
341        repository.set(cipher_id, test_cipher()).await.unwrap();
342        let (client, repo) = client_with_api_and_repo(api_client, repository);
343
344        let result = client
345            .create_attachment(cipher_id, test_request())
346            .await
347            .unwrap();
348
349        assert_eq!(result.attachment_id, NEW_ATTACHMENT_ID);
350        assert_eq!(result.upload_url, "http://example.com/upload");
351        assert_eq!(result.file_upload_type, AttachmentFileUploadType::Direct);
352        // Merged cipher is now returned inline for both paths (Comment 5).
353        assert_eq!(result.cipher.id, Some(cipher_id));
354
355        // Repository should have the merged cipher state from the response.
356        let stored = repo.get(cipher_id).await.unwrap().unwrap();
357        assert_eq!(stored.id, Some(cipher_id));
358    }
359
360    #[tokio::test]
361    async fn admin_returns_cipher_from_mini_response_and_skips_repository_write() {
362        let api_client = ApiClient::new_mocked(|mock| {
363            mock.ciphers_api
364                .expect_post_attachment()
365                .withf(|_id, req| req.as_ref().and_then(|r| r.admin_request).unwrap_or(false))
366                .returning(|_id, _req| {
367                    Ok(AttachmentUploadDataResponseModel {
368                        attachment_id: Some(NEW_ATTACHMENT_ID.to_string()),
369                        url: Some("http://example.com/upload".to_string()),
370                        file_upload_type: Some(bitwarden_api_api::models::FileUploadType::Direct),
371                        cipher_response: None,
372                        cipher_mini_response: Some(Box::new(CipherMiniResponseModel {
373                            id: Some(TEST_CIPHER_ID.try_into().unwrap()),
374                            name: Some(TEST_CIPHER_NAME.to_string()),
375                            r#type: Some(bitwarden_api_api::models::CipherType::Login),
376                            creation_date: Some("2024-05-31T11:20:58.4566667Z".to_string()),
377                            revision_date: Some("2024-05-31T11:20:58.4566667Z".to_string()),
378                            attachments: None,
379                            ..Default::default()
380                        })),
381                        ..Default::default()
382                    })
383                });
384            mock.ciphers_api.expect_delete_attachment().never();
385            mock.ciphers_api.expect_delete_attachment_admin().never();
386        });
387
388        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
389        let (client, repo) =
390            client_with_api_and_repo(api_client, MemoryRepository::<Cipher>::default());
391
392        let result = client
393            .create_attachment(cipher_id, admin_request())
394            .await
395            .unwrap();
396
397        assert_eq!(result.attachment_id, NEW_ATTACHMENT_ID);
398        assert_eq!(result.upload_url, "http://example.com/upload");
399        assert_eq!(result.cipher.id, Some(cipher_id));
400
401        // Admin path must not write to the local repository.
402        assert!(repo.get(cipher_id).await.unwrap().is_none());
403    }
404
405    #[tokio::test]
406    async fn admin_rolls_back_via_admin_delete_when_finalize_fails() {
407        let api_client = ApiClient::new_mocked(|mock| {
408            mock.ciphers_api
409                .expect_post_attachment()
410                .returning(|_id, _req| {
411                    Ok(AttachmentUploadDataResponseModel {
412                        attachment_id: Some(NEW_ATTACHMENT_ID.to_string()),
413                        url: Some("http://example.com/upload".to_string()),
414                        file_upload_type: Some(bitwarden_api_api::models::FileUploadType::Direct),
415                        cipher_response: None,
416                        cipher_mini_response: None,
417                        ..Default::default()
418                    })
419                });
420            // Admin rollback uses the admin DELETE endpoint, not the user one.
421            mock.ciphers_api
422                .expect_delete_attachment_admin()
423                .withf(|_id, attachment_id| attachment_id == NEW_ATTACHMENT_ID)
424                .times(1)
425                .returning(|_id, _att_id| {
426                    Ok(DeleteAttachmentResponseModel {
427                        object: None,
428                        cipher: None,
429                    })
430                });
431            mock.ciphers_api.expect_delete_attachment().never();
432        });
433
434        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
435        let (client, _repo) =
436            client_with_api_and_repo(api_client, MemoryRepository::<Cipher>::default());
437
438        let err = client
439            .create_attachment(cipher_id, admin_request())
440            .await
441            .unwrap_err();
442
443        assert!(matches!(err, CipherCreateAttachmentError::MissingField(_)));
444    }
445
446    #[tokio::test]
447    async fn rolls_back_orphaned_slot_when_response_has_no_cipher() {
448        let api_client = ApiClient::new_mocked(|mock| {
449            mock.ciphers_api
450                .expect_post_attachment()
451                .returning(|_id, _req| {
452                    Ok(AttachmentUploadDataResponseModel {
453                        attachment_id: Some(NEW_ATTACHMENT_ID.to_string()),
454                        url: Some("http://example.com/upload".to_string()),
455                        file_upload_type: Some(bitwarden_api_api::models::FileUploadType::Direct),
456                        cipher_response: None,
457                        cipher_mini_response: None,
458                        ..Default::default()
459                    })
460                });
461            mock.ciphers_api
462                .expect_delete_attachment()
463                .withf(|_id, attachment_id| attachment_id == NEW_ATTACHMENT_ID)
464                .times(1)
465                .returning(|_id, _att_id| {
466                    Ok(DeleteAttachmentResponseModel {
467                        object: None,
468                        cipher: None,
469                    })
470                });
471        });
472
473        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
474        let repository = MemoryRepository::<Cipher>::default();
475        repository.set(cipher_id, test_cipher()).await.unwrap();
476        let (client, _repo) = client_with_api_and_repo(api_client, repository);
477
478        let err = client
479            .create_attachment(cipher_id, test_request())
480            .await
481            .unwrap_err();
482
483        assert!(matches!(err, CipherCreateAttachmentError::MissingField(_)));
484    }
485
486    #[tokio::test]
487    async fn errors_without_rollback_when_post_v2_fails() {
488        let api_client = ApiClient::new_mocked(|mock| {
489            mock.ciphers_api
490                .expect_post_attachment()
491                .returning(|_id, _req| {
492                    Err(bitwarden_api_api::ApiError::Response(
493                        bitwarden_api_api::ResponseContent {
494                            status: reqwest::StatusCode::INTERNAL_SERVER_ERROR,
495                            message: "boom".to_string(),
496                        },
497                    ))
498                });
499            // No slot was opened, so no rollback should be attempted.
500            mock.ciphers_api.expect_delete_attachment().never();
501        });
502
503        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
504        let repository = MemoryRepository::<Cipher>::default();
505        repository.set(cipher_id, test_cipher()).await.unwrap();
506        let (client, _repo) = client_with_api_and_repo(api_client, repository);
507
508        let err = client
509            .create_attachment(cipher_id, test_request())
510            .await
511            .unwrap_err();
512
513        assert!(matches!(err, CipherCreateAttachmentError::Api(_)));
514    }
515
516    #[test]
517    fn file_upload_type_unknown_variant_returns_error() {
518        let result: Result<AttachmentFileUploadType, _> =
519            bitwarden_api_api::models::FileUploadType::__Unknown(42).try_into();
520        assert!(matches!(
521            result,
522            Err(CipherCreateAttachmentError::UnsupportedFileUploadType)
523        ));
524    }
525}