Skip to main content

xmtp_mls/
attachments.rs

1//! Pending remote attachments owned by a client.
2
3#[cfg(all(test, not(target_arch = "wasm32")))]
4use std::sync::atomic::AtomicBool;
5#[cfg(test)]
6use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
7use std::{
8    collections::{HashMap, HashSet},
9    path::PathBuf,
10    sync::{Arc, LazyLock, Weak},
11    time::Duration,
12};
13
14use futures::StreamExt as _;
15use parking_lot::Mutex;
16use prost::Message as _;
17use sha2::{Digest as _, Sha256};
18use tokio::sync::{Mutex as AsyncMutex, OnceCell, OwnedMutexGuard, watch};
19use tokio_util::sync::CancellationToken;
20use xmtp_attachments::{
21    AttachmentDecoder, AttachmentError, AttachmentFailureCause as Cause, AttachmentOptions,
22    DownloadSink as _, GcmDecryptor, GcmEncryptor, KeyMaterial, LocalStore, StoreMoveError,
23    StoreWriter, Transfer, UploadRequest, attachment_key, ciphertext_len, download_cap,
24    encoded_prefix, plaintext_rel_path, remote_attachment, retained_fields_fit, staged_path,
25    temporary_path,
26};
27use xmtp_common::{RetryableError as _, time::now_ns};
28use xmtp_content_types::remote_attachment::RemoteAttachment;
29use xmtp_db::{
30    attachments::{PendingAttachmentOutcome, StoredPendingAttachment},
31    prelude::*,
32};
33
34impl crate::worker::NeedsDbReconnect for AttachmentClientError {
35    fn needs_db_reconnect(&self) -> bool {
36        false
37    }
38}
39use xmtp_events::{AttachmentFailed, AttachmentRef, ClientEvent, EventWriter as _};
40use xmtp_proto::{api::grpc_status, backend_v1::CreateUploadRequest};
41
42use crate::context::CoreContext;
43
44/// The attachments feature slot: the client's attachment runtime.
45pub trait AttachmentFeatures {
46    fn attachments(&self) -> &Arc<AttachmentRuntime>;
47}
48
49/// A context the attachment code runs on: core and the attachment runtime.
50pub trait AttachmentContext: CoreContext<Features: AttachmentFeatures> {
51    fn attachment_runtime(&self) -> &Arc<AttachmentRuntime> {
52        self.features().attachments()
53    }
54}
55
56impl<T: CoreContext<Features: AttachmentFeatures>> AttachmentContext for T {}
57
58const DEFAULT_MAX_PENDING_AGE: Duration = Duration::from_secs(86_400);
59const RECONCILE_AGE: Duration = Duration::from_secs(3_600);
60const CHUNK: usize = 64 * 1024;
61const LEASE_DURATION: Duration = Duration::from_secs(120);
62const LEASE_RENEW: Duration = Duration::from_secs(30);
63const LEASE_POLL: Duration = Duration::from_secs(1);
64#[cfg(test)]
65static FAIL_NEXT_RECONCILES: AtomicUsize = AtomicUsize::new(0);
66static PUBLICATION_LOCKS: LazyLock<Mutex<HashMap<PathBuf, Weak<AsyncMutex<()>>>>> =
67    LazyLock::new(|| Mutex::new(HashMap::new()));
68#[cfg(all(target_arch = "wasm32", feature = "test-hooks"))]
69static CREATE_MOVE_PAUSE: Mutex<Option<(Arc<tokio::sync::Notify>, Arc<tokio::sync::Notify>)>> =
70    Mutex::new(None);
71#[cfg(all(target_arch = "wasm32", feature = "test-hooks"))]
72static DOWNLOAD_MOVE_PAUSE: Mutex<Option<(Arc<tokio::sync::Notify>, Arc<tokio::sync::Notify>)>> =
73    Mutex::new(None);
74#[cfg(all(target_arch = "wasm32", feature = "test-hooks"))]
75static DOWNLOAD_TEST_BODY: Mutex<Option<Vec<u8>>> = Mutex::new(None);
76
77#[cfg(all(target_arch = "wasm32", feature = "test-hooks"))]
78#[doc(hidden)]
79pub fn pause_next_create_move(entered: Arc<tokio::sync::Notify>, resume: Arc<tokio::sync::Notify>) {
80    *CREATE_MOVE_PAUSE.lock() = Some((entered, resume));
81}
82
83#[cfg(all(target_arch = "wasm32", feature = "test-hooks"))]
84#[doc(hidden)]
85pub fn pause_next_download_move(
86    entered: Arc<tokio::sync::Notify>,
87    resume: Arc<tokio::sync::Notify>,
88) {
89    *DOWNLOAD_MOVE_PAUSE.lock() = Some((entered, resume));
90}
91
92#[cfg(all(target_arch = "wasm32", feature = "test-hooks"))]
93#[doc(hidden)]
94pub fn set_next_download_body(body: Vec<u8>) {
95    *DOWNLOAD_TEST_BODY.lock() = Some(body);
96}
97
98fn shared_publication_lock(dir: Option<&PathBuf>) -> Arc<AsyncMutex<()>> {
99    let Some(dir) = dir else {
100        return Arc::new(AsyncMutex::new(()));
101    };
102    let path = dir.clone();
103    let mut locks = PUBLICATION_LOCKS.lock();
104    if let Some(lock) = locks.get(&path).and_then(Weak::upgrade) {
105        return lock;
106    }
107    locks.retain(|_, lock| lock.strong_count() != 0);
108    let lock = Arc::new(AsyncMutex::new(()));
109    locks.insert(path, Arc::downgrade(&lock));
110    lock
111}
112
113fn pending_row_matches_key(row: &StoredPendingAttachment, key: &str) -> bool {
114    RemoteAttachment::decode(row.remote_attachment.as_slice())
115        .ok()
116        .and_then(|remote| attachment_key(&remote).ok())
117        .is_some_and(|stored_key| stored_key == key)
118}
119
120#[derive(Clone, Copy)]
121struct LeaseTiming {
122    duration: Duration,
123    renew: Duration,
124    poll: Duration,
125}
126
127impl Default for LeaseTiming {
128    fn default() -> Self {
129        Self {
130            duration: LEASE_DURATION,
131            renew: LEASE_RENEW,
132            poll: LEASE_POLL,
133        }
134    }
135}
136
137impl LeaseTiming {
138    fn duration_ns(self) -> i64 {
139        self.duration.as_nanos().min(i64::MAX as u128) as i64
140    }
141
142    #[cfg(all(test, not(target_arch = "wasm32")))]
143    fn for_test(duration: Duration, renew: Duration, poll: Duration) -> Self {
144        Self {
145            duration,
146            renew,
147            poll,
148        }
149    }
150}
151
152#[derive(Clone, Debug)]
153pub enum AttachmentSource {
154    Path {
155        path: PathBuf,
156        filename: Option<String>,
157        mime_type: String,
158    },
159    Bytes {
160        bytes: Vec<u8>,
161        filename: Option<String>,
162        mime_type: String,
163    },
164}
165
166#[derive(Clone, Copy, Debug, PartialEq, Eq)]
167pub enum CredentialFailureKind {
168    CredentialRejected,
169    CallbackFailed,
170    Exhausted,
171    MissingCredential,
172}
173
174impl CredentialFailureKind {
175    pub const ALL: [Self; 4] = [
176        Self::CredentialRejected,
177        Self::CallbackFailed,
178        Self::Exhausted,
179        Self::MissingCredential,
180    ];
181
182    pub const fn as_str(self) -> &'static str {
183        match self {
184            Self::CredentialRejected => "credential_rejected",
185            Self::CallbackFailed => "callback_failed",
186            Self::Exhausted => "exhausted",
187            Self::MissingCredential => "missing_credential",
188        }
189    }
190
191    pub fn parse(value: &str) -> Option<Self> {
192        match value {
193            "credential_rejected" => Some(Self::CredentialRejected),
194            "callback_failed" => Some(Self::CallbackFailed),
195            "exhausted" => Some(Self::Exhausted),
196            "missing_credential" => Some(Self::MissingCredential),
197            _ => None,
198        }
199    }
200}
201
202/// A stable attachment cause with the credential detail, when applicable.
203#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
204#[error("attachment failure: {}", cause.as_str())]
205pub struct AttachmentClientError {
206    pub cause: Cause,
207    pub credential_kind: Option<CredentialFailureKind>,
208    pub retryable: bool,
209    /// True when the backend rejected the credential's scope.
210    pub missing_scope: bool,
211    /// Final status from the storage target or download host.
212    pub http_status: Option<u16>,
213}
214
215impl AttachmentClientError {
216    fn new(cause: Cause) -> Self {
217        Self {
218            cause,
219            credential_kind: None,
220            retryable: false,
221            missing_scope: false,
222            http_status: None,
223        }
224    }
225}
226
227impl From<AttachmentError> for AttachmentClientError {
228    fn from(error: AttachmentError) -> Self {
229        Self {
230            http_status: error.http_status,
231            ..Self::new(error.cause)
232        }
233    }
234}
235
236impl From<StoreMoveError> for AttachmentClientError {
237    fn from(error: StoreMoveError) -> Self {
238        match error {
239            StoreMoveError::DestinationExists => Self::new(Cause::LocalStorage),
240            StoreMoveError::Other(error) => error.into(),
241        }
242    }
243}
244
245fn api_error(error: xmtp_api::ApiError) -> AttachmentClientError {
246    if let xmtp_api::ApiError::Auth(auth) = &error {
247        use xmtp_proto::api::AuthError;
248        let credential_kind = match auth {
249            AuthError::CredentialRejected { .. } => CredentialFailureKind::CredentialRejected,
250            AuthError::CallbackFailed { .. } => CredentialFailureKind::CallbackFailed,
251            AuthError::Exhausted | AuthError::ExhaustedAfterAttempt => {
252                CredentialFailureKind::Exhausted
253            }
254            AuthError::MissingCredential => CredentialFailureKind::MissingCredential,
255        };
256        return AttachmentClientError {
257            cause: Cause::Credential,
258            credential_kind: Some(credential_kind),
259            retryable: auth.is_retryable(),
260            missing_scope: false,
261            http_status: None,
262        };
263    }
264    if grpc_status(&error).is_some_and(|status| status.code() == tonic::Code::PermissionDenied) {
265        return AttachmentClientError {
266            missing_scope: true,
267            ..AttachmentClientError::new(Cause::Credential)
268        };
269    }
270    let rejected = grpc_status(&error).is_some_and(|status| {
271        matches!(
272            status.code(),
273            tonic::Code::InvalidArgument | tonic::Code::OutOfRange | tonic::Code::Unimplemented
274        )
275    });
276    AttachmentClientError::new(if rejected {
277        Cause::BackendRejected
278    } else {
279        Cause::BackendUnavailable
280    })
281}
282
283fn attachment_reference(remote: &RemoteAttachment, key: &str) -> AttachmentRef {
284    AttachmentRef {
285        attachment_key: key.to_owned(),
286        url: remote.url.clone(),
287        content_digest: remote.content_digest.clone(),
288    }
289}
290
291struct DecoderSink {
292    writer: StoreWriter,
293    hash: Sha256,
294    cipher: GcmDecryptor,
295    decoder: AttachmentDecoder,
296}
297
298#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
299#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
300impl xmtp_attachments::DownloadSink for DecoderSink {
301    async fn write(&mut self, bytes: &[u8]) -> Result<(), AttachmentError> {
302        self.hash.update(bytes);
303        let mut plaintext = Vec::with_capacity(bytes.len());
304        self.cipher.update(bytes, &mut plaintext)?;
305        for content in self.decoder.push(&plaintext)? {
306            self.writer.write_content(content).await?;
307        }
308        Ok(())
309    }
310}
311
312#[derive(Clone, Debug, PartialEq, Eq)]
313pub enum PendingAttachmentStatus {
314    Waiting,
315    Uploading,
316    Complete,
317    Failed(AttachmentClientError),
318}
319
320fn failure_from_row(row: &StoredPendingAttachment) -> AttachmentClientError {
321    let cause = row
322        .failure_cause
323        .as_deref()
324        .and_then(Cause::parse)
325        .unwrap_or_else(|| {
326            tracing::warn!(stored = ?row.failure_cause, "unknown stored attachment failure cause");
327            Cause::LocalStorage
328        });
329    let credential_kind = row.failure_credential_kind.as_deref().and_then(|stored| {
330        let parsed = CredentialFailureKind::parse(stored);
331        if parsed.is_none() {
332            tracing::warn!(stored, "unknown stored attachment credential failure kind");
333        }
334        parsed
335    });
336    AttachmentClientError {
337        cause,
338        credential_kind,
339        retryable: row.failure_retryable.unwrap_or(false),
340        missing_scope: row.failure_missing_scope.unwrap_or(false),
341        http_status: row
342            .failure_http_status
343            .and_then(|status| u16::try_from(status).ok()),
344    }
345}
346
347fn status_from_row(row: &StoredPendingAttachment, now: i64) -> PendingAttachmentStatus {
348    match row.effective_status(now) {
349        "waiting" => PendingAttachmentStatus::Waiting,
350        "uploading" => PendingAttachmentStatus::Uploading,
351        "complete" => PendingAttachmentStatus::Complete,
352        "failed" => PendingAttachmentStatus::Failed(failure_from_row(row)),
353        _ => PendingAttachmentStatus::Failed(AttachmentClientError::new(Cause::LocalStorage)),
354    }
355}
356
357fn outcome_write_is_retryable(error: &xmtp_db::StorageError) -> bool {
358    use xmtp_db::{ConnectionError, PlatformStorageError, StorageError, diesel::result::Error};
359
360    fn diesel_locked(error: &Error) -> bool {
361        let Error::DatabaseError(_, information) = error else {
362            return false;
363        };
364        matches!(
365            information.message(),
366            "database is locked"
367                | "database table is locked"
368                | "database schema is locked"
369                | "database is busy"
370        )
371    }
372
373    fn platform_transient(error: &PlatformStorageError) -> bool {
374        match error {
375            PlatformStorageError::DieselResult(error) => diesel_locked(error),
376            #[cfg(not(target_arch = "wasm32"))]
377            PlatformStorageError::Pool(_)
378            | PlatformStorageError::DbConnection(_)
379            | PlatformStorageError::PoolNeedsConnection
380            | PlatformStorageError::DatabaseLocked
381            | PlatformStorageError::DieselConnect(_) => true,
382            #[cfg(target_arch = "wasm32")]
383            PlatformStorageError::SAH(_)
384            | PlatformStorageError::Connection(_)
385            | PlatformStorageError::Disconnected
386            | PlatformStorageError::DatabaseInUse => true,
387            _ => false,
388        }
389    }
390
391    match error {
392        StorageError::DieselResult(error)
393        | StorageError::Connection(ConnectionError::Database(error)) => diesel_locked(error),
394        StorageError::Platform(error)
395        | StorageError::Connection(ConnectionError::Platform(error)) => platform_transient(error),
396        StorageError::DieselConnect(_) => true,
397        _ => false,
398    }
399}
400
401struct PendingShared {
402    state: AsyncMutex<()>,
403    watch: watch::Sender<PendingAttachmentStatus>,
404    lease: Mutex<Option<([u8; 16], i64)>>,
405    attempt: Mutex<Option<Arc<PendingAttempt>>>,
406    cancel: Mutex<CancellationToken>,
407}
408
409struct RestorePendingCancel(Option<Arc<PendingShared>>);
410
411impl Drop for RestorePendingCancel {
412    fn drop(&mut self) {
413        if let Some(shared) = self.0.take() {
414            *shared.cancel.lock() = CancellationToken::new();
415        }
416    }
417}
418
419struct PendingAttempt {
420    outcome: watch::Sender<Option<Result<(), AttachmentClientError>>>,
421    done: CancellationToken,
422}
423
424struct PendingAttemptDoneGuard {
425    shared: Arc<PendingShared>,
426    attempt: Arc<PendingAttempt>,
427}
428
429impl Drop for PendingAttemptDoneGuard {
430    fn drop(&mut self) {
431        let mut active = self.shared.attempt.lock();
432        let was_active = active
433            .as_ref()
434            .is_some_and(|current| Arc::ptr_eq(current, &self.attempt));
435        if was_active {
436            *self.shared.lease.lock() = None;
437            *active = None;
438        }
439        drop(active);
440        if was_active {
441            let status = self.shared.watch.borrow().clone();
442            self.shared.watch.send_replace(status);
443        }
444        self.attempt.done.cancel();
445    }
446}
447
448impl PendingAttempt {
449    fn new() -> Self {
450        let (outcome, _) = watch::channel(None);
451        Self {
452            outcome,
453            done: CancellationToken::new(),
454        }
455    }
456}
457
458impl PendingShared {
459    fn new() -> Self {
460        let (watch, _) = watch::channel(PendingAttachmentStatus::Waiting);
461        Self {
462            state: AsyncMutex::new(()),
463            watch,
464            lease: Mutex::new(None),
465            attempt: Mutex::new(None),
466            cancel: Mutex::new(CancellationToken::new()),
467        }
468    }
469
470    fn cancel_token(&self) -> CancellationToken {
471        self.cancel.lock().clone()
472    }
473}
474
475#[derive(Clone, Debug, PartialEq, Eq)]
476pub struct DownloadedAttachment {
477    pub path: PathBuf,
478    pub mime_type: Option<String>,
479    pub filename: Option<String>,
480}
481
482#[derive(Clone, Debug, PartialEq, Eq)]
483pub struct LocalAttachment {
484    pub path: String,
485    pub created_at_ns: i64,
486}
487
488struct DownloadShared {
489    outcome: watch::Sender<Option<Result<DownloadedAttachment, AttachmentClientError>>>,
490    cancel: CancellationToken,
491}
492
493struct DownloadAttemptGuard<Context: AttachmentContext + 'static> {
494    context: Context,
495    remote: RemoteAttachment,
496    relative: String,
497    key: String,
498    shared: Arc<DownloadShared>,
499    publication: Option<tokio::sync::OwnedMutexGuard<()>>,
500    finished: bool,
501}
502
503async fn publish_download_outcome<Context: AttachmentContext>(
504    context: &Context,
505    remote: &RemoteAttachment,
506    relative: &str,
507    key: &str,
508    shared: &Arc<DownloadShared>,
509    publication: Option<tokio::sync::OwnedMutexGuard<()>>,
510    result: Result<DownloadedAttachment, AttachmentClientError>,
511) {
512    let runtime = context.attachment_runtime();
513    let _publication = match publication {
514        Some(guard) => guard,
515        None => runtime.publication_lock.clone().lock_owned().await,
516    };
517    let lock = runtime.event_lock(key);
518    let _guard = lock.lock().await;
519    if shared.outcome.borrow().is_some() {
520        return;
521    }
522    let reference = attachment_reference(remote, key);
523    let event = match &result {
524        Ok(_) => ClientEvent::AttachmentDownloadCompleted(reference),
525        Err(error) => ClientEvent::AttachmentDownloadFailed(AttachmentFailed {
526            attachment_key: key.to_owned(),
527            url: reference.url,
528            content_digest: reference.content_digest,
529            cause: error.cause.as_str().to_owned(),
530        }),
531    };
532    context.events().emit(Some(event), None);
533    let mut downloads = runtime.downloads.lock();
534    if downloads
535        .get(relative)
536        .is_some_and(|current| Arc::ptr_eq(current, shared))
537    {
538        downloads.remove(relative);
539    }
540    shared.outcome.send_replace(Some(result));
541}
542
543impl<Context: AttachmentContext + 'static> DownloadAttemptGuard<Context> {
544    async fn finish(&mut self, result: Result<DownloadedAttachment, AttachmentClientError>) {
545        publish_download_outcome(
546            &self.context,
547            &self.remote,
548            &self.relative,
549            &self.key,
550            &self.shared,
551            self.publication.take(),
552            result,
553        )
554        .await;
555        self.finished = true;
556    }
557}
558
559impl<Context: AttachmentContext + 'static> Drop for DownloadAttemptGuard<Context> {
560    fn drop(&mut self) {
561        if self.finished {
562            return;
563        }
564        let context = self.context.clone();
565        let remote = self.remote.clone();
566        let relative = self.relative.clone();
567        let key = self.key.clone();
568        let shared = self.shared.clone();
569        let publication = self.publication.take();
570        drop(xmtp_common::task::spawn(async move {
571            publish_download_outcome(
572                &context,
573                &remote,
574                &relative,
575                &key,
576                &shared,
577                publication,
578                Err(AttachmentClientError::new(Cause::LocalStorage)),
579            )
580            .await;
581        }));
582    }
583}
584
585struct DeleteInProgress<'a> {
586    paths: &'a Mutex<HashMap<String, usize>>,
587    path: String,
588}
589
590impl<'a> DeleteInProgress<'a> {
591    fn new(paths: &'a Mutex<HashMap<String, usize>>, path: String) -> Self {
592        *paths.lock().entry(path.clone()).or_default() += 1;
593        Self { paths, path }
594    }
595}
596
597impl Drop for DeleteInProgress<'_> {
598    fn drop(&mut self) {
599        let mut paths = self.paths.lock();
600        if let Some(count) = paths.get_mut(&self.path) {
601            *count -= 1;
602            if *count == 0 {
603                paths.remove(&self.path);
604            }
605        }
606    }
607}
608
609impl DownloadShared {
610    fn new() -> Self {
611        let (outcome, _) = watch::channel(None);
612        Self {
613            outcome,
614            cancel: CancellationToken::new(),
615        }
616    }
617}
618
619#[derive(Clone, Default)]
620struct CreateRollbackState {
621    plain_temp: Option<String>,
622    staged_temp: Option<String>,
623    final_plain: Option<String>,
624    final_staged: Option<String>,
625    created_key_dir: Option<String>,
626    local_row: Option<String>,
627    pending_row: Option<String>,
628}
629
630async fn rollback_create<Context: AttachmentContext>(
631    context: &Context,
632    store: &Arc<dyn LocalStore>,
633    state: &CreateRollbackState,
634) {
635    if let Some(digest) = &state.pending_row {
636        let _ = context.db().delete_pending_attachment(digest);
637    }
638    if let Some(path) = &state.local_row {
639        let _ = context.db().delete_local_attachment(path);
640    }
641    for path in [
642        &state.plain_temp,
643        &state.staged_temp,
644        &state.final_plain,
645        &state.final_staged,
646    ]
647    .into_iter()
648    .flatten()
649    {
650        let _ = store.remove_file(path).await;
651    }
652    if let Some(key_dir) = &state.created_key_dir {
653        let _ = store.remove_empty_dir(key_dir).await;
654    }
655}
656
657struct CreateRollbackGuard<Context: AttachmentContext + 'static> {
658    context: Context,
659    store: Arc<dyn LocalStore>,
660    state: CreateRollbackState,
661    publication: Option<OwnedMutexGuard<()>>,
662    #[cfg(target_arch = "wasm32")]
663    in_flight_move: Option<InFlightCreateMove>,
664    committed: bool,
665}
666
667#[derive(Clone, Copy)]
668enum CreateMoveKind {
669    Plain,
670    Staged,
671}
672
673#[cfg(target_arch = "wasm32")]
674struct InFlightCreateMove {
675    receiver: tokio::sync::oneshot::Receiver<Result<(), StoreMoveError>>,
676    destination: String,
677    kind: CreateMoveKind,
678}
679
680#[cfg(target_arch = "wasm32")]
681impl InFlightCreateMove {
682    fn record(&self, state: &mut CreateRollbackState) {
683        match self.kind {
684            CreateMoveKind::Plain => {
685                state.plain_temp = None;
686                state.final_plain = Some(self.destination.clone());
687            }
688            CreateMoveKind::Staged => {
689                state.staged_temp = None;
690                state.final_staged = Some(self.destination.clone());
691            }
692        }
693    }
694}
695
696impl<Context: AttachmentContext + 'static> CreateRollbackGuard<Context> {
697    #[cfg(not(target_arch = "wasm32"))]
698    fn record_move(&mut self, kind: CreateMoveKind, destination: &str) {
699        match kind {
700            CreateMoveKind::Plain => {
701                self.state.plain_temp = None;
702                self.state.final_plain = Some(destination.to_owned());
703            }
704            CreateMoveKind::Staged => {
705                self.state.staged_temp = None;
706                self.state.final_staged = Some(destination.to_owned());
707            }
708        }
709    }
710
711    async fn rename_owned(
712        &mut self,
713        from: &str,
714        to: &str,
715        kind: CreateMoveKind,
716    ) -> Result<(), StoreMoveError> {
717        #[cfg(not(target_arch = "wasm32"))]
718        {
719            let result = self.store.rename(from, to).await;
720            if result.is_ok() {
721                self.record_move(kind, to);
722            }
723            result
724        }
725        #[cfg(target_arch = "wasm32")]
726        {
727            let store = self.store.clone();
728            let source = from.to_owned();
729            let destination = to.to_owned();
730            let (sender, receiver) = tokio::sync::oneshot::channel();
731            drop(xmtp_common::task::spawn(async move {
732                let result = store.rename(&source, &destination).await;
733                #[cfg(feature = "test-hooks")]
734                if matches!(kind, CreateMoveKind::Plain) && result.is_ok() {
735                    let pause = CREATE_MOVE_PAUSE.lock().take();
736                    if let Some((entered, resume)) = pause {
737                        entered.notify_one();
738                        resume.notified().await;
739                    }
740                }
741                let _ = sender.send(result);
742            }));
743            self.in_flight_move = Some(InFlightCreateMove {
744                receiver,
745                destination: to.to_owned(),
746                kind,
747            });
748            let result = match self.in_flight_move.as_mut() {
749                Some(moving) => (&mut moving.receiver).await.unwrap_or_else(|_| {
750                    Err(StoreMoveError::Other(AttachmentError::new(
751                        Cause::LocalStorage,
752                    )))
753                }),
754                None => Err(StoreMoveError::Other(AttachmentError::new(
755                    Cause::LocalStorage,
756                ))),
757            };
758            if let Some(moving) = self.in_flight_move.take()
759                && result.is_ok()
760            {
761                moving.record(&mut self.state);
762            }
763            result
764        }
765    }
766
767    async fn rollback(&mut self) {
768        #[cfg(target_arch = "wasm32")]
769        if let Some(mut moving) = self.in_flight_move.take()
770            && matches!((&mut moving.receiver).await, Ok(Ok(())))
771        {
772            moving.record(&mut self.state);
773        }
774        rollback_create(&self.context, &self.store, &self.state).await;
775        self.committed = true;
776    }
777}
778
779impl<Context: AttachmentContext + 'static> Drop for CreateRollbackGuard<Context> {
780    fn drop(&mut self) {
781        if self.committed {
782            return;
783        }
784        let context = self.context.clone();
785        let store = self.store.clone();
786        let state = self.state.clone();
787        #[cfg(target_arch = "wasm32")]
788        let mut state = state;
789        let publication = self.publication.take();
790        #[cfg(target_arch = "wasm32")]
791        let in_flight_move = self.in_flight_move.take();
792        drop(xmtp_common::task::spawn(async move {
793            let _publication = publication;
794            #[cfg(target_arch = "wasm32")]
795            if let Some(mut moving) = in_flight_move
796                && matches!((&mut moving.receiver).await, Ok(Ok(())))
797            {
798                moving.record(&mut state);
799            }
800            rollback_create(&context, &store, &state).await;
801        }));
802    }
803}
804
805#[doc(hidden)]
806pub struct AttachmentRuntime {
807    pub(crate) store: Option<Arc<dyn LocalStore>>,
808    pub(crate) dir: Option<PathBuf>,
809    pub(crate) options: AttachmentOptions,
810    pending: Mutex<HashMap<String, Weak<PendingShared>>>,
811    downloads: Mutex<HashMap<String, Arc<DownloadShared>>>,
812    deleting: Mutex<HashMap<String, usize>>,
813    event_locks: Mutex<HashMap<String, Weak<AsyncMutex<()>>>>,
814    publication_lock: Arc<AsyncMutex<()>>,
815    reconciled: OnceCell<()>,
816    lease_timing: Mutex<LeaseTiming>,
817    #[cfg(test)]
818    sweep_pause: Mutex<Option<(Arc<tokio::sync::Notify>, Arc<tokio::sync::Notify>)>>,
819    #[cfg(test)]
820    delete_pause: Mutex<Option<(Arc<tokio::sync::Notify>, Arc<tokio::sync::Notify>)>>,
821    #[cfg(all(test, not(target_arch = "wasm32")))]
822    outcome_lock_pause: Mutex<Option<(Arc<tokio::sync::Notify>, Arc<tokio::sync::Notify>)>>,
823    #[cfg(all(test, not(target_arch = "wasm32")))]
824    fail_next_staged_removal: AtomicBool,
825    #[cfg(all(test, not(target_arch = "wasm32")))]
826    fail_next_download_cleanup: AtomicBool,
827    #[cfg(all(test, not(target_arch = "wasm32")))]
828    create_move_pause: Mutex<Option<Arc<CreateMovePause>>>,
829    #[cfg(all(test, not(target_arch = "wasm32")))]
830    create_publish_pause: Mutex<Option<Arc<CreatePublishPause>>>,
831    #[cfg(all(test, not(target_arch = "wasm32")))]
832    attempt_panic_pause: Mutex<Option<(Arc<tokio::sync::Notify>, Arc<tokio::sync::Notify>)>>,
833    #[cfg(all(test, not(target_arch = "wasm32")))]
834    download_panic_pause: Mutex<Option<(Arc<tokio::sync::Notify>, Arc<tokio::sync::Notify>)>>,
835    #[cfg(all(test, not(target_arch = "wasm32")))]
836    download_move_pause: Mutex<Option<(Arc<tokio::sync::Notify>, Arc<tokio::sync::Notify>)>>,
837    #[cfg(all(test, not(target_arch = "wasm32")))]
838    download_publish_pause: Mutex<Option<(Arc<tokio::sync::Notify>, Arc<tokio::sync::Notify>)>>,
839    #[cfg(all(test, not(target_arch = "wasm32")))]
840    download_completion_pause: Mutex<Option<(Arc<tokio::sync::Notify>, Arc<tokio::sync::Notify>)>>,
841    #[cfg(all(test, not(target_arch = "wasm32")))]
842    download_failure_pause: Mutex<Option<(Arc<tokio::sync::Notify>, Arc<tokio::sync::Notify>)>>,
843    #[cfg(all(test, not(target_arch = "wasm32")))]
844    download_existing_pause: Mutex<Option<(Arc<tokio::sync::Notify>, Arc<tokio::sync::Notify>)>>,
845    #[cfg(all(test, not(target_arch = "wasm32")))]
846    reconcile_snapshot_pause: Mutex<Option<(Arc<tokio::sync::Notify>, Arc<tokio::sync::Notify>)>>,
847    #[cfg(test)]
848    outcome_write_errors: AtomicUsize,
849    #[cfg(test)]
850    lease_extension_errors: AtomicUsize,
851}
852
853#[cfg(all(test, not(target_arch = "wasm32")))]
854struct CreateMovePause {
855    path: Mutex<Option<String>>,
856    staged_path: Mutex<Option<String>>,
857    entered: tokio::sync::Notify,
858    resume: tokio::sync::Notify,
859}
860
861#[cfg(all(test, not(target_arch = "wasm32")))]
862struct CreatePublishPause {
863    after_staged: bool,
864    path: Mutex<Option<String>>,
865    staged_path: Mutex<Option<String>>,
866    entered: tokio::sync::Notify,
867    resume: tokio::sync::Notify,
868}
869
870impl Default for AttachmentRuntime {
871    fn default() -> Self {
872        Self {
873            store: None,
874            dir: None,
875            options: AttachmentOptions::default(),
876            pending: Mutex::new(HashMap::new()),
877            downloads: Mutex::new(HashMap::new()),
878            deleting: Mutex::new(HashMap::new()),
879            event_locks: Mutex::new(HashMap::new()),
880            publication_lock: Arc::new(AsyncMutex::new(())),
881            reconciled: OnceCell::new(),
882            lease_timing: Mutex::new(LeaseTiming::default()),
883            #[cfg(test)]
884            sweep_pause: Mutex::new(None),
885            #[cfg(test)]
886            delete_pause: Mutex::new(None),
887            #[cfg(all(test, not(target_arch = "wasm32")))]
888            outcome_lock_pause: Mutex::new(None),
889            #[cfg(all(test, not(target_arch = "wasm32")))]
890            fail_next_staged_removal: AtomicBool::new(false),
891            #[cfg(all(test, not(target_arch = "wasm32")))]
892            fail_next_download_cleanup: AtomicBool::new(false),
893            #[cfg(all(test, not(target_arch = "wasm32")))]
894            create_move_pause: Mutex::new(None),
895            #[cfg(all(test, not(target_arch = "wasm32")))]
896            create_publish_pause: Mutex::new(None),
897            #[cfg(all(test, not(target_arch = "wasm32")))]
898            attempt_panic_pause: Mutex::new(None),
899            #[cfg(all(test, not(target_arch = "wasm32")))]
900            download_panic_pause: Mutex::new(None),
901            #[cfg(all(test, not(target_arch = "wasm32")))]
902            download_move_pause: Mutex::new(None),
903            #[cfg(all(test, not(target_arch = "wasm32")))]
904            download_publish_pause: Mutex::new(None),
905            #[cfg(all(test, not(target_arch = "wasm32")))]
906            download_completion_pause: Mutex::new(None),
907            #[cfg(all(test, not(target_arch = "wasm32")))]
908            download_failure_pause: Mutex::new(None),
909            #[cfg(all(test, not(target_arch = "wasm32")))]
910            download_existing_pause: Mutex::new(None),
911            #[cfg(all(test, not(target_arch = "wasm32")))]
912            reconcile_snapshot_pause: Mutex::new(None),
913            #[cfg(test)]
914            outcome_write_errors: AtomicUsize::new(0),
915            #[cfg(test)]
916            lease_extension_errors: AtomicUsize::new(0),
917        }
918    }
919}
920
921impl AttachmentRuntime {
922    pub(crate) async fn ensure_reconciled<Context: AttachmentContext>(
923        &self,
924        context: &Context,
925    ) -> Result<(), AttachmentClientError> {
926        self.reconciled
927            .get_or_try_init(|| self.reconcile(context))
928            .await
929            .map(|_| ())
930    }
931
932    pub(crate) async fn new(
933        dir: Option<PathBuf>,
934        options: AttachmentOptions,
935    ) -> Result<Self, AttachmentClientError> {
936        let store: Option<Arc<dyn LocalStore>> = match dir.as_ref() {
937            None => None,
938            #[cfg(not(target_arch = "wasm32"))]
939            Some(path) => Some(Arc::new(xmtp_attachments::NativeStore::new(path).await?)),
940            #[cfg(target_arch = "wasm32")]
941            Some(path) => Some(Arc::new(
942                xmtp_attachments::OpfsStore::new(&path.to_string_lossy()).await?,
943            )),
944        };
945        #[cfg(not(target_arch = "wasm32"))]
946        let lock_path = match dir.as_ref() {
947            Some(path) => Some(
948                tokio::fs::canonicalize(path)
949                    .await
950                    .map_err(|_| AttachmentClientError::new(Cause::LocalStorage))?,
951            ),
952            None => None,
953        };
954        #[cfg(target_arch = "wasm32")]
955        let lock_path = dir.clone();
956        let publication_lock = shared_publication_lock(lock_path.as_ref());
957        Ok(Self {
958            store,
959            dir,
960            options,
961            pending: Mutex::new(HashMap::new()),
962            downloads: Mutex::new(HashMap::new()),
963            deleting: Mutex::new(HashMap::new()),
964            event_locks: Mutex::new(HashMap::new()),
965            publication_lock,
966            reconciled: OnceCell::new(),
967            lease_timing: Mutex::new(LeaseTiming::default()),
968            #[cfg(test)]
969            sweep_pause: Mutex::new(None),
970            #[cfg(test)]
971            delete_pause: Mutex::new(None),
972            #[cfg(all(test, not(target_arch = "wasm32")))]
973            outcome_lock_pause: Mutex::new(None),
974            #[cfg(all(test, not(target_arch = "wasm32")))]
975            fail_next_staged_removal: AtomicBool::new(false),
976            #[cfg(all(test, not(target_arch = "wasm32")))]
977            fail_next_download_cleanup: AtomicBool::new(false),
978            #[cfg(all(test, not(target_arch = "wasm32")))]
979            create_move_pause: Mutex::new(None),
980            #[cfg(all(test, not(target_arch = "wasm32")))]
981            create_publish_pause: Mutex::new(None),
982            #[cfg(all(test, not(target_arch = "wasm32")))]
983            attempt_panic_pause: Mutex::new(None),
984            #[cfg(all(test, not(target_arch = "wasm32")))]
985            download_panic_pause: Mutex::new(None),
986            #[cfg(all(test, not(target_arch = "wasm32")))]
987            download_move_pause: Mutex::new(None),
988            #[cfg(all(test, not(target_arch = "wasm32")))]
989            download_publish_pause: Mutex::new(None),
990            #[cfg(all(test, not(target_arch = "wasm32")))]
991            download_completion_pause: Mutex::new(None),
992            #[cfg(all(test, not(target_arch = "wasm32")))]
993            download_failure_pause: Mutex::new(None),
994            #[cfg(all(test, not(target_arch = "wasm32")))]
995            download_existing_pause: Mutex::new(None),
996            #[cfg(all(test, not(target_arch = "wasm32")))]
997            reconcile_snapshot_pause: Mutex::new(None),
998            #[cfg(test)]
999            outcome_write_errors: AtomicUsize::new(0),
1000            #[cfg(test)]
1001            lease_extension_errors: AtomicUsize::new(0),
1002        })
1003    }
1004
1005    /// Delete local files through the retained store after the client shuts down.
1006    #[cfg(not(target_arch = "wasm32"))]
1007    pub async fn delete_local_files(&self) -> Result<(), AttachmentClientError> {
1008        self.store()?.delete_local_files().await.map_err(Into::into)
1009    }
1010
1011    fn store(&self) -> Result<&Arc<dyn LocalStore>, AttachmentClientError> {
1012        self.store
1013            .as_ref()
1014            .ok_or_else(|| AttachmentClientError::new(Cause::LocalStorage))
1015    }
1016
1017    fn shared(&self, digest: &str) -> Arc<PendingShared> {
1018        let mut pending = self.pending.lock();
1019        if let Some(shared) = pending.get(digest).and_then(Weak::upgrade) {
1020            return shared;
1021        }
1022        pending.retain(|_, shared| shared.strong_count() != 0);
1023        let shared = Arc::new(PendingShared::new());
1024        pending.insert(digest.to_owned(), Arc::downgrade(&shared));
1025        shared
1026    }
1027
1028    fn event_lock(&self, key: &str) -> Arc<AsyncMutex<()>> {
1029        let mut locks = self.event_locks.lock();
1030        if let Some(lock) = locks.get(key).and_then(Weak::upgrade) {
1031            return lock;
1032        }
1033        locks.retain(|_, lock| lock.strong_count() != 0);
1034        let lock = Arc::new(AsyncMutex::new(()));
1035        locks.insert(key.to_owned(), Arc::downgrade(&lock));
1036        lock
1037    }
1038
1039    fn cutoff(&self) -> i64 {
1040        let age = self
1041            .options
1042            .max_pending_age
1043            .unwrap_or(DEFAULT_MAX_PENDING_AGE);
1044        now_ns().saturating_sub(age.as_nanos().min(i64::MAX as u128) as i64)
1045    }
1046
1047    /// Delete expired rows before their staged files. An active upload stays valid.
1048    pub(crate) async fn sweep<Context: AttachmentContext>(
1049        &self,
1050        context: &Context,
1051    ) -> Result<(), AttachmentClientError> {
1052        let Some(store) = &self.store else {
1053            return Ok(());
1054        };
1055        let rows = context
1056            .db()
1057            .pending_attachment_sweep_candidates(self.cutoff())
1058            .map_err(|_| AttachmentClientError::new(Cause::LocalStorage))?;
1059        for digest in rows {
1060            let shared = self.shared(&digest);
1061            let _state = shared.state.lock().await;
1062            #[cfg(test)]
1063            {
1064                let pause = self.sweep_pause.lock().clone();
1065                if let Some((entered, resume)) = pause {
1066                    entered.notify_one();
1067                    resume.notified().await;
1068                }
1069            }
1070            let deleted = context
1071                .db()
1072                .sweep_pending_attachment(&digest, self.cutoff(), now_ns())
1073                .map_err(|_| AttachmentClientError::new(Cause::LocalStorage))?;
1074            if deleted == 0 {
1075                continue;
1076            }
1077            let path = staged_path(&digest)?;
1078            if store.exists(&path).await? {
1079                store.remove_file(&path).await?;
1080            }
1081            self.pending.lock().remove(&digest);
1082        }
1083        Ok(())
1084    }
1085
1086    pub(crate) async fn reconcile<Context: AttachmentContext>(
1087        &self,
1088        context: &Context,
1089    ) -> Result<(), AttachmentClientError> {
1090        let _publication = self.publication_lock.lock().await;
1091        #[cfg(test)]
1092        if FAIL_NEXT_RECONCILES
1093            .fetch_update(AtomicOrdering::SeqCst, AtomicOrdering::SeqCst, |count| {
1094                count.checked_sub(1)
1095            })
1096            .is_ok()
1097        {
1098            return Err(AttachmentClientError::new(Cause::LocalStorage));
1099        }
1100        let Some(store) = &self.store else {
1101            return Ok(());
1102        };
1103        let pending: HashMap<_, _> = context
1104            .db()
1105            .list_pending_attachments_since(0)
1106            .map_err(|_| AttachmentClientError::new(Cause::LocalStorage))?
1107            .into_iter()
1108            .map(|row| (row.content_digest, row.status))
1109            .collect();
1110        let records = context
1111            .db()
1112            .list_local_attachments()
1113            .map_err(|_| AttachmentClientError::new(Cause::LocalStorage))?;
1114        let recorded: HashSet<_> = records.iter().map(|row| row.path.as_str()).collect();
1115        let files = store.list_files().await?;
1116        let present: HashSet<_> = files.iter().map(|file| file.path.as_str()).collect();
1117        #[cfg(all(test, not(target_arch = "wasm32")))]
1118        {
1119            let pause = self.reconcile_snapshot_pause.lock().clone();
1120            if let Some((entered, resume)) = pause {
1121                entered.notify_one();
1122                resume.notified().await;
1123            }
1124        }
1125        let now = now_ns();
1126        let cutoff = now.saturating_sub(RECONCILE_AGE.as_nanos() as i64);
1127        let staged_age = self
1128            .options
1129            .max_pending_age
1130            .unwrap_or(DEFAULT_MAX_PENDING_AGE)
1131            .min(RECONCILE_AGE);
1132        let staged_cutoff = now.saturating_sub(staged_age.as_nanos() as i64);
1133        for file in &files {
1134            if file.path.starts_with(".tmp/") {
1135                if file.modified_at_ns < cutoff {
1136                    store.remove_file(&file.path).await?;
1137                }
1138            } else if let Some(digest) = file.path.strip_prefix(".staged/") {
1139                if pending
1140                    .get(digest)
1141                    .is_some_and(|status| status == "complete")
1142                    || (!pending.contains_key(digest) && file.modified_at_ns < staged_cutoff)
1143                {
1144                    store.remove_file(&file.path).await?;
1145                }
1146            } else if file.path.split_once('/').is_some_and(|(key, name)| {
1147                key.len() == 64
1148                    && key
1149                        .bytes()
1150                        .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1151                    && !name.is_empty()
1152                    && !name.contains('/')
1153            }) && !recorded.contains(file.path.as_str())
1154            {
1155                context
1156                    .db()
1157                    .insert_or_ignore_local_attachment(&file.path, file.modified_at_ns, None, None)
1158                    .map_err(|_| AttachmentClientError::new(Cause::LocalStorage))?;
1159            }
1160        }
1161        for record in records {
1162            if !present.contains(record.path.as_str()) {
1163                context
1164                    .db()
1165                    .delete_local_attachment(&record.path)
1166                    .map_err(|_| AttachmentClientError::new(Cause::LocalStorage))?;
1167            }
1168        }
1169        Ok(())
1170    }
1171}
1172
1173pub struct Attachments<Context> {
1174    pub(crate) context: Context,
1175}
1176
1177impl<Context: AttachmentContext> Attachments<Context> {
1178    fn runtime(&self) -> &Arc<AttachmentRuntime> {
1179        self.context.attachment_runtime()
1180    }
1181
1182    /// Read whether this client's server configuration offers attachments.
1183    pub fn offered(&self) -> bool {
1184        self.context
1185            .server_configuration()
1186            .configuration()
1187            .attachments
1188            .is_some()
1189    }
1190
1191    /// Derive the plaintext path without touching the file system.
1192    pub fn local_path(&self, remote: &RemoteAttachment) -> Result<PathBuf, AttachmentClientError> {
1193        let relative = plaintext_rel_path(remote)?;
1194        let dir = self
1195            .runtime()
1196            .dir
1197            .as_ref()
1198            .ok_or_else(|| AttachmentClientError::new(Cause::LocalStorage))?;
1199        Ok(dir.join(relative))
1200    }
1201
1202    pub async fn list_local(&self) -> Result<Vec<LocalAttachment>, AttachmentClientError> {
1203        self.runtime().ensure_reconciled(&self.context).await?;
1204        let _publication = self.runtime().publication_lock.lock().await;
1205        let store = self.runtime().store()?;
1206        let rows = self
1207            .context
1208            .db()
1209            .list_local_attachments()
1210            .map_err(|_| AttachmentClientError::new(Cause::LocalStorage))?;
1211        let mut local = Vec::with_capacity(rows.len());
1212        for row in rows {
1213            if store.exists(&row.path).await? && store.is_regular_file(&row.path).await? {
1214                local.push(LocalAttachment {
1215                    path: row.path,
1216                    created_at_ns: row.created_at_ns,
1217                });
1218            }
1219        }
1220        Ok(local)
1221    }
1222
1223    /// Fetch one verified attachment when its plaintext file is absent.
1224    pub async fn download(
1225        &self,
1226        remote: &RemoteAttachment,
1227    ) -> Result<DownloadedAttachment, AttachmentClientError> {
1228        self.runtime().ensure_reconciled(&self.context).await?;
1229        let relative = plaintext_rel_path(remote)?;
1230        let path = self.local_path(remote)?;
1231        let key = attachment_key(remote)?;
1232        let store = self.runtime().store()?;
1233        let lock = self.runtime().event_lock(&key);
1234        // Keep the file check and its row insert behind the same lock as deletion.
1235        let publication = self.runtime().publication_lock.lock().await;
1236        let shared = {
1237            let _guard = lock.lock().await;
1238            if self.runtime().deleting.lock().contains_key(&key) {
1239                return Err(AttachmentClientError::new(Cause::Deleted));
1240            }
1241            if store.exists(&relative).await? {
1242                if !store.is_regular_file(&relative).await? {
1243                    return Err(AttachmentClientError::new(Cause::LocalStorage));
1244                }
1245                #[cfg(all(test, not(target_arch = "wasm32")))]
1246                let pause = { self.runtime().download_existing_pause.lock().take() };
1247                #[cfg(all(test, not(target_arch = "wasm32")))]
1248                if let Some((entered, resume)) = pause {
1249                    entered.notify_one();
1250                    resume.notified().await;
1251                }
1252                self.context
1253                    .db()
1254                    .insert_or_ignore_local_attachment(&relative, now_ns(), None, None)
1255                    .map_err(|_| AttachmentClientError::new(Cause::LocalStorage))?;
1256                let record = self
1257                    .context
1258                    .db()
1259                    .get_local_attachment(&relative)
1260                    .map_err(|_| AttachmentClientError::new(Cause::LocalStorage))?
1261                    .ok_or_else(|| AttachmentClientError::new(Cause::LocalStorage))?;
1262                return Ok(DownloadedAttachment {
1263                    path,
1264                    mime_type: record.mime_type,
1265                    filename: record.filename,
1266                });
1267            }
1268            if let Some(shared) = self.runtime().downloads.lock().get(&relative).cloned() {
1269                shared
1270            } else {
1271                let shared = Arc::new(DownloadShared::new());
1272                self.runtime()
1273                    .downloads
1274                    .lock()
1275                    .insert(relative.clone(), shared.clone());
1276                let reference = attachment_reference(remote, &key);
1277                self.context.events().emit(
1278                    Some(ClientEvent::AttachmentDownloadStarted(reference)),
1279                    None,
1280                );
1281                let task = Attachments {
1282                    context: self.context.context_ref().clone(),
1283                };
1284                let attempt = DownloadAttemptGuard {
1285                    context: task.context.clone(),
1286                    remote: remote.clone(),
1287                    relative: relative.clone(),
1288                    key: key.clone(),
1289                    shared: shared.clone(),
1290                    publication: None,
1291                    finished: false,
1292                };
1293                drop(xmtp_common::task::spawn(async move {
1294                    let mut attempt = attempt;
1295                    #[cfg(all(test, not(target_arch = "wasm32")))]
1296                    let panic_pause = { task.runtime().download_panic_pause.lock().take() };
1297                    #[cfg(all(test, not(target_arch = "wasm32")))]
1298                    if let Some((entered, resume)) = panic_pause {
1299                        entered.notify_one();
1300                        resume.notified().await;
1301                        panic!("forced download attempt panic");
1302                    }
1303                    let result = task
1304                        .download_once(
1305                            &attempt.remote,
1306                            &attempt.relative,
1307                            &attempt.shared.cancel,
1308                            &mut attempt.publication,
1309                        )
1310                        .await;
1311                    #[cfg(all(test, not(target_arch = "wasm32")))]
1312                    let completion_pause = result
1313                        .is_ok()
1314                        .then(|| task.runtime().download_completion_pause.lock().take())
1315                        .flatten();
1316                    #[cfg(all(test, not(target_arch = "wasm32")))]
1317                    if let Some((entered, resume)) = completion_pause {
1318                        entered.notify_one();
1319                        resume.notified().await;
1320                    }
1321                    #[cfg(all(test, not(target_arch = "wasm32")))]
1322                    let failure_pause = result
1323                        .is_err()
1324                        .then(|| task.runtime().download_failure_pause.lock().take())
1325                        .flatten();
1326                    #[cfg(all(test, not(target_arch = "wasm32")))]
1327                    if let Some((entered, resume)) = failure_pause {
1328                        entered.notify_one();
1329                        resume.notified().await;
1330                    }
1331                    attempt.finish(result).await;
1332                }));
1333                shared
1334            }
1335        };
1336        drop(publication);
1337        let mut outcome = shared.outcome.subscribe();
1338        loop {
1339            let current = outcome.borrow_and_update().clone();
1340            if let Some(result) = current {
1341                return result;
1342            }
1343            outcome
1344                .changed()
1345                .await
1346                .map_err(|_| AttachmentClientError::new(Cause::Network))?;
1347        }
1348    }
1349
1350    async fn download_once(
1351        &self,
1352        remote: &RemoteAttachment,
1353        relative: &str,
1354        cancel: &CancellationToken,
1355        publication_guard: &mut Option<tokio::sync::OwnedMutexGuard<()>>,
1356    ) -> Result<DownloadedAttachment, AttachmentClientError> {
1357        let store = self.runtime().store()?;
1358        let material = KeyMaterial::from_remote(remote)?;
1359        let suffix = hex::encode(xmtp_common::rand_array::<16>());
1360        let content_tmp = temporary_path(&format!("{suffix}-content"))?;
1361        let decoded_tmp = temporary_path(&format!("{suffix}-decoded"))?;
1362        let result = tokio::select! {
1363            biased;
1364            _ = cancel.cancelled() => Err(AttachmentClientError::new(Cause::Deleted)),
1365            result = async {
1366            let writer = store.create_temp(&content_tmp).await?;
1367            let mut sink = DecoderSink {
1368                writer, hash: Sha256::new(),
1369                cipher: GcmDecryptor::new(&material), decoder: AttachmentDecoder::new(),
1370            };
1371            let snapshot_limit = self.context.server_configuration().configuration().attachments
1372                .as_ref().map_or(xmtp_configuration::BACKEND_DEFAULT_MAX_UPLOAD_BYTES, |offer| offer.max_upload_bytes);
1373            let cap = download_cap(remote.content_length.map(u64::from), snapshot_limit, &self.runtime().options);
1374            #[cfg(all(target_arch = "wasm32", feature = "test-hooks"))]
1375            let test_body = { DOWNLOAD_TEST_BODY.lock().take() };
1376            #[cfg(all(target_arch = "wasm32", feature = "test-hooks"))]
1377            if let Some(body) = test_body {
1378                sink.write(&body).await?;
1379            } else {
1380                Transfer::new(self.runtime().options.clone())?.get(&remote.url, cap, &mut sink).await?;
1381            }
1382            #[cfg(not(all(target_arch = "wasm32", feature = "test-hooks")))]
1383            Transfer::new(self.runtime().options.clone())?.get(&remote.url, cap, &mut sink).await?;
1384            let DecoderSink { mut writer, hash, cipher, decoder } = sink;
1385            store.sync(&mut writer).await?;
1386            drop(writer);
1387            if hex::encode(hash.finalize()) != remote.content_digest {
1388                return Err(AttachmentClientError::new(Cause::DigestMismatch));
1389            }
1390            cipher.finish()?;
1391            let meta = store.finish_decode(decoder, &content_tmp, &decoded_tmp).await?;
1392            let final_tmp = if meta.compressed { &decoded_tmp } else { &content_tmp };
1393            #[cfg(all(test, not(target_arch = "wasm32")))]
1394            {
1395                let pause = self.runtime().download_move_pause.lock().clone();
1396                if let Some((entered, resume)) = pause {
1397                    entered.notify_one();
1398                    resume.notified().await;
1399                }
1400            }
1401            let publication = self.runtime().publication_lock.clone().lock_owned().await;
1402            #[cfg(not(target_arch = "wasm32"))]
1403            let (move_result, move_publication) = (store.rename(final_tmp, relative).await, publication);
1404            #[cfg(target_arch = "wasm32")]
1405            let (move_result, move_publication) = {
1406                // Keep the lock in the task until the OPFS move promise settles.
1407                let store = store.clone();
1408                let source = final_tmp.to_owned();
1409                let destination = relative.to_owned();
1410                let (sender, receiver) = tokio::sync::oneshot::channel();
1411                drop(xmtp_common::task::spawn(async move {
1412                    let result = store.rename(&source, &destination).await;
1413                    #[cfg(feature = "test-hooks")]
1414                    if result.is_ok() {
1415                        let pause = { DOWNLOAD_MOVE_PAUSE.lock().take() };
1416                        if let Some((entered, resume)) = pause {
1417                            entered.notify_one();
1418                            resume.notified().await;
1419                        }
1420                    }
1421                    let _ = sender.send((result, publication));
1422                }));
1423                receiver
1424                    .await
1425                    .map_err(|_| AttachmentClientError::new(Cause::LocalStorage))?
1426            };
1427            *publication_guard = Some(move_publication);
1428            match move_result {
1429                Ok(()) => {}
1430                Err(StoreMoveError::DestinationExists) => {
1431                    if !store.is_regular_file(relative).await? {
1432                        return Err(AttachmentClientError::new(Cause::LocalStorage));
1433                    }
1434                    // The file is readable at the path, so the download completes even
1435                    // without a record; crash recovery adds the record at the next client creation.
1436                    let record = self.context.db()
1437                        .insert_or_ignore_local_attachment(relative, now_ns(), None, None)
1438                        .and_then(|()| self.context.db().get_local_attachment(relative));
1439                    let (mime_type, filename) = match record {
1440                        Ok(Some(record)) => (record.mime_type, record.filename),
1441                        _ => (Some(meta.mime_type), meta.filename),
1442                    };
1443                    return Ok(DownloadedAttachment {
1444                        path: self.local_path(remote)?, mime_type, filename,
1445                    });
1446                }
1447                Err(error) => return Err(error.into()),
1448            }
1449            #[cfg(all(test, not(target_arch = "wasm32")))]
1450            let publish_pause = { self.runtime().download_publish_pause.lock().take() };
1451            #[cfg(all(test, not(target_arch = "wasm32")))]
1452            if let Some((entered, resume)) = publish_pause {
1453                entered.notify_one();
1454                resume.notified().await;
1455            }
1456            if self.context.db().insert_or_ignore_local_attachment(
1457                relative, now_ns(), Some(meta.mime_type.clone()), meta.filename.clone()
1458            )
1459            .is_err() {
1460                #[cfg(all(test, not(target_arch = "wasm32")))]
1461                let removal = if self.runtime().fail_next_download_cleanup.swap(false, AtomicOrdering::SeqCst) {
1462                    Err(AttachmentError::new(Cause::LocalStorage))
1463                } else {
1464                    store.remove_file(relative).await
1465                };
1466                #[cfg(not(all(test, not(target_arch = "wasm32"))))]
1467                let removal = store.remove_file(relative).await;
1468                // A file that stays is complete and verified, so the download completes;
1469                // crash recovery adds its record at the next client creation.
1470                match removal {
1471                    Ok(()) => return Err(AttachmentClientError::new(Cause::LocalStorage)),
1472                    Err(error) => tracing::warn!(%error, "could not remove downloaded file after metadata write failed"),
1473                }
1474            }
1475            Ok(DownloadedAttachment {
1476                path: self.local_path(remote)?, mime_type: Some(meta.mime_type), filename: meta.filename,
1477            })
1478            } => result,
1479        };
1480        for temp in [&content_tmp, &decoded_tmp] {
1481            if store.exists(temp).await.unwrap_or(false) {
1482                let _ = store.remove_file(temp).await;
1483            }
1484        }
1485        result
1486    }
1487
1488    /// Delete all local files and records for a remote attachment.
1489    pub async fn delete_local(
1490        &self,
1491        remote: &RemoteAttachment,
1492    ) -> Result<(), AttachmentClientError> {
1493        let task = Attachments {
1494            context: self.context.context_ref().clone(),
1495        };
1496        let remote = remote.clone();
1497        let (sender, receiver) = tokio::sync::oneshot::channel();
1498        drop(xmtp_common::task::spawn(async move {
1499            let result = task.delete_local_inner(&remote).await;
1500            let _ = sender.send(result);
1501        }));
1502        receiver
1503            .await
1504            .map_err(|_| AttachmentClientError::new(Cause::LocalStorage))?
1505    }
1506
1507    async fn delete_local_inner(
1508        &self,
1509        remote: &RemoteAttachment,
1510    ) -> Result<(), AttachmentClientError> {
1511        let key = attachment_key(remote)?;
1512        let staged = staged_path(&remote.content_digest)?;
1513        let store = self.runtime().store()?;
1514        let lock = self.runtime().event_lock(&key);
1515        let (upload, upload_attempt, downloads, _deleting) = {
1516            let _guard = lock.lock().await;
1517            // Marks the whole key directory, so no download into it can start.
1518            let deleting = DeleteInProgress::new(&self.runtime().deleting, key.clone());
1519            let pending_matches = self
1520                .context
1521                .db()
1522                .get_pending_attachment(&remote.content_digest)
1523                .map_err(|_| AttachmentClientError::new(Cause::LocalStorage))?
1524                .is_some_and(|row| pending_row_matches_key(&row, &key));
1525            let upload = pending_matches
1526                .then(|| {
1527                    self.runtime()
1528                        .pending
1529                        .lock()
1530                        .get(&remote.content_digest)
1531                        .and_then(Weak::upgrade)
1532                })
1533                .flatten();
1534            let prefix = format!("{key}/");
1535            let downloads: Vec<_> = self
1536                .runtime()
1537                .downloads
1538                .lock()
1539                .iter()
1540                .filter(|(path, _)| path.starts_with(&prefix))
1541                .map(|(_, shared)| shared.clone())
1542                .collect();
1543            let upload_attempt = upload
1544                .as_ref()
1545                .filter(|shared| shared.lease.lock().is_some())
1546                .and_then(|shared| shared.attempt.lock().clone());
1547            if let Some(shared) = &upload {
1548                shared.cancel.lock().cancel();
1549            }
1550            for shared in &downloads {
1551                shared.cancel.cancel();
1552            }
1553            (upload, upload_attempt, downloads, deleting)
1554        };
1555        #[cfg(test)]
1556        {
1557            let pause = self.runtime().delete_pause.lock().clone();
1558            if let Some((entered, resume)) = pause {
1559                entered.notify_one();
1560                resume.notified().await;
1561            }
1562        }
1563        let cancelled_upload = upload_attempt.is_some();
1564        if let Some(attempt) = upload_attempt {
1565            attempt.done.cancelled().await;
1566        }
1567        for shared in downloads {
1568            let mut outcome = shared.outcome.subscribe();
1569            while outcome.borrow_and_update().clone().is_none() {
1570                outcome
1571                    .changed()
1572                    .await
1573                    .map_err(|_| AttachmentClientError::new(Cause::Network))?;
1574            }
1575        }
1576        let mut restore_cancel = RestorePendingCancel(upload.clone());
1577        let _publication = self.runtime().publication_lock.lock().await;
1578        let _guard = lock.lock().await;
1579        let mut emitted = false;
1580        let mut emit_deleted = || {
1581            if !emitted {
1582                self.context.events().emit(
1583                    Some(ClientEvent::AttachmentDeleted(attachment_reference(
1584                        remote, &key,
1585                    ))),
1586                    None,
1587                );
1588                emitted = true;
1589            }
1590        };
1591        // Remove the row first. An upload in another client sees the deletion
1592        // before this client removes its staged ciphertext.
1593        let pending_row = self
1594            .context
1595            .db()
1596            .get_pending_attachment(&remote.content_digest)
1597            .map_err(|_| AttachmentClientError::new(Cause::LocalStorage))?;
1598        let key_exists = store.prepare_remove_dir(&key).await?;
1599        let deleted_pending =
1600            if let Some(row) = pending_row.filter(|row| pending_row_matches_key(row, &key)) {
1601                self.context
1602                    .db()
1603                    .delete_pending_attachment_if_remote_matches(
1604                        &remote.content_digest,
1605                        &row.remote_attachment,
1606                    )
1607                    .map_err(|_| AttachmentClientError::new(Cause::LocalStorage))?
1608                    != 0
1609            } else {
1610                false
1611            };
1612        if deleted_pending {
1613            restore_cancel.0 = None;
1614            if cancelled_upload {
1615                let deleted = AttachmentClientError::new(Cause::Deleted);
1616                if let Some(shared) = &upload {
1617                    shared
1618                        .watch
1619                        .send_replace(PendingAttachmentStatus::Failed(deleted));
1620                }
1621                let reference = attachment_reference(remote, &key);
1622                self.context.events().emit(
1623                    Some(ClientEvent::AttachmentUploadFailed(AttachmentFailed {
1624                        attachment_key: key.clone(),
1625                        url: reference.url,
1626                        content_digest: reference.content_digest,
1627                        cause: Cause::Deleted.as_str().to_owned(),
1628                    })),
1629                    None,
1630                );
1631            }
1632        }
1633        if key_exists {
1634            if let Err(error) = store.remove_dir_all(&key).await {
1635                // A removal that fails part way has still deleted local files.
1636                if error.removed_any {
1637                    emit_deleted();
1638                }
1639                return Err(error.error.into());
1640            }
1641            emit_deleted();
1642        }
1643        if deleted_pending && store.exists(&staged).await? {
1644            store.remove_file(&staged).await?;
1645            emit_deleted();
1646        }
1647        let removed_rows = self
1648            .context
1649            .db()
1650            .delete_local_attachments_in_dir(&key)
1651            .map_err(|_| AttachmentClientError::new(Cause::LocalStorage))?;
1652        if removed_rows != 0 {
1653            emit_deleted();
1654        }
1655        if deleted_pending {
1656            self.runtime().pending.lock().remove(&remote.content_digest);
1657        }
1658        Ok(())
1659    }
1660
1661    /// Stage one source and return its complete remote description before any request.
1662    pub async fn create(
1663        &self,
1664        source: AttachmentSource,
1665    ) -> Result<PendingAttachment<Context>, AttachmentClientError> {
1666        let offer = self
1667            .context
1668            .server_configuration()
1669            .configuration()
1670            .attachments
1671            .as_ref()
1672            .ok_or_else(|| AttachmentClientError::new(Cause::NotOffered))?;
1673        let store = self.runtime().store()?;
1674        let fallback = match &source {
1675            AttachmentSource::Path {
1676                path,
1677                filename: None,
1678                ..
1679            } => path
1680                .file_name()
1681                .map(|name| name.to_string_lossy().into_owned()),
1682            _ => None,
1683        };
1684        let (source_filename, source_mime_type) = match &source {
1685            AttachmentSource::Path {
1686                filename,
1687                mime_type,
1688                ..
1689            }
1690            | AttachmentSource::Bytes {
1691                filename,
1692                mime_type,
1693                ..
1694            } => (
1695                filename.as_deref().or(fallback.as_deref()),
1696                mime_type.as_str(),
1697            ),
1698        };
1699        if !retained_fields_fit(source_filename, source_mime_type) {
1700            return Err(AttachmentClientError::new(Cause::TooLarge));
1701        }
1702        let filename = source_filename.map(str::to_owned);
1703        let mime_type = source_mime_type.to_owned();
1704        #[cfg(not(target_arch = "wasm32"))]
1705        let mut native_source_file: Option<tokio::fs::File> = None;
1706        let size = match &source {
1707            AttachmentSource::Path { path, .. } => {
1708                #[cfg(target_arch = "wasm32")]
1709                {
1710                    let source_store = xmtp_attachments::OpfsStore::new_root()
1711                        .await
1712                        .map_err(|_| AttachmentClientError::new(Cause::SourceUnreadable))?;
1713                    let source_path = path.to_string_lossy().into_owned();
1714                    source_store
1715                        .open_read(&source_path)
1716                        .await
1717                        .map_err(|_| AttachmentClientError::new(Cause::SourceUnreadable))?
1718                        .len()
1719                }
1720                #[cfg(not(target_arch = "wasm32"))]
1721                {
1722                    #[cfg(unix)]
1723                    use std::os::unix::fs::OpenOptionsExt as _;
1724                    let mut options = std::fs::OpenOptions::new();
1725                    options.read(true);
1726                    #[cfg(unix)]
1727                    options.custom_flags(libc::O_NONBLOCK);
1728                    let file = options
1729                        .open(path)
1730                        .map_err(|_| AttachmentClientError::new(Cause::SourceUnreadable))?;
1731                    let metadata = file
1732                        .metadata()
1733                        .map_err(|_| AttachmentClientError::new(Cause::SourceUnreadable))?;
1734                    if !metadata.is_file() {
1735                        return Err(AttachmentClientError::new(Cause::SourceUnreadable));
1736                    }
1737                    native_source_file = Some(tokio::fs::File::from_std(file));
1738                    metadata.len()
1739                }
1740            }
1741            AttachmentSource::Bytes { bytes, .. } => bytes.len() as u64,
1742        };
1743        let prefix = encoded_prefix(filename.as_deref(), &mime_type, size);
1744        let length = ciphertext_len(prefix.len(), size);
1745        if length > offer.max_upload_bytes || length > u32::MAX as u64 {
1746            return Err(AttachmentClientError::new(Cause::TooLarge));
1747        }
1748        let id: [u8; 16] = xmtp_common::rand_array();
1749        let plain_temp = temporary_path(&format!("{}-plain", hex::encode(id)))?;
1750        let staged_temp = temporary_path(&format!("{}-staged", hex::encode(id)))?;
1751        let publication = self.runtime().publication_lock.clone().lock_owned().await;
1752        let mut rollback = CreateRollbackGuard {
1753            context: self.context.context_ref().clone(),
1754            store: store.clone(),
1755            state: CreateRollbackState {
1756                plain_temp: Some(plain_temp.clone()),
1757                staged_temp: Some(staged_temp.clone()),
1758                ..Default::default()
1759            },
1760            publication: Some(publication),
1761            #[cfg(target_arch = "wasm32")]
1762            in_flight_move: None,
1763            committed: false,
1764        };
1765        let result: Result<PendingAttachment<Context>, AttachmentClientError> = async {
1766            let mut plain = store.create_temp(&plain_temp).await?;
1767            let mut staged = store.create_temp(&staged_temp).await?;
1768            let material = KeyMaterial::random();
1769            let mut encryptor = GcmEncryptor::new(&material);
1770            let mut hash = Sha256::new();
1771            let mut encrypted = Vec::with_capacity(CHUNK + prefix.len());
1772            encryptor.update(&prefix, &mut encrypted)?;
1773            staged.write(&encrypted).await?;
1774            hash.update(&encrypted);
1775            encrypted.clear();
1776            match source {
1777                AttachmentSource::Path { path, .. } => {
1778                    #[cfg(not(target_arch = "wasm32"))]
1779                    {
1780                        use tokio::io::AsyncReadExt as _;
1781                        let _ = path;
1782                        let mut file = native_source_file
1783                            .take()
1784                            .ok_or_else(|| AttachmentClientError::new(Cause::SourceUnreadable))?;
1785                        let mut chunk = [0u8; CHUNK];
1786                        let mut read_total = 0u64;
1787                        loop {
1788                            let count = file
1789                                .read(&mut chunk)
1790                                .await
1791                                .map_err(|_| AttachmentClientError::new(Cause::SourceUnreadable))?;
1792                            if count == 0 {
1793                                break;
1794                            }
1795                            read_total += count as u64;
1796                            if read_total > size {
1797                                return Err(AttachmentClientError::new(Cause::SourceUnreadable));
1798                            }
1799                            plain.write(&chunk[..count]).await?;
1800                            encryptor.update(&chunk[..count], &mut encrypted)?;
1801                            staged.write(&encrypted).await?;
1802                            hash.update(&encrypted);
1803                            encrypted.clear();
1804                        }
1805                        if read_total != size {
1806                            return Err(AttachmentClientError::new(Cause::SourceUnreadable));
1807                        }
1808                    }
1809                    #[cfg(target_arch = "wasm32")]
1810                    {
1811                        let source_store = xmtp_attachments::OpfsStore::new_root()
1812                            .await
1813                            .map_err(|_| AttachmentClientError::new(Cause::SourceUnreadable))?;
1814                        let source_path = path.to_string_lossy().into_owned();
1815                        let file = source_store
1816                            .open_read(&source_path)
1817                            .await
1818                            .map_err(|_| AttachmentClientError::new(Cause::SourceUnreadable))?;
1819                        let mut read_total = 0u64;
1820                        loop {
1821                            let chunk = file
1822                                .read_chunk(read_total, CHUNK)
1823                                .await
1824                                .map_err(|_| AttachmentClientError::new(Cause::SourceUnreadable))?;
1825                            if chunk.is_empty() {
1826                                break;
1827                            }
1828                            read_total += chunk.len() as u64;
1829                            if read_total > size {
1830                                return Err(AttachmentClientError::new(Cause::SourceUnreadable));
1831                            }
1832                            plain.write(&chunk).await?;
1833                            encryptor.update(&chunk, &mut encrypted)?;
1834                            staged.write(&encrypted).await?;
1835                            hash.update(&encrypted);
1836                            encrypted.clear();
1837                        }
1838                        if read_total != size {
1839                            return Err(AttachmentClientError::new(Cause::SourceUnreadable));
1840                        }
1841                    }
1842                }
1843                AttachmentSource::Bytes { bytes, .. } => {
1844                    for chunk in bytes.chunks(CHUNK) {
1845                        plain.write(chunk).await?;
1846                        encryptor.update(chunk, &mut encrypted)?;
1847                        staged.write(&encrypted).await?;
1848                        hash.update(&encrypted);
1849                        encrypted.clear();
1850                    }
1851                }
1852            }
1853            let tag = encryptor.finish();
1854            staged.write(&tag).await?;
1855            hash.update(tag);
1856            store.sync(&mut plain).await?;
1857            store.sync(&mut staged).await?;
1858            drop(plain);
1859            drop(staged);
1860            let hex_digest = hex::encode(hash.finalize());
1861            let remote = remote_attachment(
1862                &offer.base_url,
1863                &hex_digest,
1864                &material,
1865                length as u32,
1866                filename.as_deref(),
1867            );
1868            let local = plaintext_rel_path(&remote)?;
1869            let ciphertext = staged_path(&hex_digest)?;
1870            #[cfg(all(test, not(target_arch = "wasm32")))]
1871            let create_pause = { self.runtime().create_move_pause.lock().clone() };
1872            #[cfg(all(test, not(target_arch = "wasm32")))]
1873            if let Some(pause) = create_pause {
1874                *pause.path.lock() = Some(local.clone());
1875                *pause.staged_path.lock() = Some(ciphertext.clone());
1876                pause.entered.notify_one();
1877                pause.resume.notified().await;
1878            }
1879            let key_dir = attachment_key(&remote)?;
1880            if store.create_dir_if_absent(&key_dir).await? {
1881                rollback.state.created_key_dir = Some(key_dir);
1882            }
1883            match rollback
1884                .rename_owned(&plain_temp, &local, CreateMoveKind::Plain)
1885                .await
1886            {
1887                Ok(()) => {}
1888                Err(StoreMoveError::DestinationExists) => {
1889                    if !store.is_regular_file(&local).await? {
1890                        return Err(AttachmentClientError::new(Cause::LocalStorage));
1891                    }
1892                    store.remove_file(&plain_temp).await?;
1893                    rollback.state.plain_temp = None;
1894                }
1895                Err(error) => return Err(error.into()),
1896            }
1897            #[cfg(all(test, not(target_arch = "wasm32")))]
1898            let publish_pause = { self.runtime().create_publish_pause.lock().clone() };
1899            #[cfg(all(test, not(target_arch = "wasm32")))]
1900            if let Some(pause) = publish_pause
1901                && !pause.after_staged
1902            {
1903                *pause.path.lock() = Some(local.clone());
1904                *pause.staged_path.lock() = Some(ciphertext.clone());
1905                pause.entered.notify_one();
1906                pause.resume.notified().await;
1907            }
1908            match rollback
1909                .rename_owned(&staged_temp, &ciphertext, CreateMoveKind::Staged)
1910                .await
1911            {
1912                Ok(()) => {}
1913                Err(StoreMoveError::DestinationExists) => {
1914                    let valid = match store.open_read(&ciphertext).await {
1915                        Ok(file) => matches!(
1916                            file.sha256(length).await,
1917                            Ok((digest, stored_length))
1918                                if stored_length == length && hex::encode(digest) == hex_digest
1919                        ),
1920                        Err(_) => false,
1921                    };
1922                    if valid {
1923                        store.remove_file(&staged_temp).await?;
1924                        rollback.state.staged_temp = None;
1925                    } else {
1926                        store.replace(&staged_temp, &ciphertext).await?;
1927                        rollback.state.staged_temp = None;
1928                        rollback.state.final_staged = Some(ciphertext.clone());
1929                    }
1930                }
1931                Err(error) => return Err(error.into()),
1932            }
1933            #[cfg(all(test, not(target_arch = "wasm32")))]
1934            let publish_pause = { self.runtime().create_publish_pause.lock().clone() };
1935            #[cfg(all(test, not(target_arch = "wasm32")))]
1936            if let Some(pause) = publish_pause
1937                && pause.after_staged
1938            {
1939                *pause.path.lock() = Some(local.clone());
1940                *pause.staged_path.lock() = Some(ciphertext.clone());
1941                pause.entered.notify_one();
1942                pause.resume.notified().await;
1943            }
1944            let db = self.context.db();
1945            let inserted = db
1946                .insert_local_attachment_if_absent(
1947                    &local,
1948                    now_ns(),
1949                    Some(mime_type.clone()),
1950                    filename.clone(),
1951                )
1952                .map_err(|_| AttachmentClientError::new(Cause::LocalStorage))?;
1953            if inserted {
1954                rollback.state.local_row = Some(local.clone());
1955            }
1956            let inserted = db
1957                .insert_pending_attachment_if_absent(&hex_digest, &remote.encode_to_vec(), now_ns())
1958                .map_err(|_| AttachmentClientError::new(Cause::LocalStorage))?;
1959            if !inserted {
1960                return Err(AttachmentClientError::new(Cause::LocalStorage));
1961            }
1962            rollback.state.pending_row = Some(hex_digest.clone());
1963            Ok(PendingAttachment {
1964                context: self.context.clone(),
1965                remote,
1966                shared: self.runtime().shared(&hex_digest),
1967            })
1968        }
1969        .await;
1970        if result.is_err() {
1971            rollback.rollback().await;
1972        } else {
1973            rollback.committed = true;
1974        }
1975        result
1976    }
1977
1978    /// Return the held pending attachment for this digest.
1979    pub async fn pending(
1980        &self,
1981        remote: &RemoteAttachment,
1982    ) -> Result<PendingAttachment<Context>, AttachmentClientError> {
1983        self.runtime().ensure_reconciled(&self.context).await?;
1984        let row = self
1985            .context
1986            .db()
1987            .get_pending_attachment(&remote.content_digest)
1988            .map_err(|_| AttachmentClientError::new(Cause::LocalStorage))?
1989            .filter(|row| {
1990                row.created_at_ns >= self.runtime().cutoff()
1991                    || row.status == "uploading"
1992                        && row.lease_expires_at_ns.is_some_and(|end| end >= now_ns())
1993            })
1994            .ok_or_else(|| AttachmentClientError::new(Cause::StagedUnusable))?;
1995        let remote = RemoteAttachment::decode(row.remote_attachment.as_slice())
1996            .map_err(|_| AttachmentClientError::new(Cause::StagedUnusable))?;
1997        Ok(PendingAttachment {
1998            context: self.context.clone(),
1999            shared: self.runtime().shared(&remote.content_digest),
2000            remote,
2001        })
2002    }
2003
2004    pub async fn list_pending(
2005        &self,
2006    ) -> Result<Vec<PendingAttachment<Context>>, AttachmentClientError> {
2007        self.runtime().ensure_reconciled(&self.context).await?;
2008        self.context
2009            .db()
2010            .list_pending_attachments_since(0)
2011            .map_err(|_| AttachmentClientError::new(Cause::LocalStorage))?
2012            .into_iter()
2013            .filter(|row| {
2014                row.status != "complete"
2015                    && (row.created_at_ns >= self.runtime().cutoff()
2016                        || row.status == "uploading"
2017                            && row.lease_expires_at_ns.is_some_and(|end| end >= now_ns()))
2018            })
2019            .map(|row| {
2020                let remote = RemoteAttachment::decode(row.remote_attachment.as_slice())
2021                    .map_err(|_| AttachmentClientError::new(Cause::LocalStorage))?;
2022                Ok(PendingAttachment {
2023                    context: self.context.clone(),
2024                    shared: self.runtime().shared(&remote.content_digest),
2025                    remote,
2026                })
2027            })
2028            .collect()
2029    }
2030}
2031
2032pub struct PendingAttachment<Context> {
2033    context: Context,
2034    remote: RemoteAttachment,
2035    shared: Arc<PendingShared>,
2036}
2037
2038impl<Context: Clone> Clone for PendingAttachment<Context> {
2039    fn clone(&self) -> Self {
2040        Self {
2041            context: self.context.clone(),
2042            remote: self.remote.clone(),
2043            shared: self.shared.clone(),
2044        }
2045    }
2046}
2047
2048impl<Context: AttachmentContext> PendingAttachment<Context> {
2049    pub fn remote_attachment(&self) -> &RemoteAttachment {
2050        &self.remote
2051    }
2052
2053    pub fn local_path(&self) -> Result<PathBuf, AttachmentClientError> {
2054        let dir = self
2055            .context
2056            .attachment_runtime()
2057            .dir
2058            .as_ref()
2059            .ok_or_else(|| AttachmentClientError::new(Cause::LocalStorage))?;
2060        Ok(dir.join(plaintext_rel_path(&self.remote)?))
2061    }
2062
2063    pub fn status(&self) -> PendingAttachmentStatus {
2064        if self
2065            .shared
2066            .lease
2067            .lock()
2068            .as_ref()
2069            .is_some_and(|(_, until)| now_ns() < *until)
2070            && matches!(
2071                *self.shared.watch.borrow(),
2072                PendingAttachmentStatus::Uploading
2073            )
2074        {
2075            return PendingAttachmentStatus::Uploading;
2076        }
2077        match self.record() {
2078            Ok(Some(row)) => status_from_row(&row, now_ns()),
2079            Ok(None) => PendingAttachmentStatus::Failed(AttachmentClientError::new(
2080                if self.shared.cancel_token().is_cancelled() {
2081                    Cause::Deleted
2082                } else {
2083                    Cause::StagedUnusable
2084                },
2085            )),
2086            Err(_) => self.shared.watch.borrow().clone(),
2087        }
2088    }
2089
2090    pub fn watch_status(&self) -> watch::Receiver<PendingAttachmentStatus> {
2091        self.shared.watch.send_replace(self.status());
2092        let receiver = self.shared.watch.subscribe();
2093        let pending = PendingAttachment {
2094            context: self.context.context_ref().clone(),
2095            remote: self.remote.clone(),
2096            shared: self.shared.clone(),
2097        };
2098        drop(xmtp_common::task::spawn(async move {
2099            while pending.shared.watch.receiver_count() > 0 {
2100                let poll = pending
2101                    .context
2102                    .attachment_runtime()
2103                    .lease_timing
2104                    .lock()
2105                    .poll;
2106                xmtp_common::time::sleep(poll).await;
2107                let status = pending.status();
2108                if *pending.shared.watch.borrow() != status {
2109                    pending.shared.watch.send_replace(status);
2110                }
2111            }
2112        }));
2113        receiver
2114    }
2115
2116    fn reference(&self) -> AttachmentRef {
2117        AttachmentRef {
2118            attachment_key: xmtp_attachments::attachment_key(&self.remote).unwrap_or_default(),
2119            url: self.remote.url.clone(),
2120            content_digest: self.remote.content_digest.clone(),
2121        }
2122    }
2123
2124    fn record(&self) -> Result<Option<StoredPendingAttachment>, AttachmentClientError> {
2125        self.context
2126            .db()
2127            .get_pending_attachment(&self.remote.content_digest)
2128            .map_err(|_| AttachmentClientError::new(Cause::LocalStorage))
2129    }
2130
2131    async fn wait_for_record_change(
2132        &self,
2133        local_attempt: Option<Arc<PendingAttempt>>,
2134    ) -> Result<PendingAttachmentStatus, AttachmentClientError> {
2135        let mut watch = self.shared.watch.subscribe();
2136        let mut attempt_watch = local_attempt.map(|attempt| attempt.outcome.subscribe());
2137        loop {
2138            if let Some(result) = attempt_watch
2139                .as_ref()
2140                .and_then(|receiver| receiver.borrow().clone())
2141            {
2142                return Ok(match result {
2143                    Ok(()) => PendingAttachmentStatus::Complete,
2144                    Err(error) => PendingAttachmentStatus::Failed(error),
2145                });
2146            }
2147            match self.record() {
2148                Ok(Some(row)) => {
2149                    let status = status_from_row(&row, now_ns());
2150                    if status != PendingAttachmentStatus::Uploading
2151                        && self.shared.lease.lock().is_none()
2152                    {
2153                        self.shared.watch.send_replace(status.clone());
2154                        return Ok(status);
2155                    }
2156                }
2157                Ok(None) => {
2158                    return Err(AttachmentClientError::new(Cause::Deleted));
2159                }
2160                Err(error) => {
2161                    tracing::warn!(%error, "pending attachment status read will be retried")
2162                }
2163            }
2164            let poll = self.context.attachment_runtime().lease_timing.lock().poll;
2165            tokio::select! {
2166                _ = xmtp_common::time::sleep(poll) => {},
2167                changed = watch.changed() => {
2168                    if changed.is_err() {
2169                        return Err(AttachmentClientError::new(Cause::LocalStorage));
2170                    }
2171                }
2172                changed = async {
2173                    if let Some(receiver) = attempt_watch.as_mut() {
2174                        receiver.changed().await
2175                    } else {
2176                        std::future::pending().await
2177                    }
2178                } => {
2179                    if changed.is_err() {
2180                        attempt_watch = None;
2181                    }
2182                }
2183            }
2184        }
2185    }
2186
2187    pub async fn upload(&self) -> Result<(), AttachmentClientError> {
2188        loop {
2189            let mut claim_missed = false;
2190            let (should_wait, local_attempt) = {
2191                let _state = self.shared.state.lock().await;
2192                let row = self
2193                    .record()?
2194                    .ok_or_else(|| AttachmentClientError::new(Cause::StagedUnusable))?;
2195                match status_from_row(&row, now_ns()) {
2196                    PendingAttachmentStatus::Complete => return Ok(()),
2197                    PendingAttachmentStatus::Failed(error)
2198                        if error.cause == Cause::BackendRejected =>
2199                    {
2200                        return Err(error);
2201                    }
2202                    PendingAttachmentStatus::Uploading => {
2203                        (true, self.shared.attempt.lock().clone())
2204                    }
2205                    PendingAttachmentStatus::Waiting | PendingAttachmentStatus::Failed(_)
2206                        if self.shared.lease.lock().is_some() =>
2207                    {
2208                        (true, self.shared.attempt.lock().clone())
2209                    }
2210                    PendingAttachmentStatus::Waiting | PendingAttachmentStatus::Failed(_) => {
2211                        let token = xmtp_common::rand_array::<16>();
2212                        let now = now_ns();
2213                        let timing = *self.context.attachment_runtime().lease_timing.lock();
2214                        let event_lock = self
2215                            .context
2216                            .attachment_runtime()
2217                            .event_lock(&self.reference().attachment_key);
2218                        let _event_guard = event_lock.lock().await;
2219                        let claimed = self
2220                            .context
2221                            .db()
2222                            .claim_pending_attachment(
2223                                &self.remote.content_digest,
2224                                &token,
2225                                now,
2226                                timing.duration_ns(),
2227                            )
2228                            .map_err(|_| AttachmentClientError::new(Cause::LocalStorage))?;
2229                        if claimed == 0 {
2230                            claim_missed = true;
2231                            (false, None)
2232                        } else {
2233                            let attempt = Arc::new(PendingAttempt::new());
2234                            *self.shared.attempt.lock() = Some(attempt.clone());
2235                            *self.shared.lease.lock() =
2236                                Some((token, now.saturating_add(timing.duration_ns())));
2237                            self.shared
2238                                .watch
2239                                .send_replace(PendingAttachmentStatus::Uploading);
2240                            self.context.events().emit(
2241                                Some(ClientEvent::AttachmentUploadStarted(self.reference())),
2242                                None,
2243                            );
2244                            let pending = PendingAttachment {
2245                                context: self.context.context_ref().clone(),
2246                                remote: self.remote.clone(),
2247                                shared: self.shared.clone(),
2248                            };
2249                            // The registry entry owns the attempt after the caller stops waiting.
2250                            let local_attempt = attempt.clone();
2251                            let done_guard = PendingAttemptDoneGuard {
2252                                shared: self.shared.clone(),
2253                                attempt: attempt.clone(),
2254                            };
2255                            drop(xmtp_common::task::spawn(async move {
2256                                let _done_guard = done_guard;
2257                                pending.run_attempt(token, attempt).await;
2258                            }));
2259                            (true, Some(local_attempt))
2260                        }
2261                    }
2262                }
2263            };
2264            if claim_missed {
2265                let row = self
2266                    .record()?
2267                    .ok_or_else(|| AttachmentClientError::new(Cause::StagedUnusable))?;
2268                match status_from_row(&row, now_ns()) {
2269                    PendingAttachmentStatus::Complete => return Ok(()),
2270                    PendingAttachmentStatus::Failed(error) => return Err(error),
2271                    PendingAttachmentStatus::Waiting => continue,
2272                    PendingAttachmentStatus::Uploading => {}
2273                }
2274            }
2275            if should_wait || claim_missed {
2276                match self.wait_for_record_change(local_attempt).await? {
2277                    PendingAttachmentStatus::Complete => return Ok(()),
2278                    PendingAttachmentStatus::Failed(error) => return Err(error),
2279                    PendingAttachmentStatus::Waiting => continue,
2280                    PendingAttachmentStatus::Uploading => continue,
2281                }
2282            }
2283        }
2284    }
2285
2286    fn lease_is_current(&self, token: &[u8; 16]) -> bool {
2287        self.shared
2288            .lease
2289            .lock()
2290            .as_ref()
2291            .is_some_and(|(owner, end)| owner == token && now_ns() < *end)
2292    }
2293
2294    async fn run_attempt(&self, token: [u8; 16], attempt: Arc<PendingAttempt>) {
2295        let cancel = self.shared.cancel_token();
2296        #[cfg(all(test, not(target_arch = "wasm32")))]
2297        {
2298            let pause = self
2299                .context
2300                .attachment_runtime()
2301                .attempt_panic_pause
2302                .lock()
2303                .clone();
2304            if let Some((entered, resume)) = pause {
2305                entered.notify_one();
2306                resume.notified().await;
2307                panic!("forced attachment upload panic");
2308            }
2309        }
2310        let timing = *self.context.attachment_runtime().lease_timing.lock();
2311        let mut tick = Box::pin(xmtp_common::time::interval_stream(timing.renew));
2312        #[cfg(not(target_arch = "wasm32"))]
2313        tick.next().await;
2314        let mut transfer = Box::pin(self.upload_once(&token));
2315        let result = loop {
2316            tokio::select! {
2317                biased;
2318                _ = cancel.cancelled() => break Err(AttachmentClientError::new(Cause::Deleted)),
2319                result = &mut transfer => break result,
2320                _ = tick.next() => {
2321                    let now = now_ns();
2322                    let extension = self.context.db().extend_pending_attachment(
2323                        &self.remote.content_digest, &token, now, timing.duration_ns()
2324                    );
2325                    match extension {
2326                        Ok(1) => *self.shared.lease.lock() = Some((token, now.saturating_add(timing.duration_ns()))),
2327                        Ok(_) => {
2328                            self.lost_lease(&attempt).await;
2329                            return;
2330                        }
2331                        Err(error) => {
2332                            #[cfg(test)]
2333                            self.context.attachment_runtime().lease_extension_errors.fetch_add(1, AtomicOrdering::SeqCst);
2334                            tracing::warn!(%error, "attachment lease extension will be retried");
2335                        },
2336                    }
2337                }
2338            }
2339        };
2340        drop(transfer);
2341        self.finish_attempt(&token, result, &attempt).await;
2342    }
2343
2344    fn end_local_attempt(
2345        &self,
2346        attempt: &Arc<PendingAttempt>,
2347        result: Option<&Result<(), AttachmentClientError>>,
2348    ) {
2349        let mut active = self.shared.attempt.lock();
2350        if active
2351            .as_ref()
2352            .is_some_and(|current| Arc::ptr_eq(current, attempt))
2353        {
2354            *active = None;
2355        }
2356        drop(active);
2357        if let Some(result) = result {
2358            attempt.outcome.send_replace(Some(result.clone()));
2359        }
2360        attempt.done.cancel();
2361    }
2362
2363    fn end_cancelled_attempt(&self, attempt: &Arc<PendingAttempt>) {
2364        *self.shared.lease.lock() = None;
2365        let deleted = Err(AttachmentClientError::new(Cause::Deleted));
2366        self.end_local_attempt(attempt, Some(&deleted));
2367    }
2368
2369    async fn lost_lease(&self, attempt: &Arc<PendingAttempt>) {
2370        *self.shared.lease.lock() = None;
2371        {
2372            // Read the record before this attempt ends. A delete in this client
2373            // waits for that end before it removes the record and reports the
2374            // failure, so a record that is gone here was removed by a delete
2375            // that did not report it, for example one in another client.
2376            let event_lock = self
2377                .context
2378                .attachment_runtime()
2379                .event_lock(&self.reference().attachment_key);
2380            let _event_guard = event_lock.lock().await;
2381            match self.record() {
2382                Ok(Some(row)) => {
2383                    self.shared
2384                        .watch
2385                        .send_replace(status_from_row(&row, now_ns()));
2386                }
2387                Ok(None) => {
2388                    self.publish_upload_outcome(&Err(AttachmentClientError::new(Cause::Deleted)));
2389                }
2390                Err(error) => {
2391                    tracing::warn!(%error, "lost attachment lease status read will be retried")
2392                }
2393            }
2394        }
2395        self.end_local_attempt(attempt, None);
2396    }
2397
2398    async fn finish_attempt(
2399        &self,
2400        token: &[u8; 16],
2401        result: Result<(), AttachmentClientError>,
2402        attempt: &Arc<PendingAttempt>,
2403    ) {
2404        let cancel = self.shared.cancel_token();
2405        let mut delay = Duration::from_millis(100);
2406        let mut retrying = false;
2407        let timing = *self.context.attachment_runtime().lease_timing.lock();
2408        let mut renew = Box::pin(xmtp_common::time::interval_stream(timing.renew));
2409        #[cfg(not(target_arch = "wasm32"))]
2410        renew.next().await;
2411        loop {
2412            if retrying && cancel.is_cancelled() {
2413                self.end_cancelled_attempt(attempt);
2414                return;
2415            }
2416            #[cfg(all(test, not(target_arch = "wasm32")))]
2417            let outcome_pause = self
2418                .context
2419                .attachment_runtime()
2420                .outcome_lock_pause
2421                .lock()
2422                .take();
2423            #[cfg(all(test, not(target_arch = "wasm32")))]
2424            if let Some((entered, resume)) = outcome_pause {
2425                entered.notify_one();
2426                resume.notified().await;
2427            }
2428            let event_lock = self
2429                .context
2430                .attachment_runtime()
2431                .event_lock(&self.reference().attachment_key);
2432            let _event_guard = event_lock.lock().await;
2433            if cancel.is_cancelled() {
2434                drop(_event_guard);
2435                self.end_cancelled_attempt(attempt);
2436                return;
2437            }
2438            let recorded = match &result {
2439                Ok(()) => PendingAttachmentOutcome::Complete,
2440                Err(error) => PendingAttachmentOutcome::Failed {
2441                    cause: error.cause.as_str(),
2442                    credential_kind: error.credential_kind.map(CredentialFailureKind::as_str),
2443                    retryable: Some(error.retryable),
2444                    missing_scope: Some(error.missing_scope),
2445                    http_status: error.http_status,
2446                },
2447            };
2448            let outcome = self.context.db().finish_pending_attachment(
2449                &self.remote.content_digest,
2450                token,
2451                now_ns(),
2452                recorded,
2453            );
2454            #[cfg(test)]
2455            if outcome.is_err() {
2456                self.context
2457                    .attachment_runtime()
2458                    .outcome_write_errors
2459                    .fetch_add(1, AtomicOrdering::SeqCst);
2460            }
2461            match outcome {
2462                Ok(1) => {
2463                    *self.shared.lease.lock() = None;
2464                    if result.is_ok()
2465                        && let Ok(store) = self.context.attachment_runtime().store()
2466                        && let Ok(path) = staged_path(&self.remote.content_digest)
2467                    {
2468                        #[cfg(all(test, not(target_arch = "wasm32")))]
2469                        let removal = if self
2470                            .context
2471                            .attachment_runtime()
2472                            .fail_next_staged_removal
2473                            .swap(false, AtomicOrdering::SeqCst)
2474                        {
2475                            Err(AttachmentError::new(Cause::LocalStorage))
2476                        } else {
2477                            store.remove_file(&path).await
2478                        };
2479                        #[cfg(not(all(test, not(target_arch = "wasm32"))))]
2480                        let removal = store.remove_file(&path).await;
2481                        if let Err(error) = removal {
2482                            tracing::warn!(%error, "staged ciphertext cleanup will be retried by reconciliation");
2483                        }
2484                    }
2485                    self.publish_upload_outcome(&result);
2486                    self.end_local_attempt(attempt, Some(&result));
2487                    return;
2488                }
2489                Ok(_) => {
2490                    drop(_event_guard);
2491                    self.lost_lease(attempt).await;
2492                    return;
2493                }
2494                Err(error) if outcome_write_is_retryable(&error) => {
2495                    retrying = true;
2496                    tracing::warn!(%error, "attachment outcome write will be retried");
2497                }
2498                Err(error) => {
2499                    tracing::error!(%error, "attachment outcome write cannot be retried");
2500                    // Nothing was recorded, so no event is sent. The record
2501                    // stays `uploading` until its lease expires.
2502                    *self.shared.lease.lock() = None;
2503                    let failed = Err(AttachmentClientError::new(Cause::LocalStorage));
2504                    self.end_local_attempt(attempt, Some(&failed));
2505                    return;
2506                }
2507            }
2508            drop(_event_guard);
2509            let retry_delay = xmtp_common::time::sleep(delay);
2510            tokio::pin!(retry_delay);
2511            loop {
2512                tokio::select! {
2513                    biased;
2514                    _ = cancel.cancelled() => {
2515                        self.end_cancelled_attempt(attempt);
2516                        return;
2517                    }
2518                    _ = &mut retry_delay => break,
2519                    _ = renew.next() => {
2520                        let now = now_ns();
2521                        let extension = self.context.db().extend_pending_attachment(
2522                            &self.remote.content_digest,
2523                            token,
2524                            now,
2525                            timing.duration_ns(),
2526                        );
2527                        match extension {
2528                            Ok(1) => *self.shared.lease.lock() =
2529                                Some((*token, now.saturating_add(timing.duration_ns()))),
2530                            Ok(_) => {
2531                                self.lost_lease(attempt).await;
2532                                return;
2533                            }
2534                            Err(error) => {
2535                                #[cfg(test)]
2536                                self.context
2537                                    .attachment_runtime()
2538                                    .lease_extension_errors
2539                                    .fetch_add(1, AtomicOrdering::SeqCst);
2540                                tracing::warn!(%error, "attachment lease extension will be retried");
2541                            }
2542                        }
2543                    }
2544                }
2545            }
2546            delay = delay.saturating_mul(2).min(Duration::from_secs(5));
2547        }
2548    }
2549
2550    fn publish_upload_outcome(&self, result: &Result<(), AttachmentClientError>) {
2551        let next = match result {
2552            Ok(()) => PendingAttachmentStatus::Complete,
2553            Err(error) => PendingAttachmentStatus::Failed(error.clone()),
2554        };
2555        self.shared.watch.send_replace(next);
2556        let reference = self.reference();
2557        let event = match result {
2558            Ok(()) => ClientEvent::AttachmentUploadCompleted(reference),
2559            Err(error) => ClientEvent::AttachmentUploadFailed(AttachmentFailed {
2560                attachment_key: reference.attachment_key,
2561                url: reference.url,
2562                content_digest: reference.content_digest,
2563                cause: error.cause.as_str().to_owned(),
2564            }),
2565        };
2566        self.context.events().emit(Some(event), None);
2567    }
2568
2569    async fn upload_once(&self, token: &[u8; 16]) -> Result<(), AttachmentClientError> {
2570        self.context
2571            .server_configuration()
2572            .check()
2573            .map_err(|_| AttachmentClientError::new(Cause::ConnectionBlocked))?;
2574        let runtime = self.context.attachment_runtime();
2575        let store = runtime.store()?;
2576        let path = staged_path(&self.remote.content_digest)?;
2577        let storage_error = |_| AttachmentClientError {
2578            retryable: true,
2579            ..AttachmentClientError::new(Cause::LocalStorage)
2580        };
2581        if !store.exists(&path).await.map_err(storage_error)? {
2582            return Err(AttachmentClientError::new(Cause::StagedUnusable));
2583        }
2584        let expected_length = self
2585            .remote
2586            .content_length
2587            .map(u64::from)
2588            .ok_or_else(|| AttachmentClientError::new(Cause::StagedUnusable))?;
2589        let staged = store.open_read(&path).await.map_err(storage_error)?;
2590        let (digest, length) = staged
2591            .sha256(expected_length)
2592            .await
2593            .map_err(storage_error)?;
2594        if hex::encode(digest) != self.remote.content_digest || length != expected_length {
2595            return Err(AttachmentClientError::new(Cause::StagedUnusable));
2596        }
2597        if !self.lease_is_current(token) {
2598            return Err(AttachmentClientError::new(Cause::Network));
2599        }
2600        let response = self
2601            .context
2602            .api()
2603            .create_upload(CreateUploadRequest {
2604                content_digest: digest.to_vec(),
2605                content_length: length,
2606            })
2607            .await
2608            .map_err(api_error)?;
2609        let request = UploadRequest {
2610            method: response.method,
2611            url: response.url,
2612            headers: response
2613                .headers
2614                .into_iter()
2615                .map(|header| (header.name, header.value))
2616                .collect(),
2617            expires_in_seconds: response.expires_in_seconds,
2618        };
2619        if !self.lease_is_current(token) {
2620            return Err(AttachmentClientError::new(Cause::Network));
2621        }
2622        let transfer = Transfer::new(runtime.options.clone())?;
2623        transfer.put(&request, staged).await?;
2624        Ok(())
2625    }
2626}
2627
2628pub(crate) mod cleanup {
2629    use super::*;
2630    use crate::worker::{BoxedWorker, NewWorker, Worker, WorkerFactory, WorkerKind, WorkerResult};
2631
2632    pub struct AttachmentCleanup<Context> {
2633        context: Context,
2634    }
2635    struct Factory<Context> {
2636        context: Context,
2637    }
2638
2639    impl<Context: AttachmentContext + 'static> WorkerFactory for Factory<Context> {
2640        fn kind(&self) -> WorkerKind {
2641            WorkerKind::AttachmentCleanup
2642        }
2643        fn create(&self) -> BoxedWorker {
2644            Box::new(AttachmentCleanup {
2645                context: self.context.clone(),
2646            })
2647        }
2648    }
2649
2650    impl<Context: AttachmentContext + 'static> NewWorker<Context> for AttachmentCleanup<Context> {
2651        fn factory(context: Context) -> impl WorkerFactory + 'static {
2652            Factory { context }
2653        }
2654    }
2655
2656    #[xmtp_common::async_trait]
2657    impl<Context: AttachmentContext + 'static> Worker for AttachmentCleanup<Context> {
2658        fn kind(&self) -> WorkerKind {
2659            WorkerKind::AttachmentCleanup
2660        }
2661        async fn run_tasks(&mut self) -> WorkerResult<()> {
2662            loop {
2663                let (interval, _) = self
2664                    .context
2665                    .worker_interval(WorkerKind::AttachmentCleanup, Duration::from_secs(3600));
2666                xmtp_common::time::sleep(interval.min(Duration::from_secs(3600))).await;
2667                let runtime = self.context.attachment_runtime();
2668                if let Err(error) = runtime.sweep(&self.context).await {
2669                    tracing::warn!(%error, "attachment expiry sweep failed");
2670                }
2671                if let Err(error) = runtime.reconcile(&self.context).await {
2672                    tracing::warn!(%error, "attachment reconciliation failed");
2673                }
2674            }
2675        }
2676    }
2677}
2678
2679#[cfg(all(test, not(target_arch = "wasm32")))]
2680mod tests;