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            partial_data: None,
333            id: TEST_CIPHER_ID.parse().ok(),
334            name: Some(name),
335            r#type: CipherType::Login,
336            attachments,
337            organization_id: None,
338            folder_id: None,
339            collection_ids: vec![],
340            key: None,
341            notes: None,
342            login: None,
343            identity: None,
344            card: None,
345            secure_note: None,
346            ssh_key: None,
347            bank_account: None,
348            drivers_license: None,
349            passport: None,
350            favorite: false,
351            reprompt: CipherRepromptType::None,
352            organization_use_totp: true,
353            edit: true,
354            permissions: None,
355            view_password: true,
356            local_data: None,
357            fields: None,
358            password_history: None,
359            creation_date: "2024-05-31T11:20:58.4566667Z".parse().unwrap(),
360            deleted_date: None,
361            revision_date: "2024-05-31T11:20:58.4566667Z".parse().unwrap(),
362            archived_date: None,
363            data: None,
364        }
365    }
366
367    fn attachment_model(id: &str) -> AttachmentResponseModel {
368        AttachmentResponseModel {
369            id: Some(id.to_string()),
370            ..Default::default()
371        }
372    }
373
374    fn server_cipher_response() -> CipherResponseModel {
375        CipherResponseModel {
376            id: Some(TEST_CIPHER_ID.try_into().unwrap()),
377            name: Some(TEST_CIPHER_NAME.to_string()),
378            r#type: Some(bitwarden_api_api::models::CipherType::Login),
379            creation_date: Some("2024-05-31T11:20:58.4566667Z".to_string()),
380            revision_date: Some("2024-05-31T11:20:58.4566667Z".to_string()),
381            attachments: Some(vec![
382                attachment_model(OLD_ATTACHMENT_ID),
383                attachment_model(NEW_ATTACHMENT_ID),
384            ]),
385            ..Default::default()
386        }
387    }
388
389    // `upgrade_attachment` returns the decrypted delete result, so the delete response's name
390    // must decrypt under the test user key. Callers pass a name produced by `encrypted_name`.
391    fn server_cipher_mini_response(name: String) -> CipherMiniResponseModel {
392        CipherMiniResponseModel {
393            id: Some(TEST_CIPHER_ID.try_into().unwrap()),
394            name: Some(name),
395            r#type: Some(bitwarden_api_api::models::CipherType::Login),
396            creation_date: Some("2024-05-31T11:20:58.4566667Z".to_string()),
397            revision_date: Some("2024-05-31T11:20:58.4566667Z".to_string()),
398            attachments: Some(vec![attachment_model(NEW_ATTACHMENT_ID)]),
399            ..Default::default()
400        }
401    }
402
403    fn encrypted_name(key_store: &KeyStore<KeySlotIds>) -> String {
404        "Upgraded cipher"
405            .encrypt(&mut key_store.context(), SymmetricKeySlotId::User)
406            .expect("encrypt name")
407            .to_string()
408    }
409
410    /// Builds legacy attachment bytes: `[0x02][IV][HMAC][ciphertext]`,
411    /// encrypted under the user key.
412    async fn make_legacy_wire(key_store: &KeyStore<KeySlotIds>, plaintext: &[u8]) -> Vec<u8> {
413        let mut wire = Vec::new();
414        {
415            let ctx = key_store.context();
416            let mut enc = StreamingAttachmentEncryptor::new(
417                SymmetricKeySlotId::User,
418                ctx,
419                &mut wire,
420                plaintext.len(),
421            )
422            .expect("encryptor construction");
423            enc.write_all(plaintext).await.expect("write_all");
424            enc.shutdown().await.expect("shutdown");
425        }
426        wire
427    }
428
429    /// Builds a legacy cipher with one keyless attachment so the cipher still decrypts.
430    fn legacy_cipher(key_store: &KeyStore<KeySlotIds>, encrypted_size: usize) -> Cipher {
431        let mut ctx = key_store.context();
432        let name = "Upgrade test cipher"
433            .encrypt(&mut ctx, SymmetricKeySlotId::User)
434            .expect("encrypt name");
435        let file_name = "hello.txt"
436            .encrypt(&mut ctx, SymmetricKeySlotId::User)
437            .expect("encrypt file name");
438        drop(ctx);
439
440        cipher_with(
441            name,
442            Some(vec![Attachment {
443                id: Some(OLD_ATTACHMENT_ID.to_string()),
444                url: None,
445                file_name: Some(file_name),
446                key: None,
447                size: Some(encrypted_size.to_string()),
448                size_name: Some(format!("{encrypted_size} Bytes")),
449            }]),
450        )
451    }
452
453    #[tokio::test]
454    async fn returns_not_found_when_cipher_missing() {
455        let api_client = ApiClient::new_mocked(|_mock| {});
456        let key_store = create_test_crypto_with_user_key(SymmetricCryptoKey::make(
457            SymmetricKeyAlgorithm::Aes256CbcHmac,
458        ));
459        let client = client(
460            api_client,
461            MemoryRepository::<Cipher>::default(),
462            key_store,
463            "",
464        );
465        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
466
467        let err = client
468            .upgrade_attachment(cipher_id, OLD_ATTACHMENT_ID.to_string())
469            .await
470            .unwrap_err();
471
472        assert!(matches!(err, CipherUpgradeAttachmentError::NotFound));
473    }
474
475    #[tokio::test]
476    async fn returns_not_found_when_attachment_missing() {
477        let api_client = ApiClient::new_mocked(|_mock| {});
478        let key_store = create_test_crypto_with_user_key(SymmetricCryptoKey::make(
479            SymmetricKeyAlgorithm::Aes256CbcHmac,
480        ));
481
482        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
483        let repository = MemoryRepository::<Cipher>::default();
484        repository
485            .set(
486                cipher_id,
487                cipher_with(TEST_CIPHER_NAME.parse().unwrap(), None),
488            )
489            .await
490            .unwrap();
491
492        let client = client(api_client, repository, key_store, "");
493
494        let err = client
495            .upgrade_attachment(cipher_id, OLD_ATTACHMENT_ID.to_string())
496            .await
497            .unwrap_err();
498
499        assert!(matches!(err, CipherUpgradeAttachmentError::NotFound));
500    }
501
502    #[tokio::test]
503    async fn returns_already_upgraded_when_attachment_has_key() {
504        let api_client = ApiClient::new_mocked(|_mock| {});
505        let key_store = create_test_crypto_with_user_key(SymmetricCryptoKey::make(
506            SymmetricKeyAlgorithm::Aes256CbcHmac,
507        ));
508
509        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
510        let repository = MemoryRepository::<Cipher>::default();
511        repository
512            .set(
513                cipher_id,
514                cipher_with(
515                    TEST_CIPHER_NAME.parse().unwrap(),
516                    Some(vec![Attachment {
517                        id: Some(OLD_ATTACHMENT_ID.to_string()),
518                        url: None,
519                        file_name: Some(TEST_FILE_NAME.parse().unwrap()),
520                        // Already-modern attachment: it carries its own wrapped key.
521                        key: Some(TEST_KEY.parse().unwrap()),
522                        size: Some("65".to_string()),
523                        size_name: Some("65 Bytes".to_string()),
524                    }]),
525                ),
526            )
527            .await
528            .unwrap();
529
530        let client = client(api_client, repository, key_store, "");
531
532        let err = client
533            .upgrade_attachment(cipher_id, OLD_ATTACHMENT_ID.to_string())
534            .await
535            .unwrap_err();
536
537        assert!(matches!(err, CipherUpgradeAttachmentError::AlreadyUpgraded));
538    }
539
540    #[tokio::test]
541    async fn upgrades_legacy_attachment_via_direct_upload() {
542        use wiremock::{
543            Mock, MockServer, ResponseTemplate,
544            matchers::{method, path},
545        };
546
547        let key_store = create_test_crypto_with_user_key(SymmetricCryptoKey::make(
548            SymmetricKeyAlgorithm::Aes256CbcHmac,
549        ));
550        let wire = make_legacy_wire(&key_store, b"Hello, attachment upgrade world!").await;
551        let encrypted_size = wire.len();
552        let mini_name = encrypted_name(&key_store);
553
554        // Direct uploads go to the authenticated API endpoint, not the returned `url`.
555        let upload_path = format!("/ciphers/{TEST_CIPHER_ID}/attachment/{NEW_ATTACHMENT_ID}");
556
557        let server = MockServer::start().await;
558        Mock::given(method("GET"))
559            .and(path("/download/old"))
560            .respond_with(ResponseTemplate::new(200).set_body_bytes(wire.clone()))
561            .mount(&server)
562            .await;
563        Mock::given(method("POST"))
564            .and(path(upload_path.clone()))
565            .respond_with(ResponseTemplate::new(201))
566            .mount(&server)
567            .await;
568
569        let download_url = format!("{}/download/old", server.uri());
570
571        let api_client = ApiClient::new_mocked(move |mock| {
572            let download_url = download_url.clone();
573            mock.ciphers_api
574                .expect_get_attachment_data()
575                .returning(move |_id, _att| {
576                    Ok(AttachmentResponseModel {
577                        id: Some(OLD_ATTACHMENT_ID.to_string()),
578                        url: Some(download_url.clone()),
579                        ..Default::default()
580                    })
581                });
582            mock.ciphers_api
583                .expect_post_attachment()
584                .returning(move |_id, _req| {
585                    Ok(AttachmentUploadDataResponseModel {
586                        attachment_id: Some(NEW_ATTACHMENT_ID.to_string()),
587                        // `url` is ignored for Direct uploads.
588                        url: Some("https://unused.example/direct".to_string()),
589                        file_upload_type: Some(bitwarden_api_api::models::FileUploadType::Direct),
590                        cipher_response: Some(Box::new(server_cipher_response())),
591                        cipher_mini_response: None,
592                        ..Default::default()
593                    })
594                });
595            // On success the *legacy* attachment is deleted; the new slot is kept.
596            mock.ciphers_api
597                .expect_delete_attachment()
598                .withf(|_id, att_id| att_id == OLD_ATTACHMENT_ID)
599                .times(1)
600                .returning({
601                    let mini_name = mini_name.clone();
602                    move |_id, _att| {
603                        Ok(DeleteAttachmentResponseModel {
604                            object: None,
605                            cipher: Some(Box::new(server_cipher_mini_response(mini_name.clone()))),
606                        })
607                    }
608                });
609        });
610
611        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
612        let repository = MemoryRepository::<Cipher>::default();
613        repository
614            .set(cipher_id, legacy_cipher(&key_store, encrypted_size))
615            .await
616            .unwrap();
617
618        let client = client(api_client, repository, key_store, &server.uri());
619
620        let cipher = client
621            .upgrade_attachment(cipher_id, OLD_ATTACHMENT_ID.to_string())
622            .await
623            .unwrap();
624        assert_eq!(cipher.id, Some(cipher_id));
625
626        // The returned cipher must reflect the post-delete state, not the slot-creation snapshot:
627        // the legacy attachment is gone and the new one remains.
628        let returned_ids: Vec<String> = cipher
629            .attachments
630            .unwrap_or_default()
631            .into_iter()
632            .filter_map(|a| a.id)
633            .collect();
634        assert!(
635            !returned_ids.contains(&OLD_ATTACHMENT_ID.to_string()),
636            "returned cipher must not list the deleted legacy attachment, got {returned_ids:?}"
637        );
638        assert!(
639            returned_ids.contains(&NEW_ATTACHMENT_ID.to_string()),
640            "returned cipher should list the upgraded attachment, got {returned_ids:?}"
641        );
642
643        let requests = server.received_requests().await.unwrap();
644        assert_eq!(
645            requests
646                .iter()
647                .filter(|r| r.url.path() == "/download/old")
648                .count(),
649            1,
650            "legacy ciphertext should be downloaded exactly once"
651        );
652        assert_eq!(
653            requests
654                .iter()
655                .filter(|r| r.url.path() == upload_path.as_str())
656                .count(),
657            1,
658            "Direct upload should hit the authenticated attachment endpoint exactly once"
659        );
660    }
661
662    #[tokio::test]
663    async fn upgrades_legacy_attachment_via_azure_upload() {
664        use wiremock::{
665            Mock, MockServer, ResponseTemplate,
666            matchers::{header, method, path},
667        };
668
669        let key_store = create_test_crypto_with_user_key(SymmetricCryptoKey::make(
670            SymmetricKeyAlgorithm::Aes256CbcHmac,
671        ));
672        let wire = make_legacy_wire(&key_store, b"azure upload path plaintext").await;
673        let encrypted_size = wire.len();
674        let mini_name = encrypted_name(&key_store);
675
676        let server = MockServer::start().await;
677        Mock::given(method("GET"))
678            .and(path("/download/old"))
679            .respond_with(ResponseTemplate::new(200).set_body_bytes(wire.clone()))
680            .mount(&server)
681            .await;
682        // Azure uploads PUT directly to the presigned blob URL with the BlockBlob header.
683        Mock::given(method("PUT"))
684            .and(path("/upload/blob"))
685            .and(header("x-ms-blob-type", "BlockBlob"))
686            .respond_with(ResponseTemplate::new(201))
687            .mount(&server)
688            .await;
689
690        let download_url = format!("{}/download/old", server.uri());
691        let upload_url = format!("{}/upload/blob", server.uri());
692
693        let api_client = ApiClient::new_mocked(move |mock| {
694            let download_url = download_url.clone();
695            mock.ciphers_api
696                .expect_get_attachment_data()
697                .returning(move |_id, _att| {
698                    Ok(AttachmentResponseModel {
699                        id: Some(OLD_ATTACHMENT_ID.to_string()),
700                        url: Some(download_url.clone()),
701                        ..Default::default()
702                    })
703                });
704            let upload_url = upload_url.clone();
705            mock.ciphers_api
706                .expect_post_attachment()
707                .returning(move |_id, _req| {
708                    Ok(AttachmentUploadDataResponseModel {
709                        attachment_id: Some(NEW_ATTACHMENT_ID.to_string()),
710                        url: Some(upload_url.clone()),
711                        file_upload_type: Some(bitwarden_api_api::models::FileUploadType::Azure),
712                        cipher_response: Some(Box::new(server_cipher_response())),
713                        cipher_mini_response: None,
714                        ..Default::default()
715                    })
716                });
717            mock.ciphers_api
718                .expect_delete_attachment()
719                .withf(|_id, att_id| att_id == OLD_ATTACHMENT_ID)
720                .times(1)
721                .returning({
722                    let mini_name = mini_name.clone();
723                    move |_id, _att| {
724                        Ok(DeleteAttachmentResponseModel {
725                            object: None,
726                            cipher: Some(Box::new(server_cipher_mini_response(mini_name.clone()))),
727                        })
728                    }
729                });
730        });
731
732        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
733        let repository = MemoryRepository::<Cipher>::default();
734        repository
735            .set(cipher_id, legacy_cipher(&key_store, encrypted_size))
736            .await
737            .unwrap();
738
739        // Azure uses the presigned URL on the unauthenticated client, so `base_path` is unused.
740        let client = client(api_client, repository, key_store, "");
741
742        let cipher = client
743            .upgrade_attachment(cipher_id, OLD_ATTACHMENT_ID.to_string())
744            .await
745            .unwrap();
746        assert_eq!(cipher.id, Some(cipher_id));
747
748        let requests = server.received_requests().await.unwrap();
749        assert_eq!(
750            requests
751                .iter()
752                .filter(|r| r.url.path() == "/upload/blob")
753                .count(),
754            1,
755            "Azure upload should PUT to the presigned blob URL exactly once"
756        );
757    }
758
759    #[tokio::test]
760    async fn rolls_back_new_slot_when_upload_fails() {
761        use wiremock::{
762            Mock, MockServer, ResponseTemplate,
763            matchers::{method, path},
764        };
765
766        let key_store = create_test_crypto_with_user_key(SymmetricCryptoKey::make(
767            SymmetricKeyAlgorithm::Aes256CbcHmac,
768        ));
769        let wire = make_legacy_wire(&key_store, b"rollback path plaintext").await;
770        let encrypted_size = wire.len();
771        let mini_name = encrypted_name(&key_store);
772
773        let upload_path = format!("/ciphers/{TEST_CIPHER_ID}/attachment/{NEW_ATTACHMENT_ID}");
774
775        let server = MockServer::start().await;
776        Mock::given(method("GET"))
777            .and(path("/download/old"))
778            .respond_with(ResponseTemplate::new(200).set_body_bytes(wire.clone()))
779            .mount(&server)
780            .await;
781        // Upload fails — the orphaned new slot must be rolled back.
782        Mock::given(method("POST"))
783            .and(path(upload_path))
784            .respond_with(ResponseTemplate::new(500))
785            .mount(&server)
786            .await;
787
788        let download_url = format!("{}/download/old", server.uri());
789
790        let api_client = ApiClient::new_mocked(move |mock| {
791            let download_url = download_url.clone();
792            mock.ciphers_api
793                .expect_get_attachment_data()
794                .returning(move |_id, _att| {
795                    Ok(AttachmentResponseModel {
796                        id: Some(OLD_ATTACHMENT_ID.to_string()),
797                        url: Some(download_url.clone()),
798                        ..Default::default()
799                    })
800                });
801            mock.ciphers_api
802                .expect_post_attachment()
803                .returning(move |_id, _req| {
804                    Ok(AttachmentUploadDataResponseModel {
805                        attachment_id: Some(NEW_ATTACHMENT_ID.to_string()),
806                        url: Some("https://unused.example/direct".to_string()),
807                        file_upload_type: Some(bitwarden_api_api::models::FileUploadType::Direct),
808                        cipher_response: Some(Box::new(server_cipher_response())),
809                        cipher_mini_response: None,
810                        ..Default::default()
811                    })
812                });
813            // Rollback must delete the *new* slot, never the legacy one.
814            mock.ciphers_api
815                .expect_delete_attachment()
816                .withf(|_id, att_id| att_id == NEW_ATTACHMENT_ID)
817                .times(1)
818                .returning({
819                    let mini_name = mini_name.clone();
820                    move |_id, _att| {
821                        Ok(DeleteAttachmentResponseModel {
822                            object: None,
823                            cipher: Some(Box::new(server_cipher_mini_response(mini_name.clone()))),
824                        })
825                    }
826                });
827        });
828
829        let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
830        let repository = MemoryRepository::<Cipher>::default();
831        repository
832            .set(cipher_id, legacy_cipher(&key_store, encrypted_size))
833            .await
834            .unwrap();
835
836        let client = client(api_client, repository, key_store, &server.uri());
837
838        let err = client
839            .upgrade_attachment(cipher_id, OLD_ATTACHMENT_ID.to_string())
840            .await
841            .unwrap_err();
842
843        assert!(matches!(err, CipherUpgradeAttachmentError::Upload));
844    }
845}