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 id: TEST_CIPHER_ID.parse().ok(),
277 name: Some(TEST_CIPHER_NAME.parse().unwrap()),
278 r#type: CipherType::Login,
279 attachments: None,
280 organization_id: None,
281 folder_id: None,
282 collection_ids: vec![],
283 key: None,
284 notes: None,
285 login: None,
286 identity: None,
287 card: None,
288 secure_note: None,
289 ssh_key: None,
290 bank_account: None,
291 drivers_license: None,
292 passport: None,
293 favorite: false,
294 reprompt: CipherRepromptType::None,
295 organization_use_totp: true,
296 edit: true,
297 permissions: None,
298 view_password: true,
299 local_data: None,
300 fields: None,
301 password_history: None,
302 creation_date: "2024-05-31T11:20:58.4566667Z".parse().unwrap(),
303 deleted_date: None,
304 revision_date: "2024-05-31T11:20:58.4566667Z".parse().unwrap(),
305 archived_date: None,
306 data: None,
307 }
308 }
309
310 fn server_cipher_response() -> CipherResponseModel {
311 CipherResponseModel {
312 id: Some(TEST_CIPHER_ID.try_into().unwrap()),
313 name: Some(TEST_CIPHER_NAME.to_string()),
314 r#type: Some(bitwarden_api_api::models::CipherType::Login),
315 creation_date: Some("2024-05-31T11:20:58.4566667Z".to_string()),
316 revision_date: Some("2024-05-31T11:20:58.4566667Z".to_string()),
317 ..Default::default()
318 }
319 }
320
321 #[tokio::test]
322 async fn returns_created_attachment_on_success() {
323 let api_client = ApiClient::new_mocked(|mock| {
324 mock.ciphers_api
325 .expect_post_attachment()
326 .returning(|_id, _req| {
327 Ok(AttachmentUploadDataResponseModel {
328 attachment_id: Some(NEW_ATTACHMENT_ID.to_string()),
329 url: Some("http://example.com/upload".to_string()),
330 file_upload_type: Some(bitwarden_api_api::models::FileUploadType::Direct),
331 cipher_response: Some(Box::new(server_cipher_response())),
332 cipher_mini_response: None,
333 ..Default::default()
334 })
335 });
336 mock.ciphers_api.expect_delete_attachment().never();
337 });
338
339 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
340 let repository = MemoryRepository::<Cipher>::default();
341 repository.set(cipher_id, test_cipher()).await.unwrap();
342 let (client, repo) = client_with_api_and_repo(api_client, repository);
343
344 let result = client
345 .create_attachment(cipher_id, test_request())
346 .await
347 .unwrap();
348
349 assert_eq!(result.attachment_id, NEW_ATTACHMENT_ID);
350 assert_eq!(result.upload_url, "http://example.com/upload");
351 assert_eq!(result.file_upload_type, AttachmentFileUploadType::Direct);
352 assert_eq!(result.cipher.id, Some(cipher_id));
354
355 let stored = repo.get(cipher_id).await.unwrap().unwrap();
357 assert_eq!(stored.id, Some(cipher_id));
358 }
359
360 #[tokio::test]
361 async fn admin_returns_cipher_from_mini_response_and_skips_repository_write() {
362 let api_client = ApiClient::new_mocked(|mock| {
363 mock.ciphers_api
364 .expect_post_attachment()
365 .withf(|_id, req| req.as_ref().and_then(|r| r.admin_request).unwrap_or(false))
366 .returning(|_id, _req| {
367 Ok(AttachmentUploadDataResponseModel {
368 attachment_id: Some(NEW_ATTACHMENT_ID.to_string()),
369 url: Some("http://example.com/upload".to_string()),
370 file_upload_type: Some(bitwarden_api_api::models::FileUploadType::Direct),
371 cipher_response: None,
372 cipher_mini_response: Some(Box::new(CipherMiniResponseModel {
373 id: Some(TEST_CIPHER_ID.try_into().unwrap()),
374 name: Some(TEST_CIPHER_NAME.to_string()),
375 r#type: Some(bitwarden_api_api::models::CipherType::Login),
376 creation_date: Some("2024-05-31T11:20:58.4566667Z".to_string()),
377 revision_date: Some("2024-05-31T11:20:58.4566667Z".to_string()),
378 attachments: None,
379 ..Default::default()
380 })),
381 ..Default::default()
382 })
383 });
384 mock.ciphers_api.expect_delete_attachment().never();
385 mock.ciphers_api.expect_delete_attachment_admin().never();
386 });
387
388 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
389 let (client, repo) =
390 client_with_api_and_repo(api_client, MemoryRepository::<Cipher>::default());
391
392 let result = client
393 .create_attachment(cipher_id, admin_request())
394 .await
395 .unwrap();
396
397 assert_eq!(result.attachment_id, NEW_ATTACHMENT_ID);
398 assert_eq!(result.upload_url, "http://example.com/upload");
399 assert_eq!(result.cipher.id, Some(cipher_id));
400
401 assert!(repo.get(cipher_id).await.unwrap().is_none());
403 }
404
405 #[tokio::test]
406 async fn admin_rolls_back_via_admin_delete_when_finalize_fails() {
407 let api_client = ApiClient::new_mocked(|mock| {
408 mock.ciphers_api
409 .expect_post_attachment()
410 .returning(|_id, _req| {
411 Ok(AttachmentUploadDataResponseModel {
412 attachment_id: Some(NEW_ATTACHMENT_ID.to_string()),
413 url: Some("http://example.com/upload".to_string()),
414 file_upload_type: Some(bitwarden_api_api::models::FileUploadType::Direct),
415 cipher_response: None,
416 cipher_mini_response: None,
417 ..Default::default()
418 })
419 });
420 mock.ciphers_api
422 .expect_delete_attachment_admin()
423 .withf(|_id, attachment_id| attachment_id == NEW_ATTACHMENT_ID)
424 .times(1)
425 .returning(|_id, _att_id| {
426 Ok(DeleteAttachmentResponseModel {
427 object: None,
428 cipher: None,
429 })
430 });
431 mock.ciphers_api.expect_delete_attachment().never();
432 });
433
434 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
435 let (client, _repo) =
436 client_with_api_and_repo(api_client, MemoryRepository::<Cipher>::default());
437
438 let err = client
439 .create_attachment(cipher_id, admin_request())
440 .await
441 .unwrap_err();
442
443 assert!(matches!(err, CipherCreateAttachmentError::MissingField(_)));
444 }
445
446 #[tokio::test]
447 async fn rolls_back_orphaned_slot_when_response_has_no_cipher() {
448 let api_client = ApiClient::new_mocked(|mock| {
449 mock.ciphers_api
450 .expect_post_attachment()
451 .returning(|_id, _req| {
452 Ok(AttachmentUploadDataResponseModel {
453 attachment_id: Some(NEW_ATTACHMENT_ID.to_string()),
454 url: Some("http://example.com/upload".to_string()),
455 file_upload_type: Some(bitwarden_api_api::models::FileUploadType::Direct),
456 cipher_response: None,
457 cipher_mini_response: None,
458 ..Default::default()
459 })
460 });
461 mock.ciphers_api
462 .expect_delete_attachment()
463 .withf(|_id, attachment_id| attachment_id == NEW_ATTACHMENT_ID)
464 .times(1)
465 .returning(|_id, _att_id| {
466 Ok(DeleteAttachmentResponseModel {
467 object: None,
468 cipher: None,
469 })
470 });
471 });
472
473 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
474 let repository = MemoryRepository::<Cipher>::default();
475 repository.set(cipher_id, test_cipher()).await.unwrap();
476 let (client, _repo) = client_with_api_and_repo(api_client, repository);
477
478 let err = client
479 .create_attachment(cipher_id, test_request())
480 .await
481 .unwrap_err();
482
483 assert!(matches!(err, CipherCreateAttachmentError::MissingField(_)));
484 }
485
486 #[tokio::test]
487 async fn errors_without_rollback_when_post_v2_fails() {
488 let api_client = ApiClient::new_mocked(|mock| {
489 mock.ciphers_api
490 .expect_post_attachment()
491 .returning(|_id, _req| {
492 Err(bitwarden_api_api::ApiError::Response(
493 bitwarden_api_api::ResponseContent {
494 status: reqwest::StatusCode::INTERNAL_SERVER_ERROR,
495 message: "boom".to_string(),
496 },
497 ))
498 });
499 mock.ciphers_api.expect_delete_attachment().never();
501 });
502
503 let cipher_id: CipherId = TEST_CIPHER_ID.parse().unwrap();
504 let repository = MemoryRepository::<Cipher>::default();
505 repository.set(cipher_id, test_cipher()).await.unwrap();
506 let (client, _repo) = client_with_api_and_repo(api_client, repository);
507
508 let err = client
509 .create_attachment(cipher_id, test_request())
510 .await
511 .unwrap_err();
512
513 assert!(matches!(err, CipherCreateAttachmentError::Api(_)));
514 }
515
516 #[test]
517 fn file_upload_type_unknown_variant_returns_error() {
518 let result: Result<AttachmentFileUploadType, _> =
519 bitwarden_api_api::models::FileUploadType::__Unknown(42).try_into();
520 assert!(matches!(
521 result,
522 Err(CipherCreateAttachmentError::UnsupportedFileUploadType)
523 ));
524 }
525}