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