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