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 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 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 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 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 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 let mut decryptor = {
200 let ctx = self.key_store.context();
201 StreamingAttachmentDecryptor::new(legacy_key_slot, ctx, download_reader)?
202 };
203
204 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 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 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 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 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 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 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 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 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 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: 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 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 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 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 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 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 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}