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 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 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 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 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 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 let mut decryptor = {
209 let ctx = self.key_store.context();
210 StreamingAttachmentDecryptor::new(legacy_key_slot, ctx, download_reader)?
211 };
212
213 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 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 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 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 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 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 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 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 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 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: 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 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 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 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 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 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 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}