1use bitwarden_api_api::{
2 apis::ApiClient,
3 models::{AttachmentRequestModel, AttachmentUploadDataResponseModel},
4};
5use bitwarden_core::{ApiError, MissingFieldError};
6use bitwarden_crypto::EncString;
7use bitwarden_error::bitwarden_error;
8use bitwarden_state::repository::{RepositoryError, RepositoryOption};
9use chrono::{DateTime, SecondsFormat, Utc};
10use serde::{Deserialize, Serialize};
11use thiserror::Error;
12
13use crate::{AttachmentsClient, Cipher, CipherId, VaultParseError, cipher::cipher::PartialCipher};
14
15#[allow(missing_docs)]
16#[bitwarden_error(flat)]
17#[derive(Debug, Error)]
18pub enum CipherCreateAttachmentError {
19 #[error(transparent)]
20 Api(#[from] ApiError),
21 #[error(transparent)]
22 Repository(#[from] RepositoryError),
23 #[error(transparent)]
24 MissingField(#[from] MissingFieldError),
25 #[error(transparent)]
26 VaultParse(#[from] VaultParseError),
27 #[error("Server returned an unsupported file upload type")]
28 UnsupportedFileUploadType,
29}
30
31#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
33#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
34#[bitwarden_ffi::wasm_record]
35pub enum AttachmentFileUploadType {
36 Direct,
38 Azure,
40}
41
42impl AttachmentFileUploadType {
43 fn from_api<E: CreateAttachmentFailure>(
44 value: bitwarden_api_api::models::FileUploadType,
45 ) -> Result<Self, E> {
46 match value {
47 bitwarden_api_api::models::FileUploadType::Direct => Ok(Self::Direct),
48 bitwarden_api_api::models::FileUploadType::Azure => Ok(Self::Azure),
49 bitwarden_api_api::models::FileUploadType::__Unknown(_) => {
50 Err(E::unsupported_file_upload_type())
51 }
52 }
53 }
54}
55
56pub(super) trait CreateAttachmentFailure:
59 From<ApiError> + From<MissingFieldError> + From<VaultParseError>
60{
61 fn unsupported_file_upload_type() -> Self;
62}
63
64impl CreateAttachmentFailure for CipherCreateAttachmentError {
65 fn unsupported_file_upload_type() -> Self {
66 Self::UnsupportedFileUploadType
67 }
68}
69
70#[derive(Clone, Debug, Serialize, Deserialize)]
76#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
77#[bitwarden_ffi::wasm_record]
78#[serde(rename_all = "camelCase")]
79pub struct CreateAttachmentRequest {
80 pub key: EncString,
82 pub file_name: EncString,
84 pub file_size: u64,
86 pub last_known_revision_date: DateTime<Utc>,
88}
89
90impl CreateAttachmentRequest {
91 fn into_api_model(self, as_admin: bool) -> AttachmentRequestModel {
92 AttachmentRequestModel {
93 key: Some(self.key.to_string()),
94 file_name: Some(self.file_name.to_string()),
95 file_size: Some(self.file_size as i64),
96 admin_request: Some(as_admin),
97 last_known_revision_date: Some(
98 self.last_known_revision_date
99 .to_rfc3339_opts(SecondsFormat::Millis, true),
100 ),
101 }
102 }
103}
104
105#[derive(Clone, Debug, Serialize, Deserialize)]
108#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
109#[bitwarden_ffi::wasm_record]
110#[serde(rename_all = "camelCase")]
111pub struct CreatedAttachment {
112 pub attachment_id: String,
114 pub upload_url: String,
116 pub file_upload_type: AttachmentFileUploadType,
118 pub cipher: Cipher,
121}
122
123#[bitwarden_ffi::wasm_export]
124impl AttachmentsClient {
125 pub async fn create_attachment(
134 &self,
135 cipher_id: CipherId,
136 request: CreateAttachmentRequest,
137 ) -> Result<CreatedAttachment, CipherCreateAttachmentError> {
138 let repository = self.repository.require()?;
139 let existing_cipher = repository.get(cipher_id).await?;
140
141 create_attachment(
142 &self.api_configurations.api_client,
143 cipher_id,
144 request,
145 false,
146 async |response| {
147 let cipher_response = response
148 .cipher_response
149 .ok_or(MissingFieldError("cipher_response"))?;
150 let merged = (*cipher_response).merge_with_cipher(existing_cipher)?;
151 repository.set(cipher_id, merged.clone()).await?;
152 Ok(merged)
153 },
154 )
155 .await
156 }
157}
158
159pub(super) async fn create_attachment<E: CreateAttachmentFailure>(
163 api_client: &ApiClient,
164 cipher_id: CipherId,
165 request: CreateAttachmentRequest,
166 as_admin: bool,
167 into_cipher: impl AsyncFnOnce(AttachmentUploadDataResponseModel) -> Result<Cipher, E>,
168) -> Result<CreatedAttachment, E> {
169 let response = api_client
170 .ciphers_api()
171 .post_attachment(cipher_id.into(), Some(request.into_api_model(as_admin)))
172 .await?;
173
174 let new_attachment_id = response
177 .attachment_id
178 .clone()
179 .ok_or(MissingFieldError("attachment_id"))?;
180
181 let result = finalize_create(response, into_cipher).await;
182
183 if result.is_err() {
184 let rollback = if as_admin {
185 api_client
186 .ciphers_api()
187 .delete_attachment_admin(cipher_id.into(), &new_attachment_id)
188 .await
189 .map(|_| ())
190 .map_err(|e| format!("{e:?}"))
191 } else {
192 api_client
193 .ciphers_api()
194 .delete_attachment(cipher_id.into(), &new_attachment_id)
195 .await
196 .map(|_| ())
197 .map_err(|e| format!("{e:?}"))
198 };
199
200 if let Err(rollback_err) = rollback {
201 tracing::warn!(
202 "failed to roll back orphaned attachment slot {new_attachment_id} on cipher {cipher_id}: {rollback_err}",
203 );
204 }
205 }
206
207 result
208}
209
210async fn finalize_create<E: CreateAttachmentFailure>(
211 mut response: AttachmentUploadDataResponseModel,
212 into_cipher: impl AsyncFnOnce(AttachmentUploadDataResponseModel) -> Result<Cipher, E>,
213) -> Result<CreatedAttachment, E> {
214 let attachment_id = response
215 .attachment_id
216 .take()
217 .ok_or(MissingFieldError("attachment_id"))?;
218 let upload_url = response.url.take().ok_or(MissingFieldError("url"))?;
219 let file_upload_type = AttachmentFileUploadType::from_api::<E>(
220 response
221 .file_upload_type
222 .take()
223 .ok_or(MissingFieldError("file_upload_type"))?,
224 )?;
225 let cipher = into_cipher(response).await?;
226
227 Ok(CreatedAttachment {
228 attachment_id,
229 upload_url,
230 file_upload_type,
231 cipher,
232 })
233}
234
235#[cfg(test)]
236mod tests {
237 use std::sync::Arc;
238
239 use bitwarden_api_api::{
240 apis::ApiClient,
241 models::{
242 AttachmentUploadDataResponseModel, CipherMiniResponseModel, CipherResponseModel,
243 DeleteAttachmentResponseModel,
244 },
245 };
246 use bitwarden_core::{client::ApiConfigurations, key_management::KeySlotIds};
247 use bitwarden_crypto::KeyStore;
248 use bitwarden_state::repository::Repository;
249 use bitwarden_test::MemoryRepository;
250
251 use super::*;
252 use crate::{CipherRepromptType, CipherType, CreateAttachmentAdminError};
253
254 const TEST_CIPHER_ID: &str = "5faa9684-c793-4a2d-8a12-b33900187097";
255 const NEW_ATTACHMENT_ID: &str = "newatt9999999999999999999999999";
256 const TEST_CIPHER_NAME: &str = "2.pMS6/icTQABtulw52pq2lg==|XXbxKxDTh+mWiN1HjH2N1w==|Q6PkuT+KX/axrgN9ubD5Ajk2YNwxQkgs3WJM0S0wtG8=";
257 const TEST_FILE_NAME: &str = "2.mV50WiLq6duhwGbhM1TO0A==|dTufWNH8YTPP0EMlNLIpFA==|QHp+7OM8xHtEmCfc9QPXJ0Ro2BeakzvLgxJZ7NdLuDc=";
258 const TEST_KEY: &str = "2.6TPEiYULFg/4+3CpDRwCqw==|6swweBHCJcd5CHdwBBWuRN33XRV22VoroDFDUmiM4OzjPEAhgZK57IZS1KkBlCcFvT+t+YbsmDcdv+Lqr+iJ3MmzfJ40MCB5TfYy+22HVRA=|rkgFDh2IWTfPC1Y66h68Diiab/deyi1p/X0Fwkva0NQ=";
259
260 fn client_with_api_and_repo(
261 api_client: ApiClient,
262 repository: MemoryRepository<Cipher>,
263 ) -> (AttachmentsClient, Arc<MemoryRepository<Cipher>>) {
264 let repo_arc = Arc::new(repository);
265 let client = AttachmentsClient {
266 key_store: KeyStore::<KeySlotIds>::default(),
267 api_configurations: Arc::new(ApiConfigurations::from_api_client(api_client)),
268 repository: Some(repo_arc.clone()),
269 http_client: reqwest::Client::new(),
270 };
271 (client, repo_arc)
272 }
273
274 fn test_request() -> CreateAttachmentRequest {
275 CreateAttachmentRequest {
276 key: TEST_KEY.parse().unwrap(),
277 file_name: TEST_FILE_NAME.parse().unwrap(),
278 file_size: 65,
279 last_known_revision_date: "2024-05-31T11:20:58.456Z".parse().unwrap(),
280 }
281 }
282
283 fn test_cipher() -> Cipher {
284 Cipher {
285 partial_data: None,
286 id: TEST_CIPHER_ID.parse().ok(),
287 name: Some(TEST_CIPHER_NAME.parse().unwrap()),
288 r#type: CipherType::Login,
289 attachments: None,
290 organization_id: None,
291 folder_id: None,
292 collection_ids: vec![],
293 key: None,
294 notes: None,
295 login: None,
296 identity: None,
297 card: None,
298 secure_note: None,
299 ssh_key: None,
300 bank_account: None,
301 drivers_license: None,
302 passport: None,
303 favorite: false,
304 reprompt: CipherRepromptType::None,
305 organization_use_totp: true,
306 edit: true,
307 permissions: None,
308 view_password: true,
309 local_data: None,
310 fields: None,
311 password_history: None,
312 creation_date: "2024-05-31T11:20:58.4566667Z".parse().unwrap(),
313 deleted_date: None,
314 revision_date: "2024-05-31T11:20:58.4566667Z".parse().unwrap(),
315 archived_date: None,
316 data: None,
317 }
318 }
319
320 fn server_cipher_response() -> CipherResponseModel {
321 CipherResponseModel {
322 id: Some(TEST_CIPHER_ID.try_into().unwrap()),
323 name: Some(TEST_CIPHER_NAME.to_string()),
324 r#type: Some(bitwarden_api_api::models::CipherType::Login),
325 creation_date: Some("2024-05-31T11:20:58.4566667Z".to_string()),
326 revision_date: Some("2024-05-31T11:20:58.4566667Z".to_string()),
327 ..Default::default()
328 }
329 }
330
331 #[tokio::test]
332 async fn returns_created_attachment_on_success() {
333 let api_client = ApiClient::new_mocked(|mock| {
334 mock.ciphers_api
335 .expect_post_attachment()
336 .returning(|_id, _req| {
337 Ok(AttachmentUploadDataResponseModel {
338 attachment_id: Some(NEW_ATTACHMENT_ID.to_string()),
339 url: Some("http://example.com/upload".to_string()),
340 file_upload_type: Some(bitwarden_api_api::models::FileUploadType::Direct),
341 cipher_response: Some(Box::new(server_cipher_response())),
342 cipher_mini_response: None,
343 ..Default::default()
344 })
345 });
346 mock.ciphers_api.expect_delete_attachment().never();
347 });
348
349 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
350 let repository = MemoryRepository::<Cipher>::default();
351 repository.set(cipher_id, test_cipher()).await.unwrap();
352 let (client, repo) = client_with_api_and_repo(api_client, repository);
353
354 let result = client
355 .create_attachment(cipher_id, test_request())
356 .await
357 .unwrap();
358
359 assert_eq!(result.attachment_id, NEW_ATTACHMENT_ID);
360 assert_eq!(result.upload_url, "http://example.com/upload");
361 assert_eq!(result.file_upload_type, AttachmentFileUploadType::Direct);
362 assert_eq!(result.cipher.id, Some(cipher_id));
364
365 let stored = repo.get(cipher_id).await.unwrap().unwrap();
367 assert_eq!(stored.id, Some(cipher_id));
368 }
369
370 #[tokio::test]
371 async fn admin_returns_cipher_from_mini_response_and_skips_repository_write() {
372 let api_client = ApiClient::new_mocked(|mock| {
373 mock.ciphers_api
374 .expect_post_attachment()
375 .withf(|_id, req| req.as_ref().and_then(|r| r.admin_request).unwrap_or(false))
376 .returning(|_id, _req| {
377 Ok(AttachmentUploadDataResponseModel {
378 attachment_id: Some(NEW_ATTACHMENT_ID.to_string()),
379 url: Some("http://example.com/upload".to_string()),
380 file_upload_type: Some(bitwarden_api_api::models::FileUploadType::Direct),
381 cipher_response: None,
382 cipher_mini_response: Some(Box::new(CipherMiniResponseModel {
383 id: Some(TEST_CIPHER_ID.try_into().unwrap()),
384 name: Some(TEST_CIPHER_NAME.to_string()),
385 r#type: Some(bitwarden_api_api::models::CipherType::Login),
386 creation_date: Some("2024-05-31T11:20:58.4566667Z".to_string()),
387 revision_date: Some("2024-05-31T11:20:58.4566667Z".to_string()),
388 attachments: None,
389 ..Default::default()
390 })),
391 ..Default::default()
392 })
393 });
394 mock.ciphers_api.expect_delete_attachment().never();
395 mock.ciphers_api.expect_delete_attachment_admin().never();
396 });
397
398 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
399 let (client, repo) =
400 client_with_api_and_repo(api_client, MemoryRepository::<Cipher>::default());
401
402 let result = client
403 .admin()
404 .create_attachment(cipher_id, test_request())
405 .await
406 .unwrap();
407
408 assert_eq!(result.attachment_id, NEW_ATTACHMENT_ID);
409 assert_eq!(result.upload_url, "http://example.com/upload");
410 assert_eq!(result.cipher.id, Some(cipher_id));
411
412 assert!(repo.get(cipher_id).await.unwrap().is_none());
414 }
415
416 #[tokio::test]
417 async fn admin_rolls_back_via_admin_delete_when_finalize_fails() {
418 let api_client = ApiClient::new_mocked(|mock| {
419 mock.ciphers_api
420 .expect_post_attachment()
421 .returning(|_id, _req| {
422 Ok(AttachmentUploadDataResponseModel {
423 attachment_id: Some(NEW_ATTACHMENT_ID.to_string()),
424 url: Some("http://example.com/upload".to_string()),
425 file_upload_type: Some(bitwarden_api_api::models::FileUploadType::Direct),
426 cipher_response: None,
427 cipher_mini_response: None,
428 ..Default::default()
429 })
430 });
431 mock.ciphers_api
433 .expect_delete_attachment_admin()
434 .withf(|_id, attachment_id| attachment_id == NEW_ATTACHMENT_ID)
435 .times(1)
436 .returning(|_id, _att_id| {
437 Ok(DeleteAttachmentResponseModel {
438 object: None,
439 cipher: None,
440 })
441 });
442 mock.ciphers_api.expect_delete_attachment().never();
443 });
444
445 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
446 let (client, _repo) =
447 client_with_api_and_repo(api_client, MemoryRepository::<Cipher>::default());
448
449 let err = client
450 .admin()
451 .create_attachment(cipher_id, test_request())
452 .await
453 .unwrap_err();
454
455 assert!(matches!(err, CreateAttachmentAdminError::MissingField(_)));
456 }
457
458 #[tokio::test]
459 async fn rolls_back_orphaned_slot_when_response_has_no_cipher() {
460 let api_client = ApiClient::new_mocked(|mock| {
461 mock.ciphers_api
462 .expect_post_attachment()
463 .returning(|_id, _req| {
464 Ok(AttachmentUploadDataResponseModel {
465 attachment_id: Some(NEW_ATTACHMENT_ID.to_string()),
466 url: Some("http://example.com/upload".to_string()),
467 file_upload_type: Some(bitwarden_api_api::models::FileUploadType::Direct),
468 cipher_response: None,
469 cipher_mini_response: None,
470 ..Default::default()
471 })
472 });
473 mock.ciphers_api
474 .expect_delete_attachment()
475 .withf(|_id, attachment_id| attachment_id == NEW_ATTACHMENT_ID)
476 .times(1)
477 .returning(|_id, _att_id| {
478 Ok(DeleteAttachmentResponseModel {
479 object: None,
480 cipher: None,
481 })
482 });
483 });
484
485 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
486 let repository = MemoryRepository::<Cipher>::default();
487 repository.set(cipher_id, test_cipher()).await.unwrap();
488 let (client, _repo) = client_with_api_and_repo(api_client, repository);
489
490 let err = client
491 .create_attachment(cipher_id, test_request())
492 .await
493 .unwrap_err();
494
495 assert!(matches!(err, CipherCreateAttachmentError::MissingField(_)));
496 }
497
498 #[tokio::test]
499 async fn errors_without_rollback_when_post_v2_fails() {
500 let api_client = ApiClient::new_mocked(|mock| {
501 mock.ciphers_api
502 .expect_post_attachment()
503 .returning(|_id, _req| {
504 Err(bitwarden_api_api::ApiError::Response(
505 bitwarden_api_api::ResponseContent {
506 status: reqwest::StatusCode::INTERNAL_SERVER_ERROR,
507 message: "boom".to_string(),
508 },
509 ))
510 });
511 mock.ciphers_api.expect_delete_attachment().never();
513 });
514
515 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
516 let repository = MemoryRepository::<Cipher>::default();
517 repository.set(cipher_id, test_cipher()).await.unwrap();
518 let (client, _repo) = client_with_api_and_repo(api_client, repository);
519
520 let err = client
521 .create_attachment(cipher_id, test_request())
522 .await
523 .unwrap_err();
524
525 assert!(matches!(err, CipherCreateAttachmentError::Api(_)));
526 }
527
528 #[test]
529 fn file_upload_type_unknown_variant_returns_error() {
530 let result = AttachmentFileUploadType::from_api::<CipherCreateAttachmentError>(
531 bitwarden_api_api::models::FileUploadType::__Unknown(42),
532 );
533 assert!(matches!(
534 result,
535 Err(CipherCreateAttachmentError::UnsupportedFileUploadType)
536 ));
537 }
538}