1use bitwarden_core::{
2 ApiError, MissingFieldError,
3 key_management::{KeySlotIds, SymmetricKeySlotId},
4};
5use bitwarden_crypto::{
6 CompositeEncryptable, CryptoError, IdentifyKey, KeyStore, KeyStoreContext, OctetStreamBytes,
7 PrimitiveEncryptable,
8};
9use bitwarden_encoding::B64Url;
10use bitwarden_error::bitwarden_error;
11use bitwarden_state::repository::{Repository, RepositoryError};
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use thiserror::Error;
15#[cfg(feature = "wasm")]
16use tsify::Tsify;
17use uuid::Uuid;
18#[cfg(feature = "wasm")]
19use wasm_bindgen::prelude::*;
20
21use crate::{
22 EmptyEmailListError, Send, SendAuthType, SendId, SendView, SendViewType,
23 error::{ItemNotFoundError, SendParseError},
24 send_client::SendClient,
25};
26
27#[allow(missing_docs)]
28#[bitwarden_error(flat)]
29#[derive(Debug, Error)]
30pub enum EditSendError {
31 #[error(transparent)]
32 ItemNotFound(#[from] ItemNotFoundError),
33 #[error(transparent)]
34 Crypto(#[from] CryptoError),
35 #[error(transparent)]
36 Api(#[from] ApiError),
37 #[error(transparent)]
38 EmptyEmailList(#[from] EmptyEmailListError),
39 #[error(transparent)]
40 MissingField(#[from] MissingFieldError),
41 #[error(transparent)]
42 Repository(#[from] RepositoryError),
43 #[error(transparent)]
44 Uuid(#[from] uuid::Error),
45 #[error(transparent)]
46 SendParse(#[from] SendParseError),
47 #[error("Server returned Send with ID {returned:?} but expected {expected}")]
48 IdMismatch {
49 expected: Uuid,
50 returned: Option<Uuid>,
51 },
52}
53
54#[derive(Serialize, Deserialize, Debug)]
56#[serde(rename_all = "camelCase")]
57#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
58pub struct SendEditRequest {
59 pub name: String,
61 pub notes: Option<String>,
63
64 pub view_type: SendViewType,
66
67 pub max_access_count: Option<u32>,
69 pub disabled: bool,
71 pub hide_email: bool,
73
74 pub deletion_date: DateTime<Utc>,
76 pub expiration_date: Option<DateTime<Utc>>,
78
79 pub auth: SendAuthType,
81}
82
83#[derive(Debug)]
86struct SendEditRequestWithKey {
87 request: SendEditRequest,
88 send_key: String,
89}
90
91impl
92 CompositeEncryptable<
93 KeySlotIds,
94 SymmetricKeySlotId,
95 bitwarden_api_api::models::SendRequestModel,
96 > for SendEditRequestWithKey
97{
98 fn encrypt_composite(
99 &self,
100 ctx: &mut KeyStoreContext<KeySlotIds>,
101 key: SymmetricKeySlotId,
102 ) -> Result<bitwarden_api_api::models::SendRequestModel, CryptoError> {
103 let k = B64Url::try_from(self.send_key.as_str())
105 .map_err(|_| CryptoError::InvalidKey)?
106 .as_bytes()
107 .to_vec();
108
109 let send_key = Send::derive_shareable_key(ctx, &k)?;
110
111 let (send_type, file, text) = self
112 .request
113 .view_type
114 .clone()
115 .encrypt_composite(ctx, send_key)?;
116
117 let (password, emails) = self.request.auth.auth_data(&k);
118
119 Ok(bitwarden_api_api::models::SendRequestModel {
120 r#type: Some(send_type),
121 auth_type: Some(self.request.auth.auth_type().into()),
122 file_length: None,
123 name: Some(self.request.name.encrypt(ctx, send_key)?.to_string()),
124 notes: self
125 .request
126 .notes
127 .as_ref()
128 .map(|n| n.encrypt(ctx, send_key))
129 .transpose()?
130 .map(|e| e.to_string()),
131 key: OctetStreamBytes::from(k).encrypt(ctx, key)?.to_string(),
133 max_access_count: self.request.max_access_count.map(|c| c as i32),
134 expiration_date: self.request.expiration_date.map(|d| d.to_rfc3339()),
135 deletion_date: self.request.deletion_date.to_rfc3339(),
136 file,
137 text,
138 password,
139 emails,
140 disabled: self.request.disabled,
141 hide_email: Some(self.request.hide_email),
142 })
143 }
144}
145
146impl IdentifyKey<SymmetricKeySlotId> for SendEditRequestWithKey {
147 fn key_identifier(&self) -> SymmetricKeySlotId {
148 SymmetricKeySlotId::User
149 }
150}
151
152async fn edit_send<R: Repository<Send> + ?Sized>(
153 key_store: &KeyStore<KeySlotIds>,
154 api_client: &bitwarden_api_api::apis::ApiClient,
155 repository: &R,
156 send_id: SendId,
157 request: SendEditRequest,
158) -> Result<SendView, EditSendError> {
159 request.auth.validate()?;
160
161 let id = send_id.to_string();
162
163 let existing_send = repository.get(send_id).await?.ok_or(ItemNotFoundError)?;
165
166 let existing_send_view: SendView = key_store.decrypt(&existing_send)?;
168 let send_key = existing_send_view.key.ok_or(MissingFieldError("key"))?;
169
170 let request_with_key = SendEditRequestWithKey { request, send_key };
172
173 let send_request = key_store.encrypt(request_with_key)?;
174
175 let resp = api_client
176 .sends_api()
177 .put(&id, Some(send_request))
178 .await
179 .map_err(ApiError::from)?;
180
181 let send: Send = resp.try_into()?;
182
183 if send.id != Some(send_id) {
185 return Err(EditSendError::IdMismatch {
186 expected: send_id.into(),
187 returned: send.id.map(Into::into),
188 });
189 }
190
191 repository.set(send_id, send.clone()).await?;
192
193 Ok(key_store.decrypt(&send)?)
194}
195
196#[cfg_attr(feature = "wasm", wasm_bindgen)]
197impl SendClient {
198 pub async fn edit(
200 &self,
201 send_id: SendId,
202 request: SendEditRequest,
203 ) -> Result<SendView, EditSendError> {
204 let key_store = self.client.internal.get_key_store();
205 let config = self.client.internal.get_api_configurations();
206 let repository = self.get_repository()?;
207
208 edit_send(
209 key_store,
210 &config.api_client,
211 repository.as_ref(),
212 send_id,
213 request,
214 )
215 .await
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use bitwarden_api_api::{apis::ApiClient, models::SendResponseModel};
222 use bitwarden_core::key_management::SymmetricKeySlotId;
223 use bitwarden_crypto::SymmetricKeyAlgorithm;
224 use bitwarden_test::MemoryRepository;
225 use chrono::{DateTime, Utc};
226 use uuid::uuid;
227
228 use super::*;
229 use crate::{AuthType, SendTextView, SendType, SendViewType};
230
231 #[tokio::test]
232 async fn test_edit_send() {
233 let store: KeyStore<KeySlotIds> = KeyStore::default();
234 {
235 let mut ctx = store.context_mut();
236 let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
237 ctx.persist_symmetric_key(local_key_id, SymmetricKeySlotId::User)
238 .unwrap();
239 }
240
241 let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1");
242
243 let repository = MemoryRepository::<Send>::default();
245 let existing_send_view = SendView {
246 id: None, access_id: None,
248 name: "original".to_string(),
249 notes: Some("original notes".to_string()),
250 key: None, new_password: None,
252 has_password: false,
253 r#type: SendType::Text,
254 file: None,
255 text: Some(SendTextView {
256 text: Some("original text".to_string()),
257 hidden: false,
258 }),
259 max_access_count: None,
260 access_count: 0,
261 disabled: false,
262 hide_email: false,
263 revision_date: "2025-01-01T00:00:00Z".parse().unwrap(),
264 deletion_date: "2025-01-10T00:00:00Z".parse().unwrap(),
265 expiration_date: None,
266 emails: Vec::new(),
267 auth_type: AuthType::None,
268 };
269 let mut existing_send = store.encrypt(existing_send_view).unwrap();
270 existing_send.id = Some(crate::send::SendId::new(send_id)); repository
272 .set(SendId::new(send_id), existing_send)
273 .await
274 .unwrap();
275
276 let api_client = ApiClient::new_mocked(move |mock| {
277 mock.sends_api
278 .expect_put()
279 .returning(move |_id, model| {
280 let model = model.unwrap();
281 Ok(SendResponseModel {
282 id: Some(send_id),
283 name: model.name,
284 revision_date: Some("2025-01-02T00:00:00Z".to_string()),
285 object: Some("send".to_string()),
286 access_id: None,
287 r#type: model.r#type,
288 auth_type: model.auth_type,
289 notes: model.notes,
290 file: model.file,
291 text: model.text,
292 key: Some(model.key),
293 max_access_count: model.max_access_count,
294 access_count: Some(0),
295 password: model.password,
296 emails: model.emails,
297 disabled: Some(model.disabled),
298 expiration_date: model.expiration_date,
299 deletion_date: Some(model.deletion_date),
300 hide_email: model.hide_email,
301 })
302 })
303 .once();
304 });
305
306 let result = edit_send(
307 &store,
308 &api_client,
309 &repository,
310 SendId::new(send_id),
311 SendEditRequest {
312 name: "updated".to_string(),
313 notes: Some("updated notes".to_string()),
314 view_type: SendViewType::Text(SendTextView {
315 text: Some("updated text".to_string()),
316 hidden: false,
317 }),
318 max_access_count: None,
319 disabled: false,
320 hide_email: false,
321 deletion_date: "2025-01-10T00:00:00Z".parse().unwrap(),
322 expiration_date: None,
323 auth: SendAuthType::None,
324 },
325 )
326 .await
327 .unwrap();
328
329 assert_eq!(result.id, Some(crate::send::SendId::new(send_id)));
331 assert_eq!(result.name, "updated");
332 assert_eq!(result.notes, Some("updated notes".to_string()));
333 assert!(result.key.is_some(), "Expected a key");
334 assert_eq!(
335 result.revision_date,
336 "2025-01-02T00:00:00Z".parse::<DateTime<Utc>>().unwrap()
337 );
338
339 let stored = repository.get(SendId::new(send_id)).await.unwrap().unwrap();
341 assert_eq!(
342 store
343 .decrypt::<SymmetricKeySlotId, Send, SendView>(&stored)
344 .unwrap()
345 .name,
346 "updated"
347 );
348 }
349
350 #[tokio::test]
351 async fn test_edit_send_not_found() {
352 let store: KeyStore<KeySlotIds> = KeyStore::default();
353 {
354 let mut ctx = store.context_mut();
355 let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
356 ctx.persist_symmetric_key(local_key_id, SymmetricKeySlotId::User)
357 .unwrap();
358 }
359
360 let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1");
361 let repository = MemoryRepository::<Send>::default();
362 let api_client = ApiClient::new_mocked(move |_mock| {});
363
364 let result = edit_send(
365 &store,
366 &api_client,
367 &repository,
368 SendId::new(send_id),
369 SendEditRequest {
370 name: "test".to_string(),
371 notes: None,
372 view_type: SendViewType::Text(SendTextView {
373 text: Some("test".to_string()),
374 hidden: false,
375 }),
376 max_access_count: None,
377 disabled: false,
378 hide_email: false,
379 deletion_date: "2025-01-10T00:00:00Z".parse().unwrap(),
380 expiration_date: None,
381 auth: SendAuthType::None,
382 },
383 )
384 .await;
385
386 assert!(result.is_err());
387 assert!(matches!(
388 result.unwrap_err(),
389 EditSendError::ItemNotFound(_)
390 ));
391 }
392
393 #[tokio::test]
394 async fn test_edit_send_http_error() {
395 let store: KeyStore<KeySlotIds> = KeyStore::default();
396 {
397 let mut ctx = store.context_mut();
398 let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
399 ctx.persist_symmetric_key(local_key_id, SymmetricKeySlotId::User)
400 .unwrap();
401 }
402
403 let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1");
404
405 let repository = MemoryRepository::<Send>::default();
407 let existing_send_view = SendView {
408 id: None, access_id: None,
410 name: "original".to_string(),
411 notes: Some("original notes".to_string()),
412 key: None, new_password: None,
414 has_password: false,
415 r#type: SendType::Text,
416 file: None,
417 text: Some(SendTextView {
418 text: Some("original text".to_string()),
419 hidden: false,
420 }),
421 max_access_count: None,
422 access_count: 0,
423 disabled: false,
424 hide_email: false,
425 revision_date: "2025-01-01T00:00:00Z".parse().unwrap(),
426 deletion_date: "2025-01-10T00:00:00Z".parse().unwrap(),
427 expiration_date: None,
428 emails: Vec::new(),
429 auth_type: AuthType::None,
430 };
431 let mut existing_send = store.encrypt(existing_send_view).unwrap();
432 existing_send.id = Some(crate::send::SendId::new(send_id)); repository
434 .set(SendId::new(send_id), existing_send)
435 .await
436 .unwrap();
437
438 let api_client = ApiClient::new_mocked(move |mock| {
439 mock.sends_api.expect_put().returning(move |_id, _model| {
440 Err(bitwarden_api_api::apis::Error::Io(std::io::Error::other(
441 "Simulated error",
442 )))
443 });
444 });
445
446 let result = edit_send(
447 &store,
448 &api_client,
449 &repository,
450 SendId::new(send_id),
451 SendEditRequest {
452 name: "test".to_string(),
453 notes: None,
454 view_type: SendViewType::Text(SendTextView {
455 text: Some("test".to_string()),
456 hidden: false,
457 }),
458 max_access_count: None,
459 disabled: false,
460 hide_email: false,
461 deletion_date: "2025-01-10T00:00:00Z".parse().unwrap(),
462 expiration_date: None,
463 auth: SendAuthType::None,
464 },
465 )
466 .await;
467
468 assert!(result.is_err());
469 assert!(matches!(result.unwrap_err(), EditSendError::Api(_)));
470 }
471}