1use bitwarden_api_base::AuthRequired;
2use bitwarden_core::{ApiError, MissingFieldError, require};
3use bitwarden_crypto::CryptoError;
4use bitwarden_error::bitwarden_error;
5use bitwarden_state::repository::RepositoryError;
6use serde::{Deserialize, Serialize};
7use serde_repr::{Deserialize_repr, Serialize_repr};
8use thiserror::Error;
9#[cfg(feature = "wasm")]
10use tsify::Tsify;
11#[cfg(feature = "wasm")]
12use wasm_bindgen::prelude::*;
13
14use crate::{
15 EmptyEmailListError, Send, SendAddRequest, SendId, SendParseError, SendView,
16 send_client::SendClient,
17};
18
19#[derive(Clone, Copy, Serialize_repr, Deserialize_repr, Debug, PartialEq)]
21#[repr(u8)]
22#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
23#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
24pub enum FileUploadType {
25 Direct = 0,
27 Azure = 1,
29}
30
31impl TryFrom<bitwarden_api_api::models::FileUploadType> for FileUploadType {
32 type Error = MissingFieldError;
33
34 fn try_from(t: bitwarden_api_api::models::FileUploadType) -> Result<Self, Self::Error> {
35 Ok(match t {
36 bitwarden_api_api::models::FileUploadType::Direct => FileUploadType::Direct,
37 bitwarden_api_api::models::FileUploadType::Azure => FileUploadType::Azure,
38 bitwarden_api_api::models::FileUploadType::__Unknown(_) => {
39 return Err(MissingFieldError("file_upload_type"));
40 }
41 })
42 }
43}
44
45#[derive(Serialize, Deserialize, Debug)]
49#[serde(rename_all = "camelCase")]
50#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi))]
51pub struct CreateFileSendResponse {
52 pub send: SendView,
54 pub url: String,
56 pub file_upload_type: FileUploadType,
58 pub file_id: String,
60 pub encrypted_file_name: String,
62}
63
64#[allow(missing_docs)]
65#[bitwarden_error(flat)]
66#[derive(Debug, Error)]
67pub enum CreateFileSendError {
68 #[error(transparent)]
70 Api(#[from] ApiError),
71 #[error(transparent)]
73 Crypto(#[from] CryptoError),
74 #[error(transparent)]
76 EmptyEmailList(#[from] EmptyEmailListError),
77 #[error(transparent)]
79 MissingField(#[from] MissingFieldError),
80 #[error(transparent)]
82 Repository(#[from] RepositoryError),
83 #[error(transparent)]
85 SendParse(#[from] SendParseError),
86}
87
88#[allow(missing_docs)]
89#[bitwarden_error(flat)]
90#[derive(Debug, Error)]
91pub enum UploadSendFileError {
92 #[error(transparent)]
94 Api(#[from] ApiError),
95 #[error(transparent)]
97 Reqwest(reqwest::Error),
98}
99
100#[allow(missing_docs)]
101#[bitwarden_error(flat)]
102#[derive(Debug, Error)]
103pub enum RenewFileUploadUrlError {
104 #[error(transparent)]
106 Api(#[from] ApiError),
107 #[error(transparent)]
109 MissingField(#[from] MissingFieldError),
110}
111
112#[cfg_attr(feature = "wasm", wasm_bindgen)]
113impl SendClient {
114 pub async fn create_file_send(
119 &self,
120 request: SendAddRequest,
121 ) -> Result<CreateFileSendResponse, CreateFileSendError> {
122 request.auth.validate()?;
123
124 let key_store = self.client.internal.get_key_store();
125 let config = self.client.internal.get_api_configurations();
126 let repository = self.get_repository()?;
127
128 let send_request = key_store.encrypt(request)?;
129
130 let resp = config
131 .api_client
132 .sends_api()
133 .post_file(Some(send_request))
134 .await
135 .map_err(ApiError::from)?;
136
137 let url = require!(resp.url);
138 let file_upload_type: FileUploadType = require!(resp.file_upload_type).try_into()?;
139 let send_response = *require!(resp.send_response);
140
141 let send: Send = send_response.try_into()?;
142
143 let file_id = send
144 .file
145 .as_ref()
146 .and_then(|f| f.id.clone())
147 .ok_or(MissingFieldError("file.id"))?;
148
149 let encrypted_file_name = send
150 .file
151 .as_ref()
152 .map(|f| f.file_name.to_string())
153 .ok_or(MissingFieldError("file.file_name"))?;
154
155 let send_view = key_store.decrypt(&send)?;
156
157 let send_id = require!(send.id);
158 repository.set(send_id, send.clone()).await?;
159
160 Ok(CreateFileSendResponse {
161 send: send_view,
162 url,
163 file_upload_type,
164 file_id,
165 encrypted_file_name,
166 })
167 }
168
169 pub async fn upload_send_file(
174 &self,
175 send_id: SendId,
176 file_id: String,
177 encrypted_file_name: String,
178 data: Vec<u8>,
179 ) -> Result<(), UploadSendFileError> {
180 let config = self.client.internal.get_api_configurations();
181
182 let url = format!(
183 "{}/sends/{}/file/{}",
184 config.api_config.base_path,
185 bitwarden_api_base::urlencode(send_id.to_string()),
186 bitwarden_api_base::urlencode(&file_id),
187 );
188
189 let part = reqwest::multipart::Part::bytes(data)
190 .file_name(encrypted_file_name)
191 .mime_str("application/octet-stream")
192 .map_err(UploadSendFileError::Reqwest)?;
193
194 let form = reqwest::multipart::Form::new().part("data", part);
195
196 let req_builder = config
197 .api_config
198 .client
199 .post(url)
200 .with_extension(AuthRequired::Bearer)
201 .multipart(form);
202
203 bitwarden_api_base::process_with_empty_response(req_builder)
204 .await
205 .map_err(|e: bitwarden_api_api::apis::Error<()>| ApiError::from(e))?;
206
207 Ok(())
208 }
209
210 pub async fn renew_file_upload_url(
214 &self,
215 send_id: SendId,
216 file_id: String,
217 ) -> Result<String, RenewFileUploadUrlError> {
218 let config = self.client.internal.get_api_configurations();
219
220 let resp = config
221 .api_client
222 .sends_api()
223 .renew_file_upload(&send_id.to_string(), &file_id)
224 .await
225 .map_err(ApiError::from)?;
226
227 Ok(require!(resp.url))
228 }
229}
230
231#[cfg(test)]
232mod tests {
233 use std::sync::Arc;
234
235 use bitwarden_api_api::models::{
236 FileUploadType, SendFileModel, SendFileUploadDataResponseModel, SendResponseModel,
237 };
238 use bitwarden_core::{
239 Client, ClientSettings, DeviceType,
240 key_management::{KeySlotIds, SymmetricKeySlotId},
241 };
242 use bitwarden_crypto::{KeyStore, SymmetricKeyAlgorithm};
243 use bitwarden_state::repository::Repository;
244 use bitwarden_test::{MemoryRepository, start_api_mock};
245 use uuid::uuid;
246 use wiremock::{
247 Mock, MockServer, ResponseTemplate,
248 matchers::{method, path, path_regex},
249 };
250
251 use super::*;
252 use crate::{
253 AuthType, Send, SendAuthType, SendClientExt, SendId, SendTextView, SendType, SendViewType,
254 };
255
256 const SEND_ID: &str = "25afb11c-9c95-4db5-8bac-c21cb204a3f1";
257 const FILE_ID: &str = "file-id-abc";
258
259 fn make_test_client(server: &MockServer) -> (Client, Arc<MemoryRepository<Send>>) {
263 let settings = ClientSettings {
264 identity_url: server.uri(),
265 api_url: server.uri(),
266 user_agent: "Bitwarden Test".into(),
267 device_type: DeviceType::SDK,
268 device_identifier: None,
269 bitwarden_client_version: None,
270 bitwarden_package_type: None,
271 };
272 let client = Client::new(Some(settings));
273
274 {
276 let key_store: &KeyStore<KeySlotIds> = client.internal.get_key_store();
277 let mut ctx = key_store.context_mut();
278 let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
279 ctx.persist_symmetric_key(local_key_id, SymmetricKeySlotId::User)
280 .unwrap();
281 }
282
283 let repository = Arc::new(MemoryRepository::<Send>::default());
284 client
285 .platform()
286 .state()
287 .register_client_managed(repository.clone());
288
289 (client, repository)
290 }
291
292 fn sample_request() -> SendAddRequest {
293 SendAddRequest {
294 name: "test-file-send".to_string(),
295 notes: None,
296 view_type: SendViewType::Text(SendTextView {
299 text: None,
300 hidden: false,
301 }),
302 max_access_count: None,
303 disabled: false,
304 hide_email: false,
305 deletion_date: "2025-01-10T00:00:00Z".parse().unwrap(),
306 expiration_date: None,
307 auth: SendAuthType::None,
308 }
309 }
310
311 fn echo_file_send_response(
314 request: bitwarden_api_api::models::SendRequestModel,
315 ) -> SendResponseModel {
316 let encrypted_name = request.name.clone();
317 SendResponseModel {
318 id: Some(uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1")),
319 name: request.name,
320 revision_date: Some("2025-01-01T00:00:00Z".to_string()),
321 object: Some("send".to_string()),
322 access_id: None,
323 r#type: Some(bitwarden_api_api::models::SendType::File),
324 auth_type: Some(bitwarden_api_api::models::AuthType::None),
325 notes: request.notes,
326 file: Some(Box::new(SendFileModel {
327 id: Some(FILE_ID.to_string()),
328 file_name: encrypted_name,
331 size: Some("123".to_string()),
332 size_name: Some("123 B".to_string()),
333 })),
334 text: None,
335 key: Some(request.key),
336 max_access_count: request.max_access_count,
337 access_count: Some(0),
338 password: request.password,
339 emails: request.emails,
340 disabled: Some(request.disabled),
341 expiration_date: request.expiration_date,
342 deletion_date: Some(request.deletion_date),
343 hide_email: request.hide_email,
344 }
345 }
346
347 #[tokio::test]
350 async fn test_create_file_send() {
351 let upload_url = "https://upload.example.com/abc";
352 let mock = Mock::given(method("POST"))
353 .and(path("/sends/file/v2"))
354 .respond_with(move |req: &wiremock::Request| {
355 let body: bitwarden_api_api::models::SendRequestModel =
356 serde_json::from_slice(&req.body).expect("request body should be valid JSON");
357 let send_response = echo_file_send_response(body);
358 let response = SendFileUploadDataResponseModel {
359 object: Some("send-fileUpload".to_string()),
360 url: Some(upload_url.to_string()),
361 file_upload_type: Some(FileUploadType::Azure),
362 send_response: Some(Box::new(send_response)),
363 };
364 ResponseTemplate::new(200).set_body_json(&response)
365 });
366
367 let (server, _config) = start_api_mock(vec![mock]).await;
368 let (client, repository) = make_test_client(&server);
369
370 let result = client
371 .sends()
372 .create_file_send(sample_request())
373 .await
374 .unwrap();
375
376 assert_eq!(result.url, upload_url);
377 assert_eq!(result.file_upload_type, crate::FileUploadType::Azure);
378 assert_eq!(result.file_id, FILE_ID);
379 assert!(!result.encrypted_file_name.is_empty());
380 assert_eq!(result.send.id, Some(SendId::new(SEND_ID.parse().unwrap())));
381 assert_eq!(result.send.name, "test-file-send");
382 assert_eq!(result.send.r#type, SendType::File);
383 assert_eq!(result.send.auth_type, AuthType::None);
384
385 let stored: Option<Send> = repository
387 .get(SendId::new(SEND_ID.parse().unwrap()))
388 .await
389 .unwrap();
390 assert!(stored.is_some(), "send should be stored in the repository");
391 }
392
393 #[tokio::test]
394 async fn test_create_file_send_errors_when_file_upload_type_missing() {
395 let mock = Mock::given(method("POST"))
396 .and(path("/sends/file/v2"))
397 .respond_with(move |req: &wiremock::Request| {
398 let body: bitwarden_api_api::models::SendRequestModel =
399 serde_json::from_slice(&req.body).unwrap();
400 let send_response = echo_file_send_response(body);
401 let response = SendFileUploadDataResponseModel {
402 object: Some("send-fileUpload".to_string()),
403 url: Some("https://upload.example.com/abc".to_string()),
404 file_upload_type: None,
407 send_response: Some(Box::new(send_response)),
408 };
409 ResponseTemplate::new(200).set_body_json(&response)
410 });
411
412 let (server, _config) = start_api_mock(vec![mock]).await;
413 let (client, _repository) = make_test_client(&server);
414
415 let err = client
416 .sends()
417 .create_file_send(sample_request())
418 .await
419 .unwrap_err();
420
421 assert!(matches!(err, CreateFileSendError::MissingField(_)));
422 }
423
424 #[tokio::test]
425 async fn test_create_file_send_http_error() {
426 let mock = Mock::given(method("POST"))
427 .and(path("/sends/file/v2"))
428 .respond_with(ResponseTemplate::new(500));
429
430 let (server, _config) = start_api_mock(vec![mock]).await;
431 let (client, _repository) = make_test_client(&server);
432
433 let err = client
434 .sends()
435 .create_file_send(sample_request())
436 .await
437 .unwrap_err();
438
439 assert!(matches!(err, CreateFileSendError::Api(_)));
440 }
441
442 #[tokio::test]
443 async fn test_create_file_send_empty_email_list_validation() {
444 let server = MockServer::start().await;
446 let (client, _repository) = make_test_client(&server);
447
448 let mut request = sample_request();
449 request.auth = SendAuthType::Emails { emails: vec![] };
450
451 let err = client.sends().create_file_send(request).await.unwrap_err();
452
453 assert!(matches!(err, CreateFileSendError::EmptyEmailList(_)));
454 assert!(
455 server.received_requests().await.unwrap().is_empty(),
456 "validation failure should short-circuit before any HTTP call"
457 );
458 }
459
460 #[tokio::test]
463 async fn test_upload_send_file() {
464 let send_id = SendId::new(SEND_ID.parse().unwrap());
465 let file_id = FILE_ID.to_string();
466 let encrypted_file_name = "2.encrypted-name|abc|def".to_string();
467 let data = b"encrypted-file-bytes".to_vec();
468
469 let mock = Mock::given(method("POST"))
470 .and(path(format!("/sends/{}/file/{}", SEND_ID, FILE_ID)))
471 .respond_with(ResponseTemplate::new(200));
472
473 let (server, _config) = start_api_mock(vec![mock]).await;
474 let (client, _repository) = make_test_client(&server);
475
476 client
477 .sends()
478 .upload_send_file(send_id, file_id, encrypted_file_name, data.clone())
479 .await
480 .unwrap();
481
482 let requests = server.received_requests().await.unwrap();
484 assert_eq!(requests.len(), 1);
485 let req = &requests[0];
486 let content_type = req
487 .headers
488 .get("content-type")
489 .map(|v| v.to_str().unwrap())
490 .unwrap_or_default();
491 assert!(
492 content_type.starts_with("multipart/form-data"),
493 "expected multipart/form-data, got {content_type}"
494 );
495 let body = &req.body;
497 assert!(
498 body.windows(data.len()).any(|w| w == data.as_slice()),
499 "request body should contain the encrypted file bytes"
500 );
501 let body_str = String::from_utf8_lossy(body);
503 assert!(
504 body_str.contains("name=\"data\""),
505 "multipart should include the 'data' part name"
506 );
507 assert!(
508 body_str.contains("filename=\"2.encrypted-name|abc|def\""),
509 "multipart should include the encrypted file name as the part's filename"
510 );
511 }
512
513 #[tokio::test]
514 async fn test_upload_send_file_http_error() {
515 let send_id = SendId::new(SEND_ID.parse().unwrap());
516 let file_id = FILE_ID.to_string();
517
518 let mock = Mock::given(method("POST"))
519 .and(path(format!("/sends/{}/file/{}", SEND_ID, FILE_ID)))
520 .respond_with(ResponseTemplate::new(500));
521
522 let (server, _config) = start_api_mock(vec![mock]).await;
523 let (client, _repository) = make_test_client(&server);
524
525 let err = client
526 .sends()
527 .upload_send_file(
528 send_id,
529 file_id,
530 "encrypted-name".to_string(),
531 b"data".to_vec(),
532 )
533 .await
534 .unwrap_err();
535
536 assert!(matches!(err, UploadSendFileError::Api(_)));
537 }
538
539 #[tokio::test]
542 async fn test_renew_file_upload_url() {
543 let send_id = SendId::new(SEND_ID.parse().unwrap());
544 let file_id = FILE_ID.to_string();
545 let new_url = "https://upload.example.com/renewed";
546
547 let mock = Mock::given(method("GET"))
549 .and(path_regex(r"^/sends/[a-f0-9-]+/file/[^/]+$"))
550 .respond_with(move |req: &wiremock::Request| {
551 assert!(
553 req.url
554 .path()
555 .ends_with(&format!("/sends/{}/file/{}", SEND_ID, FILE_ID))
556 );
557 let response = SendFileUploadDataResponseModel {
558 object: Some("send-fileUpload".to_string()),
559 url: Some(new_url.to_string()),
560 file_upload_type: Some(FileUploadType::Azure),
561 send_response: None,
562 };
563 ResponseTemplate::new(200).set_body_json(&response)
564 });
565
566 let (server, _config) = start_api_mock(vec![mock]).await;
567 let (client, _repository) = make_test_client(&server);
568
569 let url = client
570 .sends()
571 .renew_file_upload_url(send_id, file_id)
572 .await
573 .unwrap();
574
575 assert_eq!(url, new_url);
576 }
577
578 #[tokio::test]
579 async fn test_renew_file_upload_url_missing_url() {
580 let send_id = SendId::new(SEND_ID.parse().unwrap());
581 let file_id = FILE_ID.to_string();
582
583 let mock = Mock::given(method("GET"))
586 .and(path_regex(r"^/sends/[a-f0-9-]+/file/[^/]+$"))
587 .respond_with(ResponseTemplate::new(200).set_body_json(
588 &SendFileUploadDataResponseModel {
589 object: Some("send-fileUpload".to_string()),
590 url: None,
591 file_upload_type: None,
592 send_response: None,
593 },
594 ));
595
596 let (server, _config) = start_api_mock(vec![mock]).await;
597 let (client, _repository) = make_test_client(&server);
598
599 let err = client
600 .sends()
601 .renew_file_upload_url(send_id, file_id)
602 .await
603 .unwrap_err();
604
605 assert!(matches!(err, RenewFileUploadUrlError::MissingField(_)));
606 }
607
608 #[tokio::test]
609 async fn test_renew_file_upload_url_http_error() {
610 let send_id = SendId::new(SEND_ID.parse().unwrap());
611 let file_id = FILE_ID.to_string();
612
613 let mock = Mock::given(method("GET"))
614 .and(path_regex(r"^/sends/[a-f0-9-]+/file/[^/]+$"))
615 .respond_with(ResponseTemplate::new(500));
616
617 let (server, _config) = start_api_mock(vec![mock]).await;
618 let (client, _repository) = make_test_client(&server);
619
620 let err = client
621 .sends()
622 .renew_file_upload_url(send_id, file_id)
623 .await
624 .unwrap_err();
625
626 assert!(matches!(err, RenewFileUploadUrlError::Api(_)));
627 }
628}