Skip to main content

bitwarden_vault/cipher/attachment_client/
upgrade.rs

1use std::io;
2
3use bitwarden_api_base::AuthRequired;
4use bitwarden_core::{ApiError, MissingFieldError, key_management::SymmetricKeySlotId};
5use bitwarden_crypto::{
6    CryptoError, Decryptable, IdentifyKey, StreamingAttachmentDecryptor,
7    StreamingAttachmentEncryptor, SymmetricCryptoKey,
8};
9use bitwarden_error::bitwarden_error;
10use bitwarden_state::repository::{RepositoryError, RepositoryOption};
11use futures::TryStreamExt;
12use thiserror::Error;
13use tokio::io::AsyncWriteExt;
14use tokio_util::io::StreamReader;
15#[cfg(feature = "wasm")]
16use wasm_bindgen::prelude::*;
17
18use super::{
19    create::{
20        AttachmentFileUploadType, CipherCreateAttachmentError, CreateAttachmentRequest,
21        CreatedAttachment,
22    },
23    delete::CipherDeleteAttachmentError,
24    download_url::CipherGetAttachmentDownloadUrlError,
25};
26use crate::{
27    AttachmentsClient, Cipher, CipherError, CipherId, CipherView, DecryptError, EncryptError,
28    VaultParseError, cipher::attachment::AttachmentEncryptionVersion,
29};
30
31#[allow(missing_docs)]
32#[bitwarden_error(flat)]
33#[derive(Debug, Error)]
34pub enum CipherUpgradeAttachmentError {
35    #[error(transparent)]
36    Api(#[from] ApiError),
37    #[error(transparent)]
38    Repository(#[from] RepositoryError),
39    #[error(transparent)]
40    MissingField(#[from] MissingFieldError),
41    #[error(transparent)]
42    VaultParse(#[from] VaultParseError),
43    #[error(transparent)]
44    Decrypt(#[from] DecryptError),
45    #[error(transparent)]
46    Encrypt(#[from] EncryptError),
47    #[error(transparent)]
48    Cipher(#[from] CipherError),
49    #[error(transparent)]
50    GetDownloadUrl(#[from] CipherGetAttachmentDownloadUrlError),
51    #[error(transparent)]
52    CreateAttachment(#[from] CipherCreateAttachmentError),
53    #[error(transparent)]
54    DeleteAttachment(#[from] CipherDeleteAttachmentError),
55    #[error(transparent)]
56    Crypto(#[from] CryptoError),
57    #[error(transparent)]
58    Io(#[from] io::Error),
59    #[error("Cipher or attachment not found")]
60    NotFound,
61    #[error("Attachment already has a key (no upgrade needed)")]
62    AlreadyUpgraded,
63    #[error("Failed to download the legacy attachment")]
64    Download,
65    #[error("Failed to upload the re-encrypted attachment")]
66    Upload,
67}
68
69#[cfg_attr(feature = "wasm", wasm_bindgen)]
70impl AttachmentsClient {
71    /// Upgrades a legacy v1 attachment to `CipherKey(AttachmentKey(Contents))`.
72    ///
73    /// Downloads and re-encrypts the attachment, creates a new slot, uploads the
74    /// new bytes, then deletes the old attachment. If the upload fails, it tries
75    /// to delete the new slot before returning the error. Returns the decrypted
76    /// cipher view.
77    pub async fn upgrade_attachment(
78        &self,
79        cipher_id: CipherId,
80        attachment_id: String,
81    ) -> Result<CipherView, CipherUpgradeAttachmentError> {
82        let repository = self.repository.require()?;
83        let cipher = repository
84            .get(cipher_id)
85            .await?
86            .ok_or(CipherUpgradeAttachmentError::NotFound)?;
87
88        let attachment = cipher
89            .attachments
90            .as_ref()
91            .and_then(|atts| {
92                atts.iter()
93                    .find(|a| a.id.as_deref() == Some(&attachment_id))
94            })
95            .ok_or(CipherUpgradeAttachmentError::NotFound)?;
96
97        if matches!(
98            attachment.encryption_version(),
99            AttachmentEncryptionVersion::AttachmentKeyV2
100        ) {
101            return Err(CipherUpgradeAttachmentError::AlreadyUpgraded);
102        }
103
104        // Used only to pre-size the encryptor buffer. The legacy encrypted size is
105        // a safe upper bound here.
106        let plaintext_size_hint: u64 = attachment
107            .size
108            .as_ref()
109            .and_then(|s| s.parse().ok())
110            .ok_or(MissingFieldError("attachment.size"))?;
111
112        let file_name_plain = {
113            let mut ctx = self.key_store.context();
114            let cipher_key =
115                Cipher::decrypt_cipher_key(&mut ctx, cipher.key_identifier(), &cipher.key)?;
116            attachment
117                .decrypt(&mut ctx, cipher_key)
118                .map_err(DecryptError::from)?
119                .file_name
120                .ok_or(MissingFieldError("file_name"))?
121        };
122
123        let download_url = self
124            .get_attachment_download_url(cipher_id, attachment_id.clone(), None)
125            .await?;
126
127        let material = {
128            let mut ctx = self.key_store.context();
129            cipher.make_attachment_material(&mut ctx, &file_name_plain)?
130        };
131
132        // Re-encrypt first so we can size the new slot from the actual output.
133        // This also avoids creating a new slot if download or decrypt fails.
134        let reencrypted = self
135            .download_and_reencrypt(
136                cipher.key_identifier(),
137                material.key,
138                plaintext_size_hint,
139                &download_url,
140            )
141            .await?;
142
143        let request = CreateAttachmentRequest {
144            key: material.wrapped_key,
145            file_name: material.encrypted_file_name,
146            file_size: reencrypted.len() as u64,
147            last_known_revision_date: cipher.revision_date,
148            as_admin: false,
149        };
150        let created = self.create_attachment(cipher_id, request).await?;
151
152        if let Err(e) = self
153            .upload_reencrypted(cipher_id, &created, reencrypted)
154            .await
155        {
156            // Upload failed after we created the new slot, so try to clean it up.
157            if let Err(rollback_err) = self
158                .delete_attachment(cipher_id, created.attachment_id.clone())
159                .await
160            {
161                tracing::warn!(
162                    "failed to roll back orphaned attachment slot {} on cipher {cipher_id}: {rollback_err:?}",
163                    created.attachment_id,
164                );
165            }
166            return Err(e);
167        }
168
169        let upgraded_cipher = self.delete_attachment(cipher_id, attachment_id).await?;
170
171        Ok(self
172            .key_store
173            .decrypt(&upgraded_cipher)
174            .map_err(DecryptError::from)?)
175    }
176}
177
178impl AttachmentsClient {
179    /// Downloads the legacy ciphertext and re-encrypts it into memory.
180    ///
181    /// Wasm `reqwest` only supports buffered request bodies so the output is buffered.
182    async fn download_and_reencrypt(
183        &self,
184        legacy_key_slot: SymmetricKeySlotId,
185        new_attachment_key: SymmetricCryptoKey,
186        plaintext_size_hint: u64,
187        download_url: &str,
188    ) -> Result<Vec<u8>, CipherUpgradeAttachmentError> {
189        let response = self
190            .http_client
191            .get(download_url)
192            .send()
193            .await
194            .map_err(|_| CipherUpgradeAttachmentError::Download)?;
195        if !response.status().is_success() {
196            return Err(CipherUpgradeAttachmentError::Download);
197        }
198
199        let download_reader = StreamReader::new(response.bytes_stream().map_err(io::Error::other));
200
201        // Scope `KeyStoreContext` to construction so it is dropped before any `.await`.
202        let mut decryptor = {
203            let ctx = self.key_store.context();
204            StreamingAttachmentDecryptor::new(legacy_key_slot, ctx, download_reader)?
205        };
206
207        // Scope the encryptor so the borrow of `reencrypted` ends before return.
208        let mut reencrypted = Vec::<u8>::with_capacity(plaintext_size_hint as usize + 64);
209        {
210            let mut encryptor = {
211                let mut ctx = self.key_store.context();
212                let slot = ctx.add_local_symmetric_key(new_attachment_key);
213                // This only pre-sizes the buffer. The legacy encrypted size is a safe
214                // over-estimate.
215                StreamingAttachmentEncryptor::new(
216                    slot,
217                    ctx,
218                    &mut reencrypted,
219                    plaintext_size_hint as usize,
220                )?
221            };
222            tokio::io::copy(&mut decryptor, &mut encryptor).await?;
223            encryptor.shutdown().await?;
224        }
225
226        Ok(reencrypted)
227    }
228
229    /// Uploads the re-encrypted bytes to the newly created attachment slot.
230    ///
231    /// Transport depends on [`AttachmentFileUploadType`]: `Azure` PUTs to the presigned blob URL
232    /// on the unauthenticated client (the SAS token in the URL authorizes it; a Bearer token must
233    /// not be attached), while `Direct` POSTs to the authenticated Bitwarden API endpoint
234    /// (`POST /ciphers/{id}/attachment/{attachmentId}`) using the configured API client.
235    async fn upload_reencrypted(
236        &self,
237        cipher_id: CipherId,
238        created: &CreatedAttachment,
239        reencrypted: Vec<u8>,
240    ) -> Result<(), CipherUpgradeAttachmentError> {
241        match created.file_upload_type {
242            AttachmentFileUploadType::Azure => {
243                let response = self
244                    .http_client
245                    .put(&created.upload_url)
246                    .header("x-ms-blob-type", "BlockBlob")
247                    .body(reencrypted)
248                    .send()
249                    .await
250                    .map_err(|_| CipherUpgradeAttachmentError::Upload)?;
251                if !response.status().is_success() {
252                    return Err(CipherUpgradeAttachmentError::Upload);
253                }
254            }
255            AttachmentFileUploadType::Direct => {
256                let url = format!(
257                    "{}/ciphers/{}/attachment/{}",
258                    self.api_configurations.api_config.base_path,
259                    bitwarden_api_base::urlencode(cipher_id.to_string()),
260                    bitwarden_api_base::urlencode(&created.attachment_id),
261                );
262                let part = reqwest::multipart::Part::bytes(reencrypted).file_name("data");
263                let form = reqwest::multipart::Form::new().part("data", part);
264                let request = self
265                    .api_configurations
266                    .api_config
267                    .client
268                    .post(url)
269                    .with_extension(AuthRequired::Bearer)
270                    .multipart(form);
271                bitwarden_api_base::process_with_empty_response(request)
272                    .await
273                    .map_err(|_: bitwarden_api_api::ApiError| {
274                        CipherUpgradeAttachmentError::Upload
275                    })?;
276            }
277        }
278
279        Ok(())
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use std::sync::Arc;
286
287    use bitwarden_api_api::{
288        apis::ApiClient,
289        models::{
290            AttachmentResponseModel, AttachmentUploadDataResponseModel, CipherMiniResponseModel,
291            CipherResponseModel, DeleteAttachmentResponseModel,
292        },
293    };
294    use bitwarden_core::{
295        client::ApiConfigurations,
296        key_management::{KeySlotIds, create_test_crypto_with_user_key},
297    };
298    use bitwarden_crypto::{EncString, KeyStore, PrimitiveEncryptable, SymmetricKeyAlgorithm};
299    use bitwarden_state::repository::Repository;
300    use bitwarden_test::MemoryRepository;
301
302    use super::*;
303    use crate::{Attachment, CipherRepromptType, CipherType};
304
305    const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097";
306    const OLD_ATTACHMENT_ID: &str = "uf7bkexzag04d3cw04jsbqqkbpbwhxs0";
307    const NEW_ATTACHMENT_ID: &str = "newatt9999999999999999999999999";
308    const TEST_CIPHER_NAME: &str = "2.pMS6/icTQABtulw52pq2lg==|XXbxKxDTh+mWiN1HjH2N1w==|Q6PkuT+KX/axrgN9ubD5Ajk2YNwxQkgs3WJM0S0wtG8=";
309    // Pre-encrypted file name used in tests that do not decrypt the cipher.
310    const TEST_FILE_NAME: &str = "2.mV50WiLq6duhwGbhM1TO0A==|dTufWNH8YTPP0EMlNLIpFA==|QHp+7OM8xHtEmCfc9QPXJ0Ro2BeakzvLgxJZ7NdLuDc=";
311    const TEST_KEY: &str = "2.6TPEiYULFg/4+3CpDRwCqw==|6swweBHCJcd5CHdwBBWuRN33XRV22VoroDFDUmiM4OzjPEAhgZK57IZS1KkBlCcFvT+t+YbsmDcdv+Lqr+iJ3MmzfJ40MCB5TfYy+22HVRA=|rkgFDh2IWTfPC1Y66h68Diiab/deyi1p/X0Fwkva0NQ=";
312
313    fn client(
314        api_client: ApiClient,
315        repository: MemoryRepository<Cipher>,
316        key_store: KeyStore<KeySlotIds>,
317        api_base_url: &str,
318    ) -> AttachmentsClient {
319        // `Direct` uploads go through the authenticated API client at `api_config.base_path`
320        let mut api_configurations = ApiConfigurations::from_api_client(api_client);
321        api_configurations.api_config.base_path = api_base_url.to_string();
322        AttachmentsClient {
323            key_store,
324            api_configurations: Arc::new(api_configurations),
325            repository: Some(Arc::new(repository)),
326            http_client: reqwest::Client::new(),
327        }
328    }
329
330    fn cipher_with(name: EncString, attachments: Option<Vec<Attachment>>) -> Cipher {
331        Cipher {
332            id: TEST_CIPHER_ID.parse().ok(),
333            name: Some(name),
334            r#type: CipherType::Login,
335            attachments,
336            organization_id: None,
337            folder_id: None,
338            collection_ids: vec![],
339            key: None,
340            notes: None,
341            login: None,
342            identity: None,
343            card: None,
344            secure_note: None,
345            ssh_key: None,
346            bank_account: None,
347            drivers_license: None,
348            passport: None,
349            favorite: false,
350            reprompt: CipherRepromptType::None,
351            organization_use_totp: true,
352            edit: true,
353            permissions: None,
354            view_password: true,
355            local_data: None,
356            fields: None,
357            password_history: None,
358            creation_date: "2024-05-31T11:20:58.4566667Z".parse().unwrap(),
359            deleted_date: None,
360            revision_date: "2024-05-31T11:20:58.4566667Z".parse().unwrap(),
361            archived_date: None,
362            data: None,
363        }
364    }
365
366    fn attachment_model(id: &str) -> AttachmentResponseModel {
367        AttachmentResponseModel {
368            id: Some(id.to_string()),
369            ..Default::default()
370        }
371    }
372
373    fn server_cipher_response() -> CipherResponseModel {
374        CipherResponseModel {
375            id: Some(TEST_CIPHER_ID.try_into().unwrap()),
376            name: Some(TEST_CIPHER_NAME.to_string()),
377            r#type: Some(bitwarden_api_api::models::CipherType::Login),
378            creation_date: Some("2024-05-31T11:20:58.4566667Z".to_string()),
379            revision_date: Some("2024-05-31T11:20:58.4566667Z".to_string()),
380            attachments: Some(vec![
381                attachment_model(OLD_ATTACHMENT_ID),
382                attachment_model(NEW_ATTACHMENT_ID),
383            ]),
384            ..Default::default()
385        }
386    }
387
388    // `upgrade_attachment` returns the decrypted delete result, so the delete response's name
389    // must decrypt under the test user key. Callers pass a name produced by `encrypted_name`.
390    fn server_cipher_mini_response(name: String) -> CipherMiniResponseModel {
391        CipherMiniResponseModel {
392            id: Some(TEST_CIPHER_ID.try_into().unwrap()),
393            name: Some(name),
394            r#type: Some(bitwarden_api_api::models::CipherType::Login),
395            creation_date: Some("2024-05-31T11:20:58.4566667Z".to_string()),
396            revision_date: Some("2024-05-31T11:20:58.4566667Z".to_string()),
397            attachments: Some(vec![attachment_model(NEW_ATTACHMENT_ID)]),
398            ..Default::default()
399        }
400    }
401
402    fn encrypted_name(key_store: &KeyStore<KeySlotIds>) -> String {
403        "Upgraded cipher"
404            .encrypt(&mut key_store.context(), SymmetricKeySlotId::User)
405            .expect("encrypt name")
406            .to_string()
407    }
408
409    /// Builds legacy attachment bytes: `[0x02][IV][HMAC][ciphertext]`,
410    /// encrypted under the user key.
411    async fn make_legacy_wire(key_store: &KeyStore<KeySlotIds>, plaintext: &[u8]) -> Vec<u8> {
412        let mut wire = Vec::new();
413        {
414            let ctx = key_store.context();
415            let mut enc = StreamingAttachmentEncryptor::new(
416                SymmetricKeySlotId::User,
417                ctx,
418                &mut wire,
419                plaintext.len(),
420            )
421            .expect("encryptor construction");
422            enc.write_all(plaintext).await.expect("write_all");
423            enc.shutdown().await.expect("shutdown");
424        }
425        wire
426    }
427
428    /// Builds a legacy cipher with one keyless attachment so the cipher still decrypts.
429    fn legacy_cipher(key_store: &KeyStore<KeySlotIds>, encrypted_size: usize) -> Cipher {
430        let mut ctx = key_store.context();
431        let name = "Upgrade test cipher"
432            .encrypt(&mut ctx, SymmetricKeySlotId::User)
433            .expect("encrypt name");
434        let file_name = "hello.txt"
435            .encrypt(&mut ctx, SymmetricKeySlotId::User)
436            .expect("encrypt file name");
437        drop(ctx);
438
439        cipher_with(
440            name,
441            Some(vec![Attachment {
442                id: Some(OLD_ATTACHMENT_ID.to_string()),
443                url: None,
444                file_name: Some(file_name),
445                key: None,
446                size: Some(encrypted_size.to_string()),
447                size_name: Some(format!("{encrypted_size} Bytes")),
448            }]),
449        )
450    }
451
452    #[tokio::test]
453    async fn returns_not_found_when_cipher_missing() {
454        let api_client = ApiClient::new_mocked(|_mock| {});
455        let key_store = create_test_crypto_with_user_key(SymmetricCryptoKey::make(
456            SymmetricKeyAlgorithm::Aes256CbcHmac,
457        ));
458        let client = client(
459            api_client,
460            MemoryRepository::<Cipher>::default(),
461            key_store,
462            "",
463        );
464        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
465
466        let err = client
467            .upgrade_attachment(cipher_id, OLD_ATTACHMENT_ID.to_string())
468            .await
469            .unwrap_err();
470
471        assert!(matches!(err, CipherUpgradeAttachmentError::NotFound));
472    }
473
474    #[tokio::test]
475    async fn returns_not_found_when_attachment_missing() {
476        let api_client = ApiClient::new_mocked(|_mock| {});
477        let key_store = create_test_crypto_with_user_key(SymmetricCryptoKey::make(
478            SymmetricKeyAlgorithm::Aes256CbcHmac,
479        ));
480
481        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
482        let repository = MemoryRepository::<Cipher>::default();
483        repository
484            .set(
485                cipher_id,
486                cipher_with(TEST_CIPHER_NAME.parse().unwrap(), None),
487            )
488            .await
489            .unwrap();
490
491        let client = client(api_client, repository, key_store, "");
492
493        let err = client
494            .upgrade_attachment(cipher_id, OLD_ATTACHMENT_ID.to_string())
495            .await
496            .unwrap_err();
497
498        assert!(matches!(err, CipherUpgradeAttachmentError::NotFound));
499    }
500
501    #[tokio::test]
502    async fn returns_already_upgraded_when_attachment_has_key() {
503        let api_client = ApiClient::new_mocked(|_mock| {});
504        let key_store = create_test_crypto_with_user_key(SymmetricCryptoKey::make(
505            SymmetricKeyAlgorithm::Aes256CbcHmac,
506        ));
507
508        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
509        let repository = MemoryRepository::<Cipher>::default();
510        repository
511            .set(
512                cipher_id,
513                cipher_with(
514                    TEST_CIPHER_NAME.parse().unwrap(),
515                    Some(vec![Attachment {
516                        id: Some(OLD_ATTACHMENT_ID.to_string()),
517                        url: None,
518                        file_name: Some(TEST_FILE_NAME.parse().unwrap()),
519                        // Already-modern attachment: it carries its own wrapped key.
520                        key: Some(TEST_KEY.parse().unwrap()),
521                        size: Some("65".to_string()),
522                        size_name: Some("65 Bytes".to_string()),
523                    }]),
524                ),
525            )
526            .await
527            .unwrap();
528
529        let client = client(api_client, repository, key_store, "");
530
531        let err = client
532            .upgrade_attachment(cipher_id, OLD_ATTACHMENT_ID.to_string())
533            .await
534            .unwrap_err();
535
536        assert!(matches!(err, CipherUpgradeAttachmentError::AlreadyUpgraded));
537    }
538
539    #[tokio::test]
540    async fn upgrades_legacy_attachment_via_direct_upload() {
541        use wiremock::{
542            Mock, MockServer, ResponseTemplate,
543            matchers::{method, path},
544        };
545
546        let key_store = create_test_crypto_with_user_key(SymmetricCryptoKey::make(
547            SymmetricKeyAlgorithm::Aes256CbcHmac,
548        ));
549        let wire = make_legacy_wire(&key_store, b"Hello, attachment upgrade world!").await;
550        let encrypted_size = wire.len();
551        let mini_name = encrypted_name(&key_store);
552
553        // Direct uploads go to the authenticated API endpoint, not the returned `url`.
554        let upload_path = format!("/ciphers/{TEST_CIPHER_ID}/attachment/{NEW_ATTACHMENT_ID}");
555
556        let server = MockServer::start().await;
557        Mock::given(method("GET"))
558            .and(path("/download/old"))
559            .respond_with(ResponseTemplate::new(200).set_body_bytes(wire.clone()))
560            .mount(&server)
561            .await;
562        Mock::given(method("POST"))
563            .and(path(upload_path.clone()))
564            .respond_with(ResponseTemplate::new(201))
565            .mount(&server)
566            .await;
567
568        let download_url = format!("{}/download/old", server.uri());
569
570        let api_client = ApiClient::new_mocked(move |mock| {
571            let download_url = download_url.clone();
572            mock.ciphers_api
573                .expect_get_attachment_data()
574                .returning(move |_id, _att| {
575                    Ok(AttachmentResponseModel {
576                        id: Some(OLD_ATTACHMENT_ID.to_string()),
577                        url: Some(download_url.clone()),
578                        ..Default::default()
579                    })
580                });
581            mock.ciphers_api
582                .expect_post_attachment()
583                .returning(move |_id, _req| {
584                    Ok(AttachmentUploadDataResponseModel {
585                        attachment_id: Some(NEW_ATTACHMENT_ID.to_string()),
586                        // `url` is ignored for Direct uploads.
587                        url: Some("https://unused.example/direct".to_string()),
588                        file_upload_type: Some(bitwarden_api_api::models::FileUploadType::Direct),
589                        cipher_response: Some(Box::new(server_cipher_response())),
590                        cipher_mini_response: None,
591                        ..Default::default()
592                    })
593                });
594            // On success the *legacy* attachment is deleted; the new slot is kept.
595            mock.ciphers_api
596                .expect_delete_attachment()
597                .withf(|_id, att_id| att_id == OLD_ATTACHMENT_ID)
598                .times(1)
599                .returning({
600                    let mini_name = mini_name.clone();
601                    move |_id, _att| {
602                        Ok(DeleteAttachmentResponseModel {
603                            object: None,
604                            cipher: Some(Box::new(server_cipher_mini_response(mini_name.clone()))),
605                        })
606                    }
607                });
608        });
609
610        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
611        let repository = MemoryRepository::<Cipher>::default();
612        repository
613            .set(cipher_id, legacy_cipher(&key_store, encrypted_size))
614            .await
615            .unwrap();
616
617        let client = client(api_client, repository, key_store, &server.uri());
618
619        let cipher = client
620            .upgrade_attachment(cipher_id, OLD_ATTACHMENT_ID.to_string())
621            .await
622            .unwrap();
623        assert_eq!(cipher.id, Some(cipher_id));
624
625        // The returned cipher must reflect the post-delete state, not the slot-creation snapshot:
626        // the legacy attachment is gone and the new one remains.
627        let returned_ids: Vec<String> = cipher
628            .attachments
629            .unwrap_or_default()
630            .into_iter()
631            .filter_map(|a| a.id)
632            .collect();
633        assert!(
634            !returned_ids.contains(&OLD_ATTACHMENT_ID.to_string()),
635            "returned cipher must not list the deleted legacy attachment, got {returned_ids:?}"
636        );
637        assert!(
638            returned_ids.contains(&NEW_ATTACHMENT_ID.to_string()),
639            "returned cipher should list the upgraded attachment, got {returned_ids:?}"
640        );
641
642        let requests = server.received_requests().await.unwrap();
643        assert_eq!(
644            requests
645                .iter()
646                .filter(|r| r.url.path() == "/download/old")
647                .count(),
648            1,
649            "legacy ciphertext should be downloaded exactly once"
650        );
651        assert_eq!(
652            requests
653                .iter()
654                .filter(|r| r.url.path() == upload_path.as_str())
655                .count(),
656            1,
657            "Direct upload should hit the authenticated attachment endpoint exactly once"
658        );
659    }
660
661    #[tokio::test]
662    async fn upgrades_legacy_attachment_via_azure_upload() {
663        use wiremock::{
664            Mock, MockServer, ResponseTemplate,
665            matchers::{header, method, path},
666        };
667
668        let key_store = create_test_crypto_with_user_key(SymmetricCryptoKey::make(
669            SymmetricKeyAlgorithm::Aes256CbcHmac,
670        ));
671        let wire = make_legacy_wire(&key_store, b"azure upload path plaintext").await;
672        let encrypted_size = wire.len();
673        let mini_name = encrypted_name(&key_store);
674
675        let server = MockServer::start().await;
676        Mock::given(method("GET"))
677            .and(path("/download/old"))
678            .respond_with(ResponseTemplate::new(200).set_body_bytes(wire.clone()))
679            .mount(&server)
680            .await;
681        // Azure uploads PUT directly to the presigned blob URL with the BlockBlob header.
682        Mock::given(method("PUT"))
683            .and(path("/upload/blob"))
684            .and(header("x-ms-blob-type", "BlockBlob"))
685            .respond_with(ResponseTemplate::new(201))
686            .mount(&server)
687            .await;
688
689        let download_url = format!("{}/download/old", server.uri());
690        let upload_url = format!("{}/upload/blob", server.uri());
691
692        let api_client = ApiClient::new_mocked(move |mock| {
693            let download_url = download_url.clone();
694            mock.ciphers_api
695                .expect_get_attachment_data()
696                .returning(move |_id, _att| {
697                    Ok(AttachmentResponseModel {
698                        id: Some(OLD_ATTACHMENT_ID.to_string()),
699                        url: Some(download_url.clone()),
700                        ..Default::default()
701                    })
702                });
703            let upload_url = upload_url.clone();
704            mock.ciphers_api
705                .expect_post_attachment()
706                .returning(move |_id, _req| {
707                    Ok(AttachmentUploadDataResponseModel {
708                        attachment_id: Some(NEW_ATTACHMENT_ID.to_string()),
709                        url: Some(upload_url.clone()),
710                        file_upload_type: Some(bitwarden_api_api::models::FileUploadType::Azure),
711                        cipher_response: Some(Box::new(server_cipher_response())),
712                        cipher_mini_response: None,
713                        ..Default::default()
714                    })
715                });
716            mock.ciphers_api
717                .expect_delete_attachment()
718                .withf(|_id, att_id| att_id == OLD_ATTACHMENT_ID)
719                .times(1)
720                .returning({
721                    let mini_name = mini_name.clone();
722                    move |_id, _att| {
723                        Ok(DeleteAttachmentResponseModel {
724                            object: None,
725                            cipher: Some(Box::new(server_cipher_mini_response(mini_name.clone()))),
726                        })
727                    }
728                });
729        });
730
731        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
732        let repository = MemoryRepository::<Cipher>::default();
733        repository
734            .set(cipher_id, legacy_cipher(&key_store, encrypted_size))
735            .await
736            .unwrap();
737
738        // Azure uses the presigned URL on the unauthenticated client, so `base_path` is unused.
739        let client = client(api_client, repository, key_store, "");
740
741        let cipher = client
742            .upgrade_attachment(cipher_id, OLD_ATTACHMENT_ID.to_string())
743            .await
744            .unwrap();
745        assert_eq!(cipher.id, Some(cipher_id));
746
747        let requests = server.received_requests().await.unwrap();
748        assert_eq!(
749            requests
750                .iter()
751                .filter(|r| r.url.path() == "/upload/blob")
752                .count(),
753            1,
754            "Azure upload should PUT to the presigned blob URL exactly once"
755        );
756    }
757
758    #[tokio::test]
759    async fn rolls_back_new_slot_when_upload_fails() {
760        use wiremock::{
761            Mock, MockServer, ResponseTemplate,
762            matchers::{method, path},
763        };
764
765        let key_store = create_test_crypto_with_user_key(SymmetricCryptoKey::make(
766            SymmetricKeyAlgorithm::Aes256CbcHmac,
767        ));
768        let wire = make_legacy_wire(&key_store, b"rollback path plaintext").await;
769        let encrypted_size = wire.len();
770        let mini_name = encrypted_name(&key_store);
771
772        let upload_path = format!("/ciphers/{TEST_CIPHER_ID}/attachment/{NEW_ATTACHMENT_ID}");
773
774        let server = MockServer::start().await;
775        Mock::given(method("GET"))
776            .and(path("/download/old"))
777            .respond_with(ResponseTemplate::new(200).set_body_bytes(wire.clone()))
778            .mount(&server)
779            .await;
780        // Upload fails — the orphaned new slot must be rolled back.
781        Mock::given(method("POST"))
782            .and(path(upload_path))
783            .respond_with(ResponseTemplate::new(500))
784            .mount(&server)
785            .await;
786
787        let download_url = format!("{}/download/old", server.uri());
788
789        let api_client = ApiClient::new_mocked(move |mock| {
790            let download_url = download_url.clone();
791            mock.ciphers_api
792                .expect_get_attachment_data()
793                .returning(move |_id, _att| {
794                    Ok(AttachmentResponseModel {
795                        id: Some(OLD_ATTACHMENT_ID.to_string()),
796                        url: Some(download_url.clone()),
797                        ..Default::default()
798                    })
799                });
800            mock.ciphers_api
801                .expect_post_attachment()
802                .returning(move |_id, _req| {
803                    Ok(AttachmentUploadDataResponseModel {
804                        attachment_id: Some(NEW_ATTACHMENT_ID.to_string()),
805                        url: Some("https://unused.example/direct".to_string()),
806                        file_upload_type: Some(bitwarden_api_api::models::FileUploadType::Direct),
807                        cipher_response: Some(Box::new(server_cipher_response())),
808                        cipher_mini_response: None,
809                        ..Default::default()
810                    })
811                });
812            // Rollback must delete the *new* slot, never the legacy one.
813            mock.ciphers_api
814                .expect_delete_attachment()
815                .withf(|_id, att_id| att_id == NEW_ATTACHMENT_ID)
816                .times(1)
817                .returning({
818                    let mini_name = mini_name.clone();
819                    move |_id, _att| {
820                        Ok(DeleteAttachmentResponseModel {
821                            object: None,
822                            cipher: Some(Box::new(server_cipher_mini_response(mini_name.clone()))),
823                        })
824                    }
825                });
826        });
827
828        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
829        let repository = MemoryRepository::<Cipher>::default();
830        repository
831            .set(cipher_id, legacy_cipher(&key_store, encrypted_size))
832            .await
833            .unwrap();
834
835        let client = client(api_client, repository, key_store, &server.uri());
836
837        let err = client
838            .upgrade_attachment(cipher_id, OLD_ATTACHMENT_ID.to_string())
839            .await
840            .unwrap_err();
841
842        assert!(matches!(err, CipherUpgradeAttachmentError::Upload));
843    }
844}