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            partial_data: None,
277            id: TEST_CIPHER_ID.parse().ok(),
278            name: Some(TEST_CIPHER_NAME.parse().unwrap()),
279            r#type: CipherType::Login,
280            attachments: None,
281            organization_id: None,
282            folder_id: None,
283            collection_ids: vec![],
284            key: None,
285            notes: None,
286            login: None,
287            identity: None,
288            card: None,
289            secure_note: None,
290            ssh_key: None,
291            bank_account: None,
292            drivers_license: None,
293            passport: None,
294            favorite: false,
295            reprompt: CipherRepromptType::None,
296            organization_use_totp: true,
297            edit: true,
298            permissions: None,
299            view_password: true,
300            local_data: None,
301            fields: None,
302            password_history: None,
303            creation_date: "2024-05-31T11:20:58.4566667Z".parse().unwrap(),
304            deleted_date: None,
305            revision_date: "2024-05-31T11:20:58.4566667Z".parse().unwrap(),
306            archived_date: None,
307            data: None,
308        }
309    }
310
311    fn server_cipher_response() -> CipherResponseModel {
312        CipherResponseModel {
313            id: Some(TEST_CIPHER_ID.try_into().unwrap()),
314            name: Some(TEST_CIPHER_NAME.to_string()),
315            r#type: Some(bitwarden_api_api::models::CipherType::Login),
316            creation_date: Some("2024-05-31T11:20:58.4566667Z".to_string()),
317            revision_date: Some("2024-05-31T11:20:58.4566667Z".to_string()),
318            ..Default::default()
319        }
320    }
321
322    #[tokio::test]
323    async fn returns_created_attachment_on_success() {
324        let api_client = ApiClient::new_mocked(|mock| {
325            mock.ciphers_api
326                .expect_post_attachment()
327                .returning(|_id, _req| {
328                    Ok(AttachmentUploadDataResponseModel {
329                        attachment_id: Some(NEW_ATTACHMENT_ID.to_string()),
330                        url: Some("http://example.com/upload".to_string()),
331                        file_upload_type: Some(bitwarden_api_api::models::FileUploadType::Direct),
332                        cipher_response: Some(Box::new(server_cipher_response())),
333                        cipher_mini_response: None,
334                        ..Default::default()
335                    })
336                });
337            mock.ciphers_api.expect_delete_attachment().never();
338        });
339
340        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
341        let repository = MemoryRepository::<Cipher>::default();
342        repository.set(cipher_id, test_cipher()).await.unwrap();
343        let (client, repo) = client_with_api_and_repo(api_client, repository);
344
345        let result = client
346            .create_attachment(cipher_id, test_request())
347            .await
348            .unwrap();
349
350        assert_eq!(result.attachment_id, NEW_ATTACHMENT_ID);
351        assert_eq!(result.upload_url, "http://example.com/upload");
352        assert_eq!(result.file_upload_type, AttachmentFileUploadType::Direct);
353        // Merged cipher is now returned inline for both paths (Comment 5).
354        assert_eq!(result.cipher.id, Some(cipher_id));
355
356        // Repository should have the merged cipher state from the response.
357        let stored = repo.get(cipher_id).await.unwrap().unwrap();
358        assert_eq!(stored.id, Some(cipher_id));
359    }
360
361    #[tokio::test]
362    async fn admin_returns_cipher_from_mini_response_and_skips_repository_write() {
363        let api_client = ApiClient::new_mocked(|mock| {
364            mock.ciphers_api
365                .expect_post_attachment()
366                .withf(|_id, req| req.as_ref().and_then(|r| r.admin_request).unwrap_or(false))
367                .returning(|_id, _req| {
368                    Ok(AttachmentUploadDataResponseModel {
369                        attachment_id: Some(NEW_ATTACHMENT_ID.to_string()),
370                        url: Some("http://example.com/upload".to_string()),
371                        file_upload_type: Some(bitwarden_api_api::models::FileUploadType::Direct),
372                        cipher_response: None,
373                        cipher_mini_response: Some(Box::new(CipherMiniResponseModel {
374                            id: Some(TEST_CIPHER_ID.try_into().unwrap()),
375                            name: Some(TEST_CIPHER_NAME.to_string()),
376                            r#type: Some(bitwarden_api_api::models::CipherType::Login),
377                            creation_date: Some("2024-05-31T11:20:58.4566667Z".to_string()),
378                            revision_date: Some("2024-05-31T11:20:58.4566667Z".to_string()),
379                            attachments: None,
380                            ..Default::default()
381                        })),
382                        ..Default::default()
383                    })
384                });
385            mock.ciphers_api.expect_delete_attachment().never();
386            mock.ciphers_api.expect_delete_attachment_admin().never();
387        });
388
389        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
390        let (client, repo) =
391            client_with_api_and_repo(api_client, MemoryRepository::<Cipher>::default());
392
393        let result = client
394            .create_attachment(cipher_id, admin_request())
395            .await
396            .unwrap();
397
398        assert_eq!(result.attachment_id, NEW_ATTACHMENT_ID);
399        assert_eq!(result.upload_url, "http://example.com/upload");
400        assert_eq!(result.cipher.id, Some(cipher_id));
401
402        // Admin path must not write to the local repository.
403        assert!(repo.get(cipher_id).await.unwrap().is_none());
404    }
405
406    #[tokio::test]
407    async fn admin_rolls_back_via_admin_delete_when_finalize_fails() {
408        let api_client = ApiClient::new_mocked(|mock| {
409            mock.ciphers_api
410                .expect_post_attachment()
411                .returning(|_id, _req| {
412                    Ok(AttachmentUploadDataResponseModel {
413                        attachment_id: Some(NEW_ATTACHMENT_ID.to_string()),
414                        url: Some("http://example.com/upload".to_string()),
415                        file_upload_type: Some(bitwarden_api_api::models::FileUploadType::Direct),
416                        cipher_response: None,
417                        cipher_mini_response: None,
418                        ..Default::default()
419                    })
420                });
421            // Admin rollback uses the admin DELETE endpoint, not the user one.
422            mock.ciphers_api
423                .expect_delete_attachment_admin()
424                .withf(|_id, attachment_id| attachment_id == NEW_ATTACHMENT_ID)
425                .times(1)
426                .returning(|_id, _att_id| {
427                    Ok(DeleteAttachmentResponseModel {
428                        object: None,
429                        cipher: None,
430                    })
431                });
432            mock.ciphers_api.expect_delete_attachment().never();
433        });
434
435        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
436        let (client, _repo) =
437            client_with_api_and_repo(api_client, MemoryRepository::<Cipher>::default());
438
439        let err = client
440            .create_attachment(cipher_id, admin_request())
441            .await
442            .unwrap_err();
443
444        assert!(matches!(err, CipherCreateAttachmentError::MissingField(_)));
445    }
446
447    #[tokio::test]
448    async fn rolls_back_orphaned_slot_when_response_has_no_cipher() {
449        let api_client = ApiClient::new_mocked(|mock| {
450            mock.ciphers_api
451                .expect_post_attachment()
452                .returning(|_id, _req| {
453                    Ok(AttachmentUploadDataResponseModel {
454                        attachment_id: Some(NEW_ATTACHMENT_ID.to_string()),
455                        url: Some("http://example.com/upload".to_string()),
456                        file_upload_type: Some(bitwarden_api_api::models::FileUploadType::Direct),
457                        cipher_response: None,
458                        cipher_mini_response: None,
459                        ..Default::default()
460                    })
461                });
462            mock.ciphers_api
463                .expect_delete_attachment()
464                .withf(|_id, attachment_id| attachment_id == NEW_ATTACHMENT_ID)
465                .times(1)
466                .returning(|_id, _att_id| {
467                    Ok(DeleteAttachmentResponseModel {
468                        object: None,
469                        cipher: None,
470                    })
471                });
472        });
473
474        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
475        let repository = MemoryRepository::<Cipher>::default();
476        repository.set(cipher_id, test_cipher()).await.unwrap();
477        let (client, _repo) = client_with_api_and_repo(api_client, repository);
478
479        let err = client
480            .create_attachment(cipher_id, test_request())
481            .await
482            .unwrap_err();
483
484        assert!(matches!(err, CipherCreateAttachmentError::MissingField(_)));
485    }
486
487    #[tokio::test]
488    async fn errors_without_rollback_when_post_v2_fails() {
489        let api_client = ApiClient::new_mocked(|mock| {
490            mock.ciphers_api
491                .expect_post_attachment()
492                .returning(|_id, _req| {
493                    Err(bitwarden_api_api::ApiError::Response(
494                        bitwarden_api_api::ResponseContent {
495                            status: reqwest::StatusCode::INTERNAL_SERVER_ERROR,
496                            message: "boom".to_string(),
497                        },
498                    ))
499                });
500            // No slot was opened, so no rollback should be attempted.
501            mock.ciphers_api.expect_delete_attachment().never();
502        });
503
504        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
505        let repository = MemoryRepository::<Cipher>::default();
506        repository.set(cipher_id, test_cipher()).await.unwrap();
507        let (client, _repo) = client_with_api_and_repo(api_client, repository);
508
509        let err = client
510            .create_attachment(cipher_id, test_request())
511            .await
512            .unwrap_err();
513
514        assert!(matches!(err, CipherCreateAttachmentError::Api(_)));
515    }
516
517    #[test]
518    fn file_upload_type_unknown_variant_returns_error() {
519        let result: Result<AttachmentFileUploadType, _> =
520            bitwarden_api_api::models::FileUploadType::__Unknown(42).try_into();
521        assert!(matches!(
522            result,
523            Err(CipherCreateAttachmentError::UnsupportedFileUploadType)
524        ));
525    }
526}