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