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