Skip to main content

bitwarden_vault/cipher/attachment_client/
create.rs

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