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 id: TEST_CIPHER_ID.parse().ok(),
333 name: Some(name),
334 r#type: CipherType::Login,
335 attachments,
336 organization_id: None,
337 folder_id: None,
338 collection_ids: vec![],
339 key: None,
340 notes: None,
341 login: None,
342 identity: None,
343 card: None,
344 secure_note: None,
345 ssh_key: None,
346 bank_account: None,
347 drivers_license: None,
348 passport: None,
349 favorite: false,
350 reprompt: CipherRepromptType::None,
351 organization_use_totp: true,
352 edit: true,
353 permissions: None,
354 view_password: true,
355 local_data: None,
356 fields: None,
357 password_history: None,
358 creation_date: "2024-05-31T11:20:58.4566667Z".parse().unwrap(),
359 deleted_date: None,
360 revision_date: "2024-05-31T11:20:58.4566667Z".parse().unwrap(),
361 archived_date: None,
362 data: None,
363 }
364 }
365
366 fn attachment_model(id: &str) -> AttachmentResponseModel {
367 AttachmentResponseModel {
368 id: Some(id.to_string()),
369 ..Default::default()
370 }
371 }
372
373 fn server_cipher_response() -> CipherResponseModel {
374 CipherResponseModel {
375 id: Some(TEST_CIPHER_ID.try_into().unwrap()),
376 name: Some(TEST_CIPHER_NAME.to_string()),
377 r#type: Some(bitwarden_api_api::models::CipherType::Login),
378 creation_date: Some("2024-05-31T11:20:58.4566667Z".to_string()),
379 revision_date: Some("2024-05-31T11:20:58.4566667Z".to_string()),
380 attachments: Some(vec![
381 attachment_model(OLD_ATTACHMENT_ID),
382 attachment_model(NEW_ATTACHMENT_ID),
383 ]),
384 ..Default::default()
385 }
386 }
387
388 fn server_cipher_mini_response(name: String) -> CipherMiniResponseModel {
391 CipherMiniResponseModel {
392 id: Some(TEST_CIPHER_ID.try_into().unwrap()),
393 name: Some(name),
394 r#type: Some(bitwarden_api_api::models::CipherType::Login),
395 creation_date: Some("2024-05-31T11:20:58.4566667Z".to_string()),
396 revision_date: Some("2024-05-31T11:20:58.4566667Z".to_string()),
397 attachments: Some(vec![attachment_model(NEW_ATTACHMENT_ID)]),
398 ..Default::default()
399 }
400 }
401
402 fn encrypted_name(key_store: &KeyStore<KeySlotIds>) -> String {
403 "Upgraded cipher"
404 .encrypt(&mut key_store.context(), SymmetricKeySlotId::User)
405 .expect("encrypt name")
406 .to_string()
407 }
408
409 async fn make_legacy_wire(key_store: &KeyStore<KeySlotIds>, plaintext: &[u8]) -> Vec<u8> {
412 let mut wire = Vec::new();
413 {
414 let ctx = key_store.context();
415 let mut enc = StreamingAttachmentEncryptor::new(
416 SymmetricKeySlotId::User,
417 ctx,
418 &mut wire,
419 plaintext.len(),
420 )
421 .expect("encryptor construction");
422 enc.write_all(plaintext).await.expect("write_all");
423 enc.shutdown().await.expect("shutdown");
424 }
425 wire
426 }
427
428 fn legacy_cipher(key_store: &KeyStore<KeySlotIds>, encrypted_size: usize) -> Cipher {
430 let mut ctx = key_store.context();
431 let name = "Upgrade test cipher"
432 .encrypt(&mut ctx, SymmetricKeySlotId::User)
433 .expect("encrypt name");
434 let file_name = "hello.txt"
435 .encrypt(&mut ctx, SymmetricKeySlotId::User)
436 .expect("encrypt file name");
437 drop(ctx);
438
439 cipher_with(
440 name,
441 Some(vec![Attachment {
442 id: Some(OLD_ATTACHMENT_ID.to_string()),
443 url: None,
444 file_name: Some(file_name),
445 key: None,
446 size: Some(encrypted_size.to_string()),
447 size_name: Some(format!("{encrypted_size} Bytes")),
448 }]),
449 )
450 }
451
452 #[tokio::test]
453 async fn returns_not_found_when_cipher_missing() {
454 let api_client = ApiClient::new_mocked(|_mock| {});
455 let key_store = create_test_crypto_with_user_key(SymmetricCryptoKey::make(
456 SymmetricKeyAlgorithm::Aes256CbcHmac,
457 ));
458 let client = client(
459 api_client,
460 MemoryRepository::<Cipher>::default(),
461 key_store,
462 "",
463 );
464 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
465
466 let err = client
467 .upgrade_attachment(cipher_id, OLD_ATTACHMENT_ID.to_string())
468 .await
469 .unwrap_err();
470
471 assert!(matches!(err, CipherUpgradeAttachmentError::NotFound));
472 }
473
474 #[tokio::test]
475 async fn returns_not_found_when_attachment_missing() {
476 let api_client = ApiClient::new_mocked(|_mock| {});
477 let key_store = create_test_crypto_with_user_key(SymmetricCryptoKey::make(
478 SymmetricKeyAlgorithm::Aes256CbcHmac,
479 ));
480
481 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
482 let repository = MemoryRepository::<Cipher>::default();
483 repository
484 .set(
485 cipher_id,
486 cipher_with(TEST_CIPHER_NAME.parse().unwrap(), None),
487 )
488 .await
489 .unwrap();
490
491 let client = client(api_client, repository, key_store, "");
492
493 let err = client
494 .upgrade_attachment(cipher_id, OLD_ATTACHMENT_ID.to_string())
495 .await
496 .unwrap_err();
497
498 assert!(matches!(err, CipherUpgradeAttachmentError::NotFound));
499 }
500
501 #[tokio::test]
502 async fn returns_already_upgraded_when_attachment_has_key() {
503 let api_client = ApiClient::new_mocked(|_mock| {});
504 let key_store = create_test_crypto_with_user_key(SymmetricCryptoKey::make(
505 SymmetricKeyAlgorithm::Aes256CbcHmac,
506 ));
507
508 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
509 let repository = MemoryRepository::<Cipher>::default();
510 repository
511 .set(
512 cipher_id,
513 cipher_with(
514 TEST_CIPHER_NAME.parse().unwrap(),
515 Some(vec![Attachment {
516 id: Some(OLD_ATTACHMENT_ID.to_string()),
517 url: None,
518 file_name: Some(TEST_FILE_NAME.parse().unwrap()),
519 key: Some(TEST_KEY.parse().unwrap()),
521 size: Some("65".to_string()),
522 size_name: Some("65 Bytes".to_string()),
523 }]),
524 ),
525 )
526 .await
527 .unwrap();
528
529 let client = client(api_client, repository, key_store, "");
530
531 let err = client
532 .upgrade_attachment(cipher_id, OLD_ATTACHMENT_ID.to_string())
533 .await
534 .unwrap_err();
535
536 assert!(matches!(err, CipherUpgradeAttachmentError::AlreadyUpgraded));
537 }
538
539 #[tokio::test]
540 async fn upgrades_legacy_attachment_via_direct_upload() {
541 use wiremock::{
542 Mock, MockServer, ResponseTemplate,
543 matchers::{method, path},
544 };
545
546 let key_store = create_test_crypto_with_user_key(SymmetricCryptoKey::make(
547 SymmetricKeyAlgorithm::Aes256CbcHmac,
548 ));
549 let wire = make_legacy_wire(&key_store, b"Hello, attachment upgrade world!").await;
550 let encrypted_size = wire.len();
551 let mini_name = encrypted_name(&key_store);
552
553 let upload_path = format!("/ciphers/{TEST_CIPHER_ID}/attachment/{NEW_ATTACHMENT_ID}");
555
556 let server = MockServer::start().await;
557 Mock::given(method("GET"))
558 .and(path("/download/old"))
559 .respond_with(ResponseTemplate::new(200).set_body_bytes(wire.clone()))
560 .mount(&server)
561 .await;
562 Mock::given(method("POST"))
563 .and(path(upload_path.clone()))
564 .respond_with(ResponseTemplate::new(201))
565 .mount(&server)
566 .await;
567
568 let download_url = format!("{}/download/old", server.uri());
569
570 let api_client = ApiClient::new_mocked(move |mock| {
571 let download_url = download_url.clone();
572 mock.ciphers_api
573 .expect_get_attachment_data()
574 .returning(move |_id, _att| {
575 Ok(AttachmentResponseModel {
576 id: Some(OLD_ATTACHMENT_ID.to_string()),
577 url: Some(download_url.clone()),
578 ..Default::default()
579 })
580 });
581 mock.ciphers_api
582 .expect_post_attachment()
583 .returning(move |_id, _req| {
584 Ok(AttachmentUploadDataResponseModel {
585 attachment_id: Some(NEW_ATTACHMENT_ID.to_string()),
586 url: Some("https://unused.example/direct".to_string()),
588 file_upload_type: Some(bitwarden_api_api::models::FileUploadType::Direct),
589 cipher_response: Some(Box::new(server_cipher_response())),
590 cipher_mini_response: None,
591 ..Default::default()
592 })
593 });
594 mock.ciphers_api
596 .expect_delete_attachment()
597 .withf(|_id, att_id| att_id == OLD_ATTACHMENT_ID)
598 .times(1)
599 .returning({
600 let mini_name = mini_name.clone();
601 move |_id, _att| {
602 Ok(DeleteAttachmentResponseModel {
603 object: None,
604 cipher: Some(Box::new(server_cipher_mini_response(mini_name.clone()))),
605 })
606 }
607 });
608 });
609
610 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
611 let repository = MemoryRepository::<Cipher>::default();
612 repository
613 .set(cipher_id, legacy_cipher(&key_store, encrypted_size))
614 .await
615 .unwrap();
616
617 let client = client(api_client, repository, key_store, &server.uri());
618
619 let cipher = client
620 .upgrade_attachment(cipher_id, OLD_ATTACHMENT_ID.to_string())
621 .await
622 .unwrap();
623 assert_eq!(cipher.id, Some(cipher_id));
624
625 let returned_ids: Vec<String> = cipher
628 .attachments
629 .unwrap_or_default()
630 .into_iter()
631 .filter_map(|a| a.id)
632 .collect();
633 assert!(
634 !returned_ids.contains(&OLD_ATTACHMENT_ID.to_string()),
635 "returned cipher must not list the deleted legacy attachment, got {returned_ids:?}"
636 );
637 assert!(
638 returned_ids.contains(&NEW_ATTACHMENT_ID.to_string()),
639 "returned cipher should list the upgraded attachment, got {returned_ids:?}"
640 );
641
642 let requests = server.received_requests().await.unwrap();
643 assert_eq!(
644 requests
645 .iter()
646 .filter(|r| r.url.path() == "/download/old")
647 .count(),
648 1,
649 "legacy ciphertext should be downloaded exactly once"
650 );
651 assert_eq!(
652 requests
653 .iter()
654 .filter(|r| r.url.path() == upload_path.as_str())
655 .count(),
656 1,
657 "Direct upload should hit the authenticated attachment endpoint exactly once"
658 );
659 }
660
661 #[tokio::test]
662 async fn upgrades_legacy_attachment_via_azure_upload() {
663 use wiremock::{
664 Mock, MockServer, ResponseTemplate,
665 matchers::{header, method, path},
666 };
667
668 let key_store = create_test_crypto_with_user_key(SymmetricCryptoKey::make(
669 SymmetricKeyAlgorithm::Aes256CbcHmac,
670 ));
671 let wire = make_legacy_wire(&key_store, b"azure upload path plaintext").await;
672 let encrypted_size = wire.len();
673 let mini_name = encrypted_name(&key_store);
674
675 let server = MockServer::start().await;
676 Mock::given(method("GET"))
677 .and(path("/download/old"))
678 .respond_with(ResponseTemplate::new(200).set_body_bytes(wire.clone()))
679 .mount(&server)
680 .await;
681 Mock::given(method("PUT"))
683 .and(path("/upload/blob"))
684 .and(header("x-ms-blob-type", "BlockBlob"))
685 .respond_with(ResponseTemplate::new(201))
686 .mount(&server)
687 .await;
688
689 let download_url = format!("{}/download/old", server.uri());
690 let upload_url = format!("{}/upload/blob", server.uri());
691
692 let api_client = ApiClient::new_mocked(move |mock| {
693 let download_url = download_url.clone();
694 mock.ciphers_api
695 .expect_get_attachment_data()
696 .returning(move |_id, _att| {
697 Ok(AttachmentResponseModel {
698 id: Some(OLD_ATTACHMENT_ID.to_string()),
699 url: Some(download_url.clone()),
700 ..Default::default()
701 })
702 });
703 let upload_url = upload_url.clone();
704 mock.ciphers_api
705 .expect_post_attachment()
706 .returning(move |_id, _req| {
707 Ok(AttachmentUploadDataResponseModel {
708 attachment_id: Some(NEW_ATTACHMENT_ID.to_string()),
709 url: Some(upload_url.clone()),
710 file_upload_type: Some(bitwarden_api_api::models::FileUploadType::Azure),
711 cipher_response: Some(Box::new(server_cipher_response())),
712 cipher_mini_response: None,
713 ..Default::default()
714 })
715 });
716 mock.ciphers_api
717 .expect_delete_attachment()
718 .withf(|_id, att_id| att_id == OLD_ATTACHMENT_ID)
719 .times(1)
720 .returning({
721 let mini_name = mini_name.clone();
722 move |_id, _att| {
723 Ok(DeleteAttachmentResponseModel {
724 object: None,
725 cipher: Some(Box::new(server_cipher_mini_response(mini_name.clone()))),
726 })
727 }
728 });
729 });
730
731 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
732 let repository = MemoryRepository::<Cipher>::default();
733 repository
734 .set(cipher_id, legacy_cipher(&key_store, encrypted_size))
735 .await
736 .unwrap();
737
738 let client = client(api_client, repository, key_store, "");
740
741 let cipher = client
742 .upgrade_attachment(cipher_id, OLD_ATTACHMENT_ID.to_string())
743 .await
744 .unwrap();
745 assert_eq!(cipher.id, Some(cipher_id));
746
747 let requests = server.received_requests().await.unwrap();
748 assert_eq!(
749 requests
750 .iter()
751 .filter(|r| r.url.path() == "/upload/blob")
752 .count(),
753 1,
754 "Azure upload should PUT to the presigned blob URL exactly once"
755 );
756 }
757
758 #[tokio::test]
759 async fn rolls_back_new_slot_when_upload_fails() {
760 use wiremock::{
761 Mock, MockServer, ResponseTemplate,
762 matchers::{method, path},
763 };
764
765 let key_store = create_test_crypto_with_user_key(SymmetricCryptoKey::make(
766 SymmetricKeyAlgorithm::Aes256CbcHmac,
767 ));
768 let wire = make_legacy_wire(&key_store, b"rollback path plaintext").await;
769 let encrypted_size = wire.len();
770 let mini_name = encrypted_name(&key_store);
771
772 let upload_path = format!("/ciphers/{TEST_CIPHER_ID}/attachment/{NEW_ATTACHMENT_ID}");
773
774 let server = MockServer::start().await;
775 Mock::given(method("GET"))
776 .and(path("/download/old"))
777 .respond_with(ResponseTemplate::new(200).set_body_bytes(wire.clone()))
778 .mount(&server)
779 .await;
780 Mock::given(method("POST"))
782 .and(path(upload_path))
783 .respond_with(ResponseTemplate::new(500))
784 .mount(&server)
785 .await;
786
787 let download_url = format!("{}/download/old", server.uri());
788
789 let api_client = ApiClient::new_mocked(move |mock| {
790 let download_url = download_url.clone();
791 mock.ciphers_api
792 .expect_get_attachment_data()
793 .returning(move |_id, _att| {
794 Ok(AttachmentResponseModel {
795 id: Some(OLD_ATTACHMENT_ID.to_string()),
796 url: Some(download_url.clone()),
797 ..Default::default()
798 })
799 });
800 mock.ciphers_api
801 .expect_post_attachment()
802 .returning(move |_id, _req| {
803 Ok(AttachmentUploadDataResponseModel {
804 attachment_id: Some(NEW_ATTACHMENT_ID.to_string()),
805 url: Some("https://unused.example/direct".to_string()),
806 file_upload_type: Some(bitwarden_api_api::models::FileUploadType::Direct),
807 cipher_response: Some(Box::new(server_cipher_response())),
808 cipher_mini_response: None,
809 ..Default::default()
810 })
811 });
812 mock.ciphers_api
814 .expect_delete_attachment()
815 .withf(|_id, att_id| att_id == NEW_ATTACHMENT_ID)
816 .times(1)
817 .returning({
818 let mini_name = mini_name.clone();
819 move |_id, _att| {
820 Ok(DeleteAttachmentResponseModel {
821 object: None,
822 cipher: Some(Box::new(server_cipher_mini_response(mini_name.clone()))),
823 })
824 }
825 });
826 });
827
828 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
829 let repository = MemoryRepository::<Cipher>::default();
830 repository
831 .set(cipher_id, legacy_cipher(&key_store, encrypted_size))
832 .await
833 .unwrap();
834
835 let client = client(api_client, repository, key_store, &server.uri());
836
837 let err = client
838 .upgrade_attachment(cipher_id, OLD_ATTACHMENT_ID.to_string())
839 .await
840 .unwrap_err();
841
842 assert!(matches!(err, CipherUpgradeAttachmentError::Upload));
843 }
844}