Skip to main content

bitwarden_send/
delete.rs

1use bitwarden_api_api::ResponseContent;
2use bitwarden_core::ApiError;
3use bitwarden_error::bitwarden_error;
4use bitwarden_state::{
5    Setting, register_setting_key,
6    repository::{Repository, RepositoryError},
7};
8use thiserror::Error;
9#[cfg(feature = "wasm")]
10use wasm_bindgen::prelude::*;
11
12use crate::{Send, SendId, send_client::SendClient};
13
14// Durable queue of Sends whose server-side delete failed, retried later to avoid orphaned records.
15register_setting_key!(const PENDING_SEND_DELETIONS: Vec<SendId> = "pending_send_deletions");
16
17#[allow(missing_docs)]
18#[bitwarden_error(flat)]
19#[derive(Debug, Error)]
20pub enum DeleteSendError {
21    #[error(transparent)]
22    Api(#[from] ApiError),
23    #[error(transparent)]
24    Repository(#[from] RepositoryError),
25    #[error(transparent)]
26    State(#[from] bitwarden_state::SettingsError),
27}
28
29async fn delete_send<R: Repository<Send> + ?Sized>(
30    api_client: &bitwarden_api_api::apis::ApiClient,
31    repository: &R,
32    pending_deletions: &Setting<Vec<SendId>>,
33    send_id: SendId,
34) -> Result<(), DeleteSendError> {
35    if let Err(e) = api_client.sends_api().delete(&send_id.to_string()).await {
36        // Often an offline failure; queue for durable retry so the Send isn't orphaned in the list.
37        queue_pending_deletion(pending_deletions, send_id).await;
38        return Err(e.into());
39    }
40
41    repository.remove(send_id).await?;
42
43    Ok(())
44}
45
46/// Add `send_id` to the pending-deletion queue if not already present. Setting errors are
47/// swallowed so the caller still receives the original delete error unchanged.
48async fn queue_pending_deletion(pending_deletions: &Setting<Vec<SendId>>, send_id: SendId) {
49    let mut queue = match pending_deletions.get().await {
50        Ok(queue) => queue.unwrap_or_default(),
51        Err(e) => {
52            tracing::warn!("Failed to read pending send deletions: {e}");
53            return;
54        }
55    };
56
57    if queue.contains(&send_id) {
58        return;
59    }
60
61    queue.push(send_id);
62    if let Err(e) = pending_deletions.update(queue).await {
63        tracing::warn!("Failed to queue send for pending deletion: {e}");
64    }
65}
66
67async fn retry_pending_deletions<R: Repository<Send> + ?Sized>(
68    api_client: &bitwarden_api_api::apis::ApiClient,
69    repository: &R,
70    pending_deletions: &Setting<Vec<SendId>>,
71) -> Result<(), DeleteSendError> {
72    let queue = pending_deletions.get().await?.unwrap_or_default();
73    if queue.is_empty() {
74        return Ok(());
75    }
76
77    let mut resolved = Vec::new();
78    for send_id in queue {
79        match api_client.sends_api().delete(&send_id.to_string()).await {
80            // A 404 means it's already gone server-side, so treat it the same as a success.
81            Ok(())
82            | Err(ApiError::Response(ResponseContent {
83                status: reqwest::StatusCode::NOT_FOUND,
84                ..
85            })) => {
86                // Repo may already be clean; ignore removal errors.
87                let _ = repository.remove(send_id).await;
88                resolved.push(send_id);
89            }
90            Err(_) => {}
91        }
92    }
93
94    if resolved.is_empty() {
95        return Ok(());
96    }
97
98    // Re-read before writing so a concurrent queue_pending_deletion isn't clobbered.
99    let mut current = pending_deletions.get().await?.unwrap_or_default();
100    current.retain(|id| !resolved.contains(id));
101    pending_deletions.update(current).await?;
102
103    Ok(())
104}
105
106#[cfg_attr(feature = "wasm", wasm_bindgen)]
107impl SendClient {
108    /// Delete a [Send] from the server and remove it from local state.
109    pub async fn delete(&self, send_id: SendId) -> Result<(), DeleteSendError> {
110        let config = self.client.internal.get_api_configurations();
111        let repository = self.get_repository()?;
112        let pending_deletions = self
113            .client
114            .platform()
115            .state()
116            .setting(PENDING_SEND_DELETIONS)?;
117
118        delete_send(
119            &config.api_client,
120            repository.as_ref(),
121            &pending_deletions,
122            send_id,
123        )
124        .await
125    }
126
127    /// Retry any [Send] deletions that previously failed (e.g. while offline), removing each from
128    /// the pending queue once the server confirms it is gone.
129    pub async fn retry_pending_deletions(&self) -> Result<(), DeleteSendError> {
130        let config = self.client.internal.get_api_configurations();
131        let repository = self.get_repository()?;
132        let pending_deletions = self
133            .client
134            .platform()
135            .state()
136            .setting(PENDING_SEND_DELETIONS)?;
137
138        retry_pending_deletions(&config.api_client, repository.as_ref(), &pending_deletions).await
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use std::sync::Arc;
145
146    use bitwarden_api_api::apis::ApiClient;
147    use bitwarden_core::key_management::{KeySlotIds, SymmetricKeySlotId};
148    use bitwarden_crypto::{KeyStore, SymmetricKeyAlgorithm};
149    use bitwarden_test::{MemoryRepository, MemorySetting};
150    use uuid::uuid;
151
152    use super::*;
153    use crate::{AuthType, Send, SendId, SendTextView, SendType, SendView};
154
155    fn make_pending_setting() -> Setting<Vec<SendId>> {
156        MemorySetting::create()
157    }
158
159    async fn make_store_with_send(
160        send_id: uuid::Uuid,
161    ) -> (KeyStore<KeySlotIds>, MemoryRepository<Send>) {
162        let store: KeyStore<KeySlotIds> = KeyStore::default();
163        {
164            let mut ctx = store.context_mut();
165            let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
166            ctx.persist_symmetric_key(local_key_id, SymmetricKeySlotId::User)
167                .unwrap();
168        }
169
170        let repository = MemoryRepository::<Send>::default();
171        let send_view = SendView {
172            id: None,
173            access_id: None,
174            name: "Test Send".to_string(),
175            notes: None,
176            key: None,
177            new_password: None,
178            has_password: false,
179            r#type: SendType::Text,
180            file: None,
181            text: Some(SendTextView {
182                text: Some("Secret text".to_string()),
183                hidden: false,
184            }),
185            data: None,
186            max_access_count: None,
187            access_count: 0,
188            disabled: false,
189            hide_email: false,
190            revision_date: "2025-01-01T00:00:00Z".parse().unwrap(),
191            deletion_date: "2025-01-10T00:00:00Z".parse().unwrap(),
192            expiration_date: None,
193            emails: Vec::new(),
194            auth_type: AuthType::None,
195        };
196        let mut send = store.encrypt(send_view).unwrap();
197        send.id = Some(SendId::new(send_id));
198        repository.set(SendId::new(send_id), send).await.unwrap();
199
200        (store, repository)
201    }
202
203    #[tokio::test]
204    async fn test_delete_send() {
205        let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1");
206        let (_store, repository) = make_store_with_send(send_id).await;
207        let pending = make_pending_setting();
208
209        let api_client = ApiClient::new_mocked(move |mock| {
210            mock.sends_api
211                .expect_delete()
212                .returning(move |_id| Ok(()))
213                .once();
214        });
215
216        let result = delete_send(&api_client, &repository, &pending, SendId::new(send_id)).await;
217
218        assert!(result.is_ok());
219        assert!(
220            repository
221                .get(SendId::new(send_id))
222                .await
223                .unwrap()
224                .is_none()
225        );
226    }
227
228    #[tokio::test]
229    async fn test_delete_send_http_error() {
230        let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1");
231        let (_store, repository) = make_store_with_send(send_id).await;
232        let pending = make_pending_setting();
233
234        let api_client = ApiClient::new_mocked(move |mock| {
235            mock.sends_api
236                .expect_delete()
237                .returning(move |_id| {
238                    Err(bitwarden_api_api::ApiError::Io(std::io::Error::other(
239                        "Simulated error",
240                    )))
241                })
242                .once();
243        });
244
245        let result = delete_send(&api_client, &repository, &pending, SendId::new(send_id)).await;
246
247        assert!(result.is_err());
248        assert!(matches!(result.unwrap_err(), DeleteSendError::Api(_)));
249        // Send should still be in the repository since API call failed
250        assert!(
251            repository
252                .get(SendId::new(send_id))
253                .await
254                .unwrap()
255                .is_some()
256        );
257    }
258
259    #[tokio::test]
260    async fn test_delete_send_failure_queues_pending_deletion() {
261        let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1");
262        let (_store, repository) = make_store_with_send(send_id).await;
263        let pending = make_pending_setting();
264
265        let api_client = ApiClient::new_mocked(move |mock| {
266            mock.sends_api.expect_delete().returning(move |_id| {
267                Err(bitwarden_api_api::ApiError::Io(std::io::Error::other(
268                    "Simulated error",
269                )))
270            });
271        });
272
273        let result = delete_send(&api_client, &repository, &pending, SendId::new(send_id)).await;
274        assert!(result.is_err());
275
276        let queue = pending.get().await.unwrap().unwrap_or_default();
277        assert_eq!(queue, vec![SendId::new(send_id)]);
278    }
279
280    #[tokio::test]
281    async fn test_delete_send_failure_does_not_duplicate_queue_entry() {
282        let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1");
283        let (_store, repository) = make_store_with_send(send_id).await;
284        let pending = make_pending_setting();
285
286        let api_client = ApiClient::new_mocked(move |mock| {
287            mock.sends_api.expect_delete().returning(move |_id| {
288                Err(bitwarden_api_api::ApiError::Io(std::io::Error::other(
289                    "Simulated error",
290                )))
291            });
292        });
293
294        assert!(
295            delete_send(&api_client, &repository, &pending, SendId::new(send_id))
296                .await
297                .is_err()
298        );
299        assert!(
300            delete_send(&api_client, &repository, &pending, SendId::new(send_id))
301                .await
302                .is_err()
303        );
304
305        let queue = pending.get().await.unwrap().unwrap_or_default();
306        assert_eq!(queue, vec![SendId::new(send_id)]);
307    }
308
309    #[tokio::test]
310    async fn test_retry_pending_deletions_success_removes_from_queue_and_repository() {
311        let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1");
312        let (_store, repository) = make_store_with_send(send_id).await;
313        let pending = make_pending_setting();
314        pending.update(vec![SendId::new(send_id)]).await.unwrap();
315
316        let api_client = ApiClient::new_mocked(move |mock| {
317            mock.sends_api
318                .expect_delete()
319                .returning(move |_id| Ok(()))
320                .once();
321        });
322
323        retry_pending_deletions(&api_client, &repository, &pending)
324            .await
325            .unwrap();
326
327        let queue = pending.get().await.unwrap().unwrap_or_default();
328        assert!(queue.is_empty());
329        assert!(
330            repository
331                .get(SendId::new(send_id))
332                .await
333                .unwrap()
334                .is_none()
335        );
336    }
337
338    #[tokio::test]
339    async fn test_retry_pending_deletions_not_found_is_resolved() {
340        let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1");
341        let (_store, repository) = make_store_with_send(send_id).await;
342        let pending = make_pending_setting();
343        pending.update(vec![SendId::new(send_id)]).await.unwrap();
344
345        let api_client = ApiClient::new_mocked(move |mock| {
346            mock.sends_api
347                .expect_delete()
348                .returning(move |_id| {
349                    Err(bitwarden_api_api::ApiError::Response(ResponseContent {
350                        status: reqwest::StatusCode::NOT_FOUND,
351                        message: "not found".to_string(),
352                    }))
353                })
354                .once();
355        });
356
357        retry_pending_deletions(&api_client, &repository, &pending)
358            .await
359            .unwrap();
360
361        let queue = pending.get().await.unwrap().unwrap_or_default();
362        assert!(queue.is_empty());
363        assert!(
364            repository
365                .get(SendId::new(send_id))
366                .await
367                .unwrap()
368                .is_none()
369        );
370    }
371
372    #[tokio::test]
373    async fn test_retry_pending_deletions_other_error_stays_queued() {
374        let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1");
375        let (_store, repository) = make_store_with_send(send_id).await;
376        let pending = make_pending_setting();
377        pending.update(vec![SendId::new(send_id)]).await.unwrap();
378
379        let api_client = ApiClient::new_mocked(move |mock| {
380            mock.sends_api
381                .expect_delete()
382                .returning(move |_id| {
383                    Err(bitwarden_api_api::ApiError::Io(std::io::Error::other(
384                        "Simulated error",
385                    )))
386                })
387                .once();
388        });
389
390        retry_pending_deletions(&api_client, &repository, &pending)
391            .await
392            .unwrap();
393
394        let queue = pending.get().await.unwrap().unwrap_or_default();
395        assert_eq!(queue, vec![SendId::new(send_id)]);
396        // Still present server-side (from the client's view), so it must remain locally too.
397        assert!(
398            repository
399                .get(SendId::new(send_id))
400                .await
401                .unwrap()
402                .is_some()
403        );
404    }
405
406    #[tokio::test]
407    async fn test_retry_pending_deletions_mixed_batch() {
408        let resolved_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1");
409        let failing_id = uuid!("3b8e0f2a-1c4d-4e6f-9a7b-2d5c8e1f0a3b");
410        let (store, repository) = make_store_with_send(resolved_id).await;
411        // Add a second send sharing the same store so both live in one repository.
412        let mut send = store
413            .encrypt(SendView {
414                id: None,
415                access_id: None,
416                name: "Test Send".to_string(),
417                notes: None,
418                key: None,
419                new_password: None,
420                has_password: false,
421                r#type: SendType::Text,
422                file: None,
423                text: Some(SendTextView {
424                    text: Some("Secret text".to_string()),
425                    hidden: false,
426                }),
427                data: None,
428                max_access_count: None,
429                access_count: 0,
430                disabled: false,
431                hide_email: false,
432                revision_date: "2025-01-01T00:00:00Z".parse().unwrap(),
433                deletion_date: "2025-01-10T00:00:00Z".parse().unwrap(),
434                expiration_date: None,
435                emails: Vec::new(),
436                auth_type: AuthType::None,
437            })
438            .unwrap();
439        send.id = Some(SendId::new(failing_id));
440        repository.set(SendId::new(failing_id), send).await.unwrap();
441
442        let pending = make_pending_setting();
443        pending
444            .update(vec![SendId::new(resolved_id), SendId::new(failing_id)])
445            .await
446            .unwrap();
447
448        let resolved_id_str = resolved_id.to_string();
449        let api_client = ApiClient::new_mocked(move |mock| {
450            mock.sends_api.expect_delete().returning(move |id| {
451                if id == resolved_id_str {
452                    Ok(())
453                } else {
454                    Err(bitwarden_api_api::ApiError::Io(std::io::Error::other(
455                        "Simulated error",
456                    )))
457                }
458            });
459        });
460
461        retry_pending_deletions(&api_client, &repository, &pending)
462            .await
463            .unwrap();
464
465        let queue = pending.get().await.unwrap().unwrap_or_default();
466        assert_eq!(queue, vec![SendId::new(failing_id)]);
467        assert!(
468            repository
469                .get(SendId::new(resolved_id))
470                .await
471                .unwrap()
472                .is_none()
473        );
474        assert!(
475            repository
476                .get(SendId::new(failing_id))
477                .await
478                .unwrap()
479                .is_some()
480        );
481    }
482
483    #[tokio::test]
484    async fn test_retry_pending_deletions_empty_queue_is_noop() {
485        let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1");
486        let (_store, repository) = make_store_with_send(send_id).await;
487        let pending = make_pending_setting();
488
489        // No delete calls expected for an empty queue.
490        let api_client = ApiClient::new_mocked(|_mock| {});
491
492        retry_pending_deletions(&api_client, &repository, &pending)
493            .await
494            .unwrap();
495    }
496
497    #[tokio::test]
498    async fn test_delete_send_failure_then_retry_success_resolves_orphan() {
499        let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1");
500        let (_store, repository) = make_store_with_send(send_id).await;
501        let pending = make_pending_setting();
502
503        let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
504        let api_client = ApiClient::new_mocked(move |mock| {
505            let attempts = attempts.clone();
506            mock.sends_api.expect_delete().returning(move |_id| {
507                if attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0 {
508                    Err(bitwarden_api_api::ApiError::Io(std::io::Error::other(
509                        "offline",
510                    )))
511                } else {
512                    Ok(())
513                }
514            });
515        });
516
517        // Create-then-upload-fails rollback: the delete itself fails, e.g. still offline.
518        let result = delete_send(&api_client, &repository, &pending, SendId::new(send_id)).await;
519        assert!(result.is_err());
520        assert!(
521            repository
522                .get(SendId::new(send_id))
523                .await
524                .unwrap()
525                .is_some(),
526            "orphaned Send stays visible until a retry resolves it"
527        );
528
529        // Next sync retries once connectivity is back.
530        retry_pending_deletions(&api_client, &repository, &pending)
531            .await
532            .unwrap();
533
534        assert!(
535            repository
536                .get(SendId::new(send_id))
537                .await
538                .unwrap()
539                .is_none()
540        );
541        assert!(pending.get().await.unwrap().unwrap_or_default().is_empty());
542    }
543}