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