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