Skip to main content

bitwarden_send/
create_file_send.rs

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/// Where the client should upload the encrypted file bytes after [`SendClient::create_file_send`].
20#[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    /// Upload directly to the Bitwarden server via `POST /sends/{id}/file/{file_id}`.
26    Direct = 0,
27    /// Upload to an Azure Blob Storage pre-signed URL.
28    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/// View returned after creating a file send.
46///
47/// Contains the created send and information needed to upload the file data.
48#[derive(Serialize, Deserialize, Debug)]
49#[serde(rename_all = "camelCase")]
50#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi))]
51pub struct CreateFileSendResponse {
52    /// The created send.
53    pub send: SendView,
54    /// The upload URL for the file data.
55    pub url: String,
56    /// Which upload backend the client should target with the encrypted bytes.
57    pub file_upload_type: FileUploadType,
58    /// The file ID assigned by the server.
59    pub file_id: String,
60    /// The encrypted file name string (e.g. `"2.ABCD..."`).
61    pub encrypted_file_name: String,
62}
63
64#[allow(missing_docs)]
65#[bitwarden_error(flat)]
66#[derive(Debug, Error)]
67pub enum CreateFileSendError {
68    /// An API or network error occurred.
69    #[error(transparent)]
70    Api(#[from] ApiError),
71    /// A cryptographic error occurred.
72    #[error(transparent)]
73    Crypto(#[from] CryptoError),
74    /// An email list validation error occurred.
75    #[error(transparent)]
76    EmptyEmailList(#[from] EmptyEmailListError),
77    /// A required field was missing from the API response.
78    #[error(transparent)]
79    MissingField(#[from] MissingFieldError),
80    /// A repository error occurred.
81    #[error(transparent)]
82    Repository(#[from] RepositoryError),
83    /// A send parse error occurred.
84    #[error(transparent)]
85    SendParse(#[from] SendParseError),
86}
87
88#[allow(missing_docs)]
89#[bitwarden_error(flat)]
90#[derive(Debug, Error)]
91pub enum UploadSendFileError {
92    /// An API or network error occurred.
93    #[error(transparent)]
94    Api(#[from] ApiError),
95    /// A reqwest error occurred when building the multipart form.
96    #[error(transparent)]
97    Reqwest(reqwest::Error),
98}
99
100#[allow(missing_docs)]
101#[bitwarden_error(flat)]
102#[derive(Debug, Error)]
103pub enum RenewFileUploadUrlError {
104    /// An API or network error occurred.
105    #[error(transparent)]
106    Api(#[from] ApiError),
107    /// A required field was missing from the API response.
108    #[error(transparent)]
109    MissingField(#[from] MissingFieldError),
110}
111
112#[cfg_attr(feature = "wasm", wasm_bindgen)]
113impl SendClient {
114    /// Create a new file [Send] and save it to the server.
115    ///
116    /// Returns the created send along with metadata needed to upload the file data.
117    /// After calling this, use [`SendClient::upload_send_file`] to upload the encrypted file.
118    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    /// Upload the encrypted file data for a file [Send].
170    ///
171    /// `data` must be the encrypted file content (encrypted with the send key).
172    /// `encrypted_file_name` is the encrypted file name string from [`CreateFileSendResponse`].
173    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    /// Renew the upload URL for a file [Send].
211    ///
212    /// Returns a fresh upload URL if the previous one has expired.
213    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    /// Builds a [`Client`] whose API and identity URLs point at the supplied wiremock server,
260    /// then registers a [`MemoryRepository`] for [`Send`] and installs a fresh symmetric key
261    /// in the user slot so that encrypt/decrypt round-trips succeed.
262    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        // Seed the user key slot so SendAddRequest::encrypt_composite can wrap the send key.
275        {
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            // Even file sends use SendViewType::Text for the *request* body since the file
297            // content is uploaded separately; the server flips the type on the response.
298            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    /// Build a [`SendResponseModel`] that echoes the encrypted `name`/`key` from the request,
312    /// adds a server-assigned file ID, and is a valid input for `Send::try_from`.
313    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                // Re-use the encrypted name as a stand-in for an encrypted file_name; the
329                // SDK only requires that this be a parseable EncString.
330                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    // ===== create_file_send =====
348
349    #[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        // The created send should have been persisted to the repository.
386        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                    // Omit file_upload_type — the SDK must not silently default to a
405                    // backend that could route bytes to the wrong place.
406                    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        // No HTTP mock needed: validation should fail before the request is sent.
445        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    // ===== upload_send_file =====
461
462    #[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        // Verify the multipart request looked right.
483        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        // The encrypted bytes should appear verbatim in the multipart body.
496        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        // The form part name and file_name should be embedded in the multipart headers.
502        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    // ===== renew_file_upload_url =====
540
541    #[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        // The generated client issues a GET against /sends/{id}/file/{fileId}.
548        let mock = Mock::given(method("GET"))
549            .and(path_regex(r"^/sends/[a-f0-9-]+/file/[^/]+$"))
550            .respond_with(move |req: &wiremock::Request| {
551                // Sanity-check the path arguments were threaded through correctly.
552                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        // Server returns a 200 but omits the url field; this exercises the require!(resp.url)
584        // branch which maps to MissingField.
585        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}