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