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_state::SettingItem;
150    use bitwarden_test::MemoryRepository;
151    use uuid::uuid;
152
153    use super::*;
154    use crate::{AuthType, Send, SendId, SendTextView, SendType, SendView};
155
156    fn make_pending_setting() -> Setting<Vec<SendId>> {
157        let repository: Arc<dyn Repository<SettingItem>> =
158            Arc::new(MemoryRepository::<SettingItem>::default());
159        Setting::new(repository, PENDING_SEND_DELETIONS)
160    }
161
162    async fn make_store_with_send(
163        send_id: uuid::Uuid,
164    ) -> (KeyStore<KeySlotIds>, MemoryRepository<Send>) {
165        let store: KeyStore<KeySlotIds> = KeyStore::default();
166        {
167            let mut ctx = store.context_mut();
168            let local_key_id = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
169            ctx.persist_symmetric_key(local_key_id, SymmetricKeySlotId::User)
170                .unwrap();
171        }
172
173        let repository = MemoryRepository::<Send>::default();
174        let send_view = SendView {
175            id: None,
176            access_id: None,
177            name: "Test Send".to_string(),
178            notes: None,
179            key: None,
180            new_password: None,
181            has_password: false,
182            r#type: SendType::Text,
183            file: None,
184            text: Some(SendTextView {
185                text: Some("Secret text".to_string()),
186                hidden: false,
187            }),
188            data: None,
189            max_access_count: None,
190            access_count: 0,
191            disabled: false,
192            hide_email: false,
193            revision_date: "2025-01-01T00:00:00Z".parse().unwrap(),
194            deletion_date: "2025-01-10T00:00:00Z".parse().unwrap(),
195            expiration_date: None,
196            emails: Vec::new(),
197            auth_type: AuthType::None,
198        };
199        let mut send = store.encrypt(send_view).unwrap();
200        send.id = Some(SendId::new(send_id));
201        repository.set(SendId::new(send_id), send).await.unwrap();
202
203        (store, repository)
204    }
205
206    #[tokio::test]
207    async fn test_delete_send() {
208        let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1");
209        let (_store, repository) = make_store_with_send(send_id).await;
210        let pending = make_pending_setting();
211
212        let api_client = ApiClient::new_mocked(move |mock| {
213            mock.sends_api
214                .expect_delete()
215                .returning(move |_id| Ok(()))
216                .once();
217        });
218
219        let result = delete_send(&api_client, &repository, &pending, SendId::new(send_id)).await;
220
221        assert!(result.is_ok());
222        assert!(
223            repository
224                .get(SendId::new(send_id))
225                .await
226                .unwrap()
227                .is_none()
228        );
229    }
230
231    #[tokio::test]
232    async fn test_delete_send_http_error() {
233        let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1");
234        let (_store, repository) = make_store_with_send(send_id).await;
235        let pending = make_pending_setting();
236
237        let api_client = ApiClient::new_mocked(move |mock| {
238            mock.sends_api
239                .expect_delete()
240                .returning(move |_id| {
241                    Err(bitwarden_api_api::ApiError::Io(std::io::Error::other(
242                        "Simulated error",
243                    )))
244                })
245                .once();
246        });
247
248        let result = delete_send(&api_client, &repository, &pending, SendId::new(send_id)).await;
249
250        assert!(result.is_err());
251        assert!(matches!(result.unwrap_err(), DeleteSendError::Api(_)));
252        // Send should still be in the repository since API call failed
253        assert!(
254            repository
255                .get(SendId::new(send_id))
256                .await
257                .unwrap()
258                .is_some()
259        );
260    }
261
262    #[tokio::test]
263    async fn test_delete_send_failure_queues_pending_deletion() {
264        let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1");
265        let (_store, repository) = make_store_with_send(send_id).await;
266        let pending = make_pending_setting();
267
268        let api_client = ApiClient::new_mocked(move |mock| {
269            mock.sends_api.expect_delete().returning(move |_id| {
270                Err(bitwarden_api_api::ApiError::Io(std::io::Error::other(
271                    "Simulated error",
272                )))
273            });
274        });
275
276        let result = delete_send(&api_client, &repository, &pending, SendId::new(send_id)).await;
277        assert!(result.is_err());
278
279        let queue = pending.get().await.unwrap().unwrap_or_default();
280        assert_eq!(queue, vec![SendId::new(send_id)]);
281    }
282
283    #[tokio::test]
284    async fn test_delete_send_failure_does_not_duplicate_queue_entry() {
285        let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1");
286        let (_store, repository) = make_store_with_send(send_id).await;
287        let pending = make_pending_setting();
288
289        let api_client = ApiClient::new_mocked(move |mock| {
290            mock.sends_api.expect_delete().returning(move |_id| {
291                Err(bitwarden_api_api::ApiError::Io(std::io::Error::other(
292                    "Simulated error",
293                )))
294            });
295        });
296
297        assert!(
298            delete_send(&api_client, &repository, &pending, SendId::new(send_id))
299                .await
300                .is_err()
301        );
302        assert!(
303            delete_send(&api_client, &repository, &pending, SendId::new(send_id))
304                .await
305                .is_err()
306        );
307
308        let queue = pending.get().await.unwrap().unwrap_or_default();
309        assert_eq!(queue, vec![SendId::new(send_id)]);
310    }
311
312    #[tokio::test]
313    async fn test_retry_pending_deletions_success_removes_from_queue_and_repository() {
314        let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1");
315        let (_store, repository) = make_store_with_send(send_id).await;
316        let pending = make_pending_setting();
317        pending.update(vec![SendId::new(send_id)]).await.unwrap();
318
319        let api_client = ApiClient::new_mocked(move |mock| {
320            mock.sends_api
321                .expect_delete()
322                .returning(move |_id| Ok(()))
323                .once();
324        });
325
326        retry_pending_deletions(&api_client, &repository, &pending)
327            .await
328            .unwrap();
329
330        let queue = pending.get().await.unwrap().unwrap_or_default();
331        assert!(queue.is_empty());
332        assert!(
333            repository
334                .get(SendId::new(send_id))
335                .await
336                .unwrap()
337                .is_none()
338        );
339    }
340
341    #[tokio::test]
342    async fn test_retry_pending_deletions_not_found_is_resolved() {
343        let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1");
344        let (_store, repository) = make_store_with_send(send_id).await;
345        let pending = make_pending_setting();
346        pending.update(vec![SendId::new(send_id)]).await.unwrap();
347
348        let api_client = ApiClient::new_mocked(move |mock| {
349            mock.sends_api
350                .expect_delete()
351                .returning(move |_id| {
352                    Err(bitwarden_api_api::ApiError::Response(ResponseContent {
353                        status: reqwest::StatusCode::NOT_FOUND,
354                        message: "not found".to_string(),
355                    }))
356                })
357                .once();
358        });
359
360        retry_pending_deletions(&api_client, &repository, &pending)
361            .await
362            .unwrap();
363
364        let queue = pending.get().await.unwrap().unwrap_or_default();
365        assert!(queue.is_empty());
366        assert!(
367            repository
368                .get(SendId::new(send_id))
369                .await
370                .unwrap()
371                .is_none()
372        );
373    }
374
375    #[tokio::test]
376    async fn test_retry_pending_deletions_other_error_stays_queued() {
377        let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1");
378        let (_store, repository) = make_store_with_send(send_id).await;
379        let pending = make_pending_setting();
380        pending.update(vec![SendId::new(send_id)]).await.unwrap();
381
382        let api_client = ApiClient::new_mocked(move |mock| {
383            mock.sends_api
384                .expect_delete()
385                .returning(move |_id| {
386                    Err(bitwarden_api_api::ApiError::Io(std::io::Error::other(
387                        "Simulated error",
388                    )))
389                })
390                .once();
391        });
392
393        retry_pending_deletions(&api_client, &repository, &pending)
394            .await
395            .unwrap();
396
397        let queue = pending.get().await.unwrap().unwrap_or_default();
398        assert_eq!(queue, vec![SendId::new(send_id)]);
399        // Still present server-side (from the client's view), so it must remain locally too.
400        assert!(
401            repository
402                .get(SendId::new(send_id))
403                .await
404                .unwrap()
405                .is_some()
406        );
407    }
408
409    #[tokio::test]
410    async fn test_retry_pending_deletions_mixed_batch() {
411        let resolved_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1");
412        let failing_id = uuid!("3b8e0f2a-1c4d-4e6f-9a7b-2d5c8e1f0a3b");
413        let (store, repository) = make_store_with_send(resolved_id).await;
414        // Add a second send sharing the same store so both live in one repository.
415        let mut send = store
416            .encrypt(SendView {
417                id: None,
418                access_id: None,
419                name: "Test Send".to_string(),
420                notes: None,
421                key: None,
422                new_password: None,
423                has_password: false,
424                r#type: SendType::Text,
425                file: None,
426                text: Some(SendTextView {
427                    text: Some("Secret text".to_string()),
428                    hidden: false,
429                }),
430                data: None,
431                max_access_count: None,
432                access_count: 0,
433                disabled: false,
434                hide_email: false,
435                revision_date: "2025-01-01T00:00:00Z".parse().unwrap(),
436                deletion_date: "2025-01-10T00:00:00Z".parse().unwrap(),
437                expiration_date: None,
438                emails: Vec::new(),
439                auth_type: AuthType::None,
440            })
441            .unwrap();
442        send.id = Some(SendId::new(failing_id));
443        repository.set(SendId::new(failing_id), send).await.unwrap();
444
445        let pending = make_pending_setting();
446        pending
447            .update(vec![SendId::new(resolved_id), SendId::new(failing_id)])
448            .await
449            .unwrap();
450
451        let resolved_id_str = resolved_id.to_string();
452        let api_client = ApiClient::new_mocked(move |mock| {
453            mock.sends_api.expect_delete().returning(move |id| {
454                if id == resolved_id_str {
455                    Ok(())
456                } else {
457                    Err(bitwarden_api_api::ApiError::Io(std::io::Error::other(
458                        "Simulated error",
459                    )))
460                }
461            });
462        });
463
464        retry_pending_deletions(&api_client, &repository, &pending)
465            .await
466            .unwrap();
467
468        let queue = pending.get().await.unwrap().unwrap_or_default();
469        assert_eq!(queue, vec![SendId::new(failing_id)]);
470        assert!(
471            repository
472                .get(SendId::new(resolved_id))
473                .await
474                .unwrap()
475                .is_none()
476        );
477        assert!(
478            repository
479                .get(SendId::new(failing_id))
480                .await
481                .unwrap()
482                .is_some()
483        );
484    }
485
486    #[tokio::test]
487    async fn test_retry_pending_deletions_empty_queue_is_noop() {
488        let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1");
489        let (_store, repository) = make_store_with_send(send_id).await;
490        let pending = make_pending_setting();
491
492        // No delete calls expected for an empty queue.
493        let api_client = ApiClient::new_mocked(|_mock| {});
494
495        retry_pending_deletions(&api_client, &repository, &pending)
496            .await
497            .unwrap();
498    }
499
500    #[tokio::test]
501    async fn test_delete_send_failure_then_retry_success_resolves_orphan() {
502        let send_id = uuid!("25afb11c-9c95-4db5-8bac-c21cb204a3f1");
503        let (_store, repository) = make_store_with_send(send_id).await;
504        let pending = make_pending_setting();
505
506        let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
507        let api_client = ApiClient::new_mocked(move |mock| {
508            let attempts = attempts.clone();
509            mock.sends_api.expect_delete().returning(move |_id| {
510                if attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0 {
511                    Err(bitwarden_api_api::ApiError::Io(std::io::Error::other(
512                        "offline",
513                    )))
514                } else {
515                    Ok(())
516                }
517            });
518        });
519
520        // Create-then-upload-fails rollback: the delete itself fails, e.g. still offline.
521        let result = delete_send(&api_client, &repository, &pending, SendId::new(send_id)).await;
522        assert!(result.is_err());
523        assert!(
524            repository
525                .get(SendId::new(send_id))
526                .await
527                .unwrap()
528                .is_some(),
529            "orphaned Send stays visible until a retry resolves it"
530        );
531
532        // Next sync retries once connectivity is back.
533        retry_pending_deletions(&api_client, &repository, &pending)
534            .await
535            .unwrap();
536
537        assert!(
538            repository
539                .get(SendId::new(send_id))
540                .await
541                .unwrap()
542                .is_none()
543        );
544        assert!(pending.get().await.unwrap().unwrap_or_default().is_empty());
545    }
546}