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