Skip to main content

xmtp_attachments/
store.rs

1//! Local attachment files, rooted below one attachments directory.
2
3use std::time::Duration;
4
5use crate::{
6    AttachmentDecoder, AttachmentError, AttachmentFailureCause as Cause, ContentChunk, DecodedMeta,
7    sanitize::is_reserved_device_name,
8};
9
10#[cfg(not(target_arch = "wasm32"))]
11mod native;
12#[cfg(target_arch = "wasm32")]
13mod opfs;
14
15#[cfg(not(target_arch = "wasm32"))]
16pub use native::{NativeStore, create_private_directory};
17#[cfg(target_arch = "wasm32")]
18pub use opfs::OpfsStore;
19
20/// The maximum amount of data buffered for one file operation.
21pub const CHUNK_SIZE: usize = 64 * 1024;
22
23#[cfg(target_arch = "wasm32")]
24const MAX_EXACT_JS_OFFSET: u64 = (1_u64 << 53) - 1;
25
26/// A path for staged ciphertext. It cannot collide with an attachment key.
27pub fn staged_path(content_digest: &str) -> Result<String, AttachmentError> {
28    if content_digest.len() != 64
29        || !content_digest
30            .bytes()
31            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
32    {
33        return Err(AttachmentError::new(Cause::Malformed));
34    }
35    Ok(format!(".staged/{content_digest}"))
36}
37
38pub(crate) fn is_reconcile_dir(name: &str) -> bool {
39    name == ".tmp"
40        || name == ".staged"
41        || (name.len() == 64
42            && name
43                .bytes()
44                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)))
45}
46
47/// A path for a temporary file. Temporary files never sit under key directories.
48pub fn temporary_path(name: &str) -> Result<String, AttachmentError> {
49    validate_relative(name)?;
50    if name.contains('/') {
51        return Err(AttachmentError::new(Cause::Malformed));
52    }
53    Ok(format!(".tmp/{name}"))
54}
55
56pub(crate) fn validate_relative(path: &str) -> Result<(), AttachmentError> {
57    if path.is_empty()
58        || path.starts_with('/')
59        || path.contains('\\')
60        || path.split('/').any(|part| {
61            part.is_empty()
62                || part == "."
63                || part == ".."
64                || part.contains(':')
65                || is_reserved_device_name(part)
66        })
67    {
68        return Err(AttachmentError::new(Cause::Malformed));
69    }
70    Ok(())
71}
72
73pub(crate) fn validate_temp(path: &str) -> Result<(), AttachmentError> {
74    validate_relative(path)?;
75    if !path.starts_with(".tmp/") {
76        return Err(AttachmentError::new(Cause::Malformed));
77    }
78    Ok(())
79}
80
81/// A staged file that can be uploaded without loading it into memory.
82#[derive(Clone)]
83pub struct StagedFile {
84    #[cfg(not(target_arch = "wasm32"))]
85    pub(crate) path: std::path::PathBuf,
86    /// A file opened by the native store without following a path link.
87    #[cfg(not(target_arch = "wasm32"))]
88    pub(crate) opened: Option<std::sync::Arc<std::fs::File>>,
89    #[cfg(target_arch = "wasm32")]
90    pub(crate) file: web_sys::File,
91}
92
93#[cfg(all(test, not(target_arch = "wasm32")))]
94impl StagedFile {
95    pub(crate) fn from_path_for_test(path: std::path::PathBuf) -> Self {
96        Self { path, opened: None }
97    }
98}
99
100/// A writable file used as the sink for a download.
101pub struct StoreWriter {
102    #[cfg(not(target_arch = "wasm32"))]
103    pub(crate) file: tokio::fs::File,
104    #[cfg(target_arch = "wasm32")]
105    pub(crate) handle: web_sys::FileSystemSyncAccessHandle,
106    #[cfg(target_arch = "wasm32")]
107    pub(crate) offset: u64,
108}
109
110impl StoreWriter {
111    /// Apply a decoder content event to this temporary file.
112    pub async fn write_content(&mut self, chunk: ContentChunk<'_>) -> Result<(), AttachmentError> {
113        match chunk {
114            ContentChunk::Reset => {
115                #[cfg(not(target_arch = "wasm32"))]
116                {
117                    use tokio::io::AsyncSeekExt;
118                    self.file
119                        .set_len(0)
120                        .await
121                        .map_err(|_| AttachmentError::new(Cause::LocalStorage))?;
122                    self.file
123                        .rewind()
124                        .await
125                        .map(|_| ())
126                        .map_err(|_| AttachmentError::new(Cause::LocalStorage))
127                }
128                #[cfg(target_arch = "wasm32")]
129                {
130                    self.handle
131                        .truncate_with_u32(0)
132                        .map_err(|_| AttachmentError::new(Cause::LocalStorage))?;
133                    self.offset = 0;
134                    Ok(())
135                }
136            }
137            ContentChunk::Bytes(bytes) => self.write(bytes).await,
138        }
139    }
140}
141
142/// A file found during attachment storage reconciliation.
143pub struct StoreFile {
144    pub path: String,
145    pub modified_at_ns: i64,
146}
147
148/// The result of a move that must not replace an existing file.
149#[derive(Debug, thiserror::Error)]
150pub enum StoreMoveError {
151    #[error("attachment destination exists")]
152    DestinationExists,
153    #[error(transparent)]
154    Other(#[from] AttachmentError),
155}
156
157impl StoreMoveError {
158    pub fn cause(&self) -> Cause {
159        match self {
160            Self::DestinationExists => Cause::LocalStorage,
161            Self::Other(error) => error.cause,
162        }
163    }
164}
165
166/// A directory removal that failed.
167#[derive(Debug, thiserror::Error)]
168#[error("{error}")]
169pub struct StoreRemoveError {
170    pub error: AttachmentError,
171    /// True when the removal deleted at least one entry below the directory
172    /// before it failed.
173    pub removed_any: bool,
174}
175
176impl From<AttachmentError> for StoreRemoveError {
177    fn from(error: AttachmentError) -> Self {
178        Self {
179            error,
180            removed_any: false,
181        }
182    }
183}
184
185#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
186#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
187pub trait DownloadSink: xmtp_common::wasm::MaybeSend {
188    async fn write(&mut self, bytes: &[u8]) -> Result<(), AttachmentError>;
189}
190
191#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
192#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
193pub trait LocalStore: xmtp_common::wasm::MaybeSend + xmtp_common::wasm::MaybeSync {
194    async fn open_read(&self, path: &str) -> Result<StagedFile, AttachmentError>;
195    /// Check the file kind after the caller finds an existing path.
196    async fn is_regular_file(&self, path: &str) -> Result<bool, AttachmentError> {
197        self.open_read(path).await?.is_regular_file().await
198    }
199    /// Create a temporary file. Callers must use unique names because OPFS
200    /// cannot create a file exclusively.
201    async fn create_temp(&self, path: &str) -> Result<StoreWriter, AttachmentError>;
202    /// Return true when this call created the directory.
203    async fn create_dir_if_absent(&self, path: &str) -> Result<bool, AttachmentError>;
204    /// Move a file only when the destination does not exist.
205    async fn rename(&self, from: &str, to: &str) -> Result<(), StoreMoveError>;
206    /// Atomically replace the destination with a completed temporary file.
207    async fn replace(&self, from: &str, to: &str) -> Result<(), AttachmentError>;
208    /// Remove a directory and every entry below it. A removal can fail after
209    /// it deleted some entries; the error then reports that it did.
210    async fn remove_dir_all(&self, path: &str) -> Result<(), StoreRemoveError>;
211    /// Check a managed directory before its related record is removed.
212    /// Return false when nothing is at the path. Fail when the entry is not a
213    /// directory.
214    async fn prepare_remove_dir(&self, path: &str) -> Result<bool, AttachmentError>;
215    /// Remove only an empty directory.
216    async fn remove_empty_dir(&self, path: &str) -> Result<(), AttachmentError>;
217    async fn remove_file(&self, path: &str) -> Result<(), AttachmentError>;
218    async fn exists(&self, path: &str) -> Result<bool, AttachmentError>;
219    async fn sync(&self, writer: &mut StoreWriter) -> Result<(), AttachmentError>;
220    async fn finish_decode(
221        &self,
222        decoder: AttachmentDecoder,
223        source: &str,
224        output: &str,
225    ) -> Result<DecodedMeta, AttachmentError>;
226    /// List files one level below the managed directories. Do not enter app directories.
227    async fn list_files(&self) -> Result<Vec<StoreFile>, AttachmentError>;
228}
229
230impl StagedFile {
231    async fn is_regular_file(&self) -> Result<bool, AttachmentError> {
232        #[cfg(not(target_arch = "wasm32"))]
233        {
234            // Check the opened file. The path can name another entry by now.
235            Ok(self
236                .opened
237                .as_ref()
238                .ok_or_else(|| AttachmentError::new(Cause::LocalStorage))?
239                .metadata()
240                .map_err(|_| AttachmentError::new(Cause::LocalStorage))?
241                .is_file())
242        }
243        #[cfg(target_arch = "wasm32")]
244        {
245            // OPFS open_read obtains a file handle, which rejects directories.
246            Ok(true)
247        }
248    }
249
250    #[cfg(not(target_arch = "wasm32"))]
251    pub async fn read_chunk(
252        &self,
253        offset: u64,
254        max_bytes: usize,
255    ) -> Result<Vec<u8>, AttachmentError> {
256        use tokio::io::{AsyncReadExt as _, AsyncSeekExt as _};
257        let mut file = tokio::fs::File::open(&self.path)
258            .await
259            .map_err(|_| AttachmentError::new(Cause::LocalStorage))?;
260        file.seek(std::io::SeekFrom::Start(offset))
261            .await
262            .map_err(|_| AttachmentError::new(Cause::LocalStorage))?;
263        let mut bytes = vec![0; max_bytes];
264        let count = file
265            .read(&mut bytes)
266            .await
267            .map_err(|_| AttachmentError::new(Cause::LocalStorage))?;
268        bytes.truncate(count);
269        Ok(bytes)
270    }
271
272    #[cfg(target_arch = "wasm32")]
273    pub fn len(&self) -> u64 {
274        self.file.size() as u64
275    }
276
277    #[cfg(target_arch = "wasm32")]
278    pub fn is_empty(&self) -> bool {
279        self.len() == 0
280    }
281
282    /// Read at most one chunk from an OPFS source file.
283    #[cfg(target_arch = "wasm32")]
284    pub async fn read_chunk(
285        &self,
286        offset: u64,
287        max_bytes: usize,
288    ) -> Result<Vec<u8>, AttachmentError> {
289        use wasm_bindgen_futures::JsFuture;
290        let end = offset.saturating_add(max_bytes as u64).min(self.len());
291        if offset >= end {
292            return Ok(Vec::new());
293        }
294        let slice = self
295            .file
296            .slice_with_f64_and_f64(offset as f64, end as f64)
297            .map_err(|_| AttachmentError::new(Cause::LocalStorage))?;
298        let buffer = JsFuture::from(slice.array_buffer())
299            .await
300            .map_err(|_| AttachmentError::new(Cause::LocalStorage))?;
301        Ok(js_sys::Uint8Array::new(&buffer).to_vec())
302    }
303
304    /// Hash the stored ciphertext in fixed-size chunks before upload.
305    ///
306    /// Read at most one byte more than `expected_len`, so a longer file is
307    /// found without a read of the rest. The returned length differs from
308    /// `expected_len` when the file is shorter or longer.
309    pub async fn sha256(&self, expected_len: u64) -> Result<([u8; 32], u64), AttachmentError> {
310        let limit = expected_len.saturating_add(1);
311        #[cfg(not(target_arch = "wasm32"))]
312        {
313            let file = match &self.opened {
314                Some(file) => file.clone(),
315                None => std::sync::Arc::new(
316                    tokio::fs::File::open(&self.path)
317                        .await
318                        .map_err(|_| AttachmentError::new(Cause::LocalStorage))?
319                        .into_std()
320                        .await,
321                ),
322            };
323            xmtp_common::task::spawn_blocking(move || {
324                sha256_prefix(|bytes, offset| read_at(&file, bytes, offset), limit)
325            })
326            .await
327            .map_err(|_| AttachmentError::new(Cause::LocalStorage))?
328        }
329        #[cfg(target_arch = "wasm32")]
330        {
331            use sha2::{Digest as _, Sha256};
332            let mut hash = Sha256::new();
333            let mut length = 0u64;
334            let end = self.len().min(limit);
335            while length < end {
336                let bytes = self.read_chunk(length, CHUNK_SIZE).await?;
337                if bytes.is_empty() {
338                    return Err(AttachmentError::new(Cause::LocalStorage));
339                }
340                let bytes = &bytes[..bytes.len().min((end - length) as usize)];
341                hash.update(bytes);
342                length += bytes.len() as u64;
343            }
344            Ok((hash.finalize().into(), length))
345        }
346    }
347}
348
349/// Read a file at an offset without the shared file cursor.
350#[cfg(all(not(target_arch = "wasm32"), unix))]
351pub(crate) fn read_at(
352    file: &std::fs::File,
353    bytes: &mut [u8],
354    offset: u64,
355) -> std::io::Result<usize> {
356    std::os::unix::fs::FileExt::read_at(file, bytes, offset)
357}
358
359/// Read a file at an offset. Windows moves the cursor, so callers always
360/// give the offset.
361#[cfg(all(not(target_arch = "wasm32"), windows))]
362pub(crate) fn read_at(
363    file: &std::fs::File,
364    bytes: &mut [u8],
365    offset: u64,
366) -> std::io::Result<usize> {
367    std::os::windows::fs::FileExt::seek_read(file, bytes, offset)
368}
369
370#[cfg(all(not(target_arch = "wasm32"), not(any(unix, windows))))]
371pub(crate) fn read_at(_: &std::fs::File, _: &mut [u8], _: u64) -> std::io::Result<usize> {
372    Err(std::io::Error::from(std::io::ErrorKind::Unsupported))
373}
374
375/// Hash the first `limit` bytes that `read_at` gives, or fewer at the end.
376#[cfg(not(target_arch = "wasm32"))]
377fn sha256_prefix(
378    mut read_at: impl FnMut(&mut [u8], u64) -> std::io::Result<usize>,
379    limit: u64,
380) -> Result<([u8; 32], u64), AttachmentError> {
381    use sha2::{Digest as _, Sha256};
382    let mut hash = Sha256::new();
383    let mut length = 0u64;
384    let mut chunk = vec![0u8; CHUNK_SIZE];
385    while length < limit {
386        let want = (limit - length).min(CHUNK_SIZE as u64) as usize;
387        let count = read_at(&mut chunk[..want], length)
388            .map_err(|_| AttachmentError::new(Cause::LocalStorage))?;
389        if count == 0 {
390            break;
391        }
392        hash.update(&chunk[..count]);
393        length += count as u64;
394    }
395    Ok((hash.finalize().into(), length))
396}
397
398#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
399#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
400impl DownloadSink for StoreWriter {
401    async fn write(&mut self, bytes: &[u8]) -> Result<(), AttachmentError> {
402        #[cfg(not(target_arch = "wasm32"))]
403        {
404            use tokio::io::AsyncWriteExt;
405            self.file
406                .write_all(bytes)
407                .await
408                .map_err(|_| AttachmentError::new(Cause::LocalStorage))
409        }
410        #[cfg(target_arch = "wasm32")]
411        {
412            let mut remaining = bytes;
413            while !remaining.is_empty() {
414                if self.offset > MAX_EXACT_JS_OFFSET {
415                    return Err(AttachmentError::new(Cause::LocalStorage));
416                }
417                let options = web_sys::FileSystemReadWriteOptions::new();
418                options.set_at(self.offset as f64);
419                let count = self
420                    .handle
421                    .write_with_u8_array_and_options(remaining, &options)
422                    .map_err(|_| AttachmentError::new(Cause::LocalStorage))?
423                    as usize;
424                if count == 0 || count > remaining.len() {
425                    return Err(AttachmentError::new(Cause::LocalStorage));
426                }
427                self.offset = self
428                    .offset
429                    .checked_add(count as u64)
430                    .ok_or(AttachmentError::new(Cause::LocalStorage))?;
431                remaining = &remaining[count..];
432            }
433            Ok(())
434        }
435    }
436}
437
438/// Transfer limits. The caller supplies the snapshot default in `get`.
439#[derive(Clone, Debug, Default)]
440pub struct AttachmentOptions {
441    pub max_download_bytes: Option<u64>,
442    pub allow_private_network: bool,
443    pub max_pending_age: Option<Duration>,
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449
450    #[xmtp_common::test(unwrap_try = true)]
451    fn validate_relative_rejects_drive_and_stream_forms() {
452        for path in ["C:/outside", "C:relative", "key/a:b"] {
453            assert_eq!(
454                validate_relative(path).unwrap_err().cause,
455                Cause::Malformed,
456                "{path}"
457            );
458        }
459    }
460
461    #[xmtp_common::test(unwrap_try = true)]
462    fn validate_relative_rejects_device_names() {
463        for path in ["CON", "key/nul.txt", "COM1", "lpt³.bin"] {
464            assert_eq!(
465                validate_relative(path).unwrap_err().cause,
466                Cause::Malformed,
467                "{path}"
468            );
469        }
470        for path in ["CONOUT$X", "COM0"] {
471            validate_relative(path)?;
472        }
473    }
474
475    // verifies: ATCH-049
476    #[xmtp_common::test(unwrap_try = true)]
477    fn staged_outside_key_dirs() {
478        let digest = "a".repeat(64);
479        assert_eq!(staged_path(&digest)?, format!(".staged/{digest}"));
480        assert_eq!(temporary_path("nonce")?, ".tmp/nonce");
481        assert!(staged_path("../bad").is_err());
482        assert!(temporary_path("../bad").is_err());
483        assert!(staged_path(&"A".repeat(64)).is_err());
484    }
485
486    // verifies: ATCH-036
487    #[cfg(not(target_arch = "wasm32"))]
488    #[xmtp_common::test(unwrap_try = true)]
489    fn staged_hash_reads_only_the_limit() {
490        let source = vec![7u8; 16 * CHUNK_SIZE];
491        let mut read = 0u64;
492        let limit = 2 * CHUNK_SIZE as u64 + 1;
493        let (_, length) = sha256_prefix(
494            |bytes, offset| {
495                let start = offset as usize;
496                let count = bytes.len().min(source.len() - start);
497                bytes[..count].copy_from_slice(&source[start..start + count]);
498                read += count as u64;
499                Ok(count)
500            },
501            limit,
502        )?;
503        assert_eq!(length, limit);
504        assert_eq!(read, limit);
505    }
506
507    // verifies: ATCH-036
508    #[cfg(not(target_arch = "wasm32"))]
509    #[xmtp_common::test(unwrap_try = true)]
510    async fn staged_hash_stops_after_expected_length() {
511        use sha2::{Digest as _, Sha256};
512        let directory = tempfile::tempdir()?;
513        let store = NativeStore::new(directory.path()).await?;
514        let content: Vec<u8> = (0..4 * CHUNK_SIZE).map(|index| index as u8).collect();
515        let mut writer = store.create_temp(".tmp/long").await?;
516        writer.write(&content).await?;
517        store.sync(&mut writer).await?;
518        drop(writer);
519        let full_length = content.len() as u64;
520        let prefix_digest =
521            |length: usize| -> [u8; 32] { Sha256::digest(&content[..length]).into() };
522
523        let staged = store.open_read(".tmp/long").await?;
524        assert!(staged.opened.is_some());
525        // A longer file: the hash reads one byte past the expected length.
526        assert_eq!(staged.sha256(10).await?, (prefix_digest(11), 11));
527        assert_eq!(
528            staged.sha256(full_length).await?,
529            (prefix_digest(content.len()), full_length)
530        );
531        // A shorter file: the hash ends at the end of the file.
532        assert_eq!(staged.sha256(full_length + 5).await?.1, full_length);
533        let by_path = StagedFile::from_path_for_test(directory.path().join(".tmp/long"));
534        assert_eq!(by_path.sha256(10).await?, (prefix_digest(11), 11));
535    }
536
537    #[cfg(not(target_arch = "wasm32"))]
538    #[xmtp_common::test(unwrap_try = true)]
539    async fn native_store_paths() {
540        let directory = tempfile::tempdir()?;
541        let store = NativeStore::new(directory.path()).await?;
542        assert!(store.create_temp("key/file").await.is_err());
543        let mut writer = store.create_temp(".tmp/a").await?;
544        writer.write(b"saved").await?;
545        store.sync(&mut writer).await?;
546        drop(writer);
547        assert!(store.exists(".tmp/a").await?);
548        store.rename(".tmp/a", "key/file").await?;
549        assert!(!store.exists(".tmp/a").await?);
550        assert!(store.exists("key/file").await?);
551        store.open_read("key/file").await?;
552        store.remove_dir_all("key").await?;
553        assert!(!store.exists("key/file").await?);
554    }
555
556    // verifies: ATCH-077
557    #[cfg(unix)]
558    #[xmtp_common::test(unwrap_try = true)]
559    async fn native_create_temp_rejects_symlinked_parent() {
560        let root = tempfile::tempdir()?;
561        let outside = tempfile::tempdir()?;
562        let store = NativeStore::new(root.path()).await?;
563        std::os::unix::fs::symlink(outside.path(), root.path().join(".tmp"))?;
564
565        assert_eq!(
566            store.create_temp(".tmp/file").await.err().unwrap().cause,
567            Cause::LocalStorage
568        );
569        assert!(!outside.path().join("file").exists());
570    }
571
572    #[cfg(unix)]
573    #[xmtp_common::test(unwrap_try = true)]
574    async fn native_open_read_rejects_symlinked_parent() {
575        let root = tempfile::tempdir()?;
576        let outside = tempfile::tempdir()?;
577        std::fs::write(outside.path().join("file"), b"outside")?;
578        let store = NativeStore::new(root.path()).await?;
579        std::os::unix::fs::symlink(outside.path(), root.path().join("key"))?;
580
581        assert_eq!(
582            store.open_read("key/file").await.err().unwrap().cause,
583            Cause::LocalStorage
584        );
585        assert_eq!(std::fs::read(outside.path().join("file"))?, b"outside");
586    }
587
588    #[cfg(unix)]
589    #[xmtp_common::test(unwrap_try = true)]
590    async fn native_open_read_rejects_symlinked_file() {
591        let root = tempfile::tempdir()?;
592        let outside = tempfile::tempdir()?;
593        std::fs::create_dir(root.path().join("key"))?;
594        std::fs::write(outside.path().join("file"), b"outside")?;
595        let store = NativeStore::new(root.path()).await?;
596        std::os::unix::fs::symlink(outside.path().join("file"), root.path().join("key/file"))?;
597
598        assert_eq!(
599            store.open_read("key/file").await.err().unwrap().cause,
600            Cause::LocalStorage
601        );
602        assert_eq!(std::fs::read(outside.path().join("file"))?, b"outside");
603    }
604
605    #[cfg(unix)]
606    #[xmtp_common::test(unwrap_try = true)]
607    async fn native_rename_rejects_symlinked_destination() {
608        let root = tempfile::tempdir()?;
609        let outside = tempfile::tempdir()?;
610        let store = NativeStore::new(root.path()).await?;
611        let mut writer = store.create_temp(".tmp/source").await?;
612        writer.write(b"source").await?;
613        store.sync(&mut writer).await?;
614        drop(writer);
615        std::os::unix::fs::symlink(outside.path(), root.path().join("key"))?;
616
617        assert_eq!(
618            store
619                .rename(".tmp/source", "key/file")
620                .await
621                .unwrap_err()
622                .cause(),
623            Cause::LocalStorage
624        );
625        assert!(!outside.path().join("file").exists());
626        assert_eq!(std::fs::read(root.path().join(".tmp/source"))?, b"source");
627    }
628
629    #[cfg(unix)]
630    #[xmtp_common::test(unwrap_try = true)]
631    async fn native_rename_rejects_symlinked_source() {
632        let root = tempfile::tempdir()?;
633        let outside = tempfile::tempdir()?;
634        std::fs::write(outside.path().join("file"), b"outside")?;
635        let store = NativeStore::new(root.path()).await?;
636        std::os::unix::fs::symlink(outside.path(), root.path().join("key"))?;
637
638        assert_eq!(
639            store
640                .rename("key/file", "safe/file")
641                .await
642                .unwrap_err()
643                .cause(),
644            Cause::LocalStorage
645        );
646        assert_eq!(std::fs::read(outside.path().join("file"))?, b"outside");
647        assert!(!root.path().join("safe/file").exists());
648    }
649
650    #[cfg(unix)]
651    #[xmtp_common::test(unwrap_try = true)]
652    async fn native_exists_rejects_symlinked_parent() {
653        let root = tempfile::tempdir()?;
654        let outside = tempfile::tempdir()?;
655        std::fs::write(outside.path().join("file"), b"outside")?;
656        let store = NativeStore::new(root.path()).await?;
657        std::os::unix::fs::symlink(outside.path(), root.path().join("key"))?;
658
659        assert_eq!(
660            store.exists("key/file").await.unwrap_err().cause,
661            Cause::LocalStorage
662        );
663        assert_eq!(std::fs::read(outside.path().join("file"))?, b"outside");
664    }
665
666    // verifies: ATCH-052
667    #[cfg(unix)]
668    #[xmtp_common::test(unwrap_try = true)]
669    async fn native_regular_file_check_uses_opened_file() {
670        let root = tempfile::tempdir()?;
671        let store = NativeStore::new(root.path()).await?;
672        std::fs::create_dir(root.path().join("key"))?;
673        std::fs::write(root.path().join("key/file"), b"plain")?;
674        let opened = store.open_read("key/file").await?;
675        // Put a directory at the path after the open.
676        std::fs::remove_file(root.path().join("key/file"))?;
677        std::fs::create_dir(root.path().join("key/file"))?;
678
679        assert!(opened.is_regular_file().await?);
680    }
681
682    #[cfg(unix)]
683    #[xmtp_common::test(unwrap_try = true)]
684    async fn native_exists_rejects_symlinked_file() {
685        let root = tempfile::tempdir()?;
686        let outside = tempfile::tempdir()?;
687        std::fs::create_dir(root.path().join("key"))?;
688        std::fs::write(outside.path().join("file"), b"outside")?;
689        let store = NativeStore::new(root.path()).await?;
690        std::os::unix::fs::symlink(outside.path().join("file"), root.path().join("key/file"))?;
691
692        assert_eq!(
693            store.exists("key/file").await.unwrap_err().cause,
694            Cause::LocalStorage
695        );
696        assert_eq!(std::fs::read(outside.path().join("file"))?, b"outside");
697    }
698
699    #[cfg(unix)]
700    #[xmtp_common::test(unwrap_try = true)]
701    async fn native_remove_dir_rejects_symlinked_parent() {
702        let root = tempfile::tempdir()?;
703        let outside = tempfile::tempdir()?;
704        std::fs::create_dir(outside.path().join("sub"))?;
705        std::fs::write(outside.path().join("sub/file"), b"outside")?;
706        let store = NativeStore::new(root.path()).await?;
707        std::os::unix::fs::symlink(outside.path(), root.path().join("key"))?;
708
709        assert_eq!(
710            store
711                .remove_dir_all("key/sub")
712                .await
713                .unwrap_err()
714                .error
715                .cause,
716            Cause::LocalStorage
717        );
718        assert_eq!(std::fs::read(outside.path().join("sub/file"))?, b"outside");
719    }
720
721    // verifies: ATCH-077
722    #[cfg(unix)]
723    #[xmtp_common::test(unwrap_try = true)]
724    async fn direct_managed_directory_removal_repairs_mode_and_checks_owner() {
725        use std::os::unix::fs::PermissionsExt as _;
726
727        struct RestoreModes(Vec<std::path::PathBuf>);
728        impl Drop for RestoreModes {
729            fn drop(&mut self) {
730                for path in &self.0 {
731                    let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700));
732                }
733            }
734        }
735
736        let root = tempfile::tempdir()?;
737        let store = NativeStore::new(root.path()).await?;
738        let full = "a".repeat(64);
739        let empty = "b".repeat(64);
740        let foreign = "c".repeat(64);
741        let full_path = root.path().join(&full);
742        let empty_path = root.path().join(&empty);
743        let foreign_path = root.path().join(&foreign);
744        std::fs::create_dir(&full_path)?;
745        std::fs::write(full_path.join("file"), b"plain")?;
746        std::fs::create_dir(&empty_path)?;
747        std::fs::create_dir(&foreign_path)?;
748        let _restore = RestoreModes(vec![full_path.clone(), empty_path.clone()]);
749        for path in [&full_path, &empty_path] {
750            std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o000))?;
751        }
752
753        store.remove_dir_all(&full).await?;
754        store.remove_empty_dir(&empty).await?;
755        assert!(!full_path.exists());
756        assert!(!empty_path.exists());
757        let foreign_store = store.with_forced_foreign_owner();
758        assert_eq!(
759            foreign_store
760                .remove_dir_all(&foreign)
761                .await
762                .expect_err("foreign-owned directory must fail")
763                .error
764                .cause,
765            Cause::LocalStorage
766        );
767        assert!(foreign_path.is_dir());
768    }
769
770    // verifies: EVENT-001
771    #[cfg(unix)]
772    #[xmtp_common::test(unwrap_try = true)]
773    async fn native_failed_removal_reports_removed_entries() {
774        use std::os::unix::fs::PermissionsExt as _;
775
776        struct RestoreMode(std::path::PathBuf);
777        impl Drop for RestoreMode {
778            fn drop(&mut self) {
779                let _ = std::fs::set_permissions(&self.0, std::fs::Permissions::from_mode(0o700));
780            }
781        }
782
783        let root = tempfile::tempdir()?;
784        let store = NativeStore::new(root.path()).await?;
785        // The file can be removed, but not the directory in a read-only root.
786        std::fs::create_dir(root.path().join("partial"))?;
787        std::fs::write(root.path().join("partial/file"), b"plain")?;
788        // Nothing can be removed from a read-only child directory.
789        std::fs::create_dir_all(root.path().join("blocked/sub"))?;
790        std::fs::write(root.path().join("blocked/sub/file"), b"plain")?;
791        let _restore = [
792            RestoreMode(root.path().to_path_buf()),
793            RestoreMode(root.path().join("blocked/sub")),
794        ];
795        std::fs::set_permissions(
796            root.path().join("blocked/sub"),
797            std::fs::Permissions::from_mode(0o500),
798        )?;
799        std::fs::set_permissions(root.path(), std::fs::Permissions::from_mode(0o500))?;
800
801        let partial = store.remove_dir_all("partial").await.unwrap_err();
802        assert_eq!(partial.error.cause, Cause::LocalStorage);
803        assert!(partial.removed_any);
804        assert!(!root.path().join("partial/file").exists());
805        assert!(root.path().join("partial").is_dir());
806
807        let blocked = store.remove_dir_all("blocked").await.unwrap_err();
808        assert_eq!(blocked.error.cause, Cause::LocalStorage);
809        assert!(!blocked.removed_any);
810        assert!(root.path().join("blocked/sub/file").exists());
811    }
812
813    #[cfg(unix)]
814    #[xmtp_common::test(unwrap_try = true)]
815    async fn native_plaintext_files_are_owner_only() {
816        use std::os::unix::fs::PermissionsExt as _;
817        let directory = tempfile::tempdir()?;
818        std::fs::set_permissions(directory.path(), std::fs::Permissions::from_mode(0o755))?;
819        let store = NativeStore::new(directory.path()).await?;
820        assert_eq!(
821            std::fs::metadata(directory.path())?.permissions().mode() & 0o777,
822            0o755
823        );
824        let mut writer = store.create_temp(".tmp/plaintext").await?;
825        writer.write(b"private").await?;
826        drop(writer);
827        for path in [".tmp", ".tmp/plaintext"] {
828            let mode = std::fs::metadata(directory.path().join(path))?
829                .permissions()
830                .mode()
831                & 0o777;
832            assert_eq!(mode, if path == ".tmp" { 0o700 } else { 0o600 });
833        }
834        store.rename(".tmp/plaintext", "key/plaintext").await?;
835        assert_eq!(
836            std::fs::metadata(directory.path().join("key"))?
837                .permissions()
838                .mode()
839                & 0o777,
840            0o700
841        );
842        assert_eq!(
843            std::fs::metadata(directory.path().join("key/plaintext"))?
844                .permissions()
845                .mode()
846                & 0o777,
847            0o600
848        );
849    }
850
851    // verifies: ATCH-077
852    #[cfg(unix)]
853    #[xmtp_common::test(unwrap_try = true)]
854    async fn existing_managed_directories_are_repaired_before_write() {
855        use std::os::unix::fs::PermissionsExt as _;
856
857        let directory = tempfile::tempdir()?;
858        let key = "a".repeat(64);
859        let other_key = "b".repeat(64);
860        std::fs::create_dir(directory.path().join(".tmp"))?;
861        std::fs::create_dir(directory.path().join(&key))?;
862        std::fs::create_dir(directory.path().join(&other_key))?;
863        for name in [".tmp", key.as_str(), other_key.as_str()] {
864            std::fs::set_permissions(
865                directory.path().join(name),
866                std::fs::Permissions::from_mode(0o770),
867            )?;
868        }
869        let root_mode = std::fs::metadata(directory.path())?.permissions().mode() & 0o777;
870        let store = NativeStore::new(directory.path()).await?;
871        let mut writer = store.create_temp(".tmp/file").await?;
872        writer.write(b"private").await?;
873        store.sync(&mut writer).await?;
874        drop(writer);
875        assert_eq!(
876            std::fs::metadata(directory.path().join(".tmp"))?
877                .permissions()
878                .mode()
879                & 0o777,
880            0o700
881        );
882        assert!(!store.create_dir_if_absent(&other_key).await?);
883        assert_eq!(
884            std::fs::metadata(directory.path().join(&other_key))?
885                .permissions()
886                .mode()
887                & 0o777,
888            0o700
889        );
890        store.rename(".tmp/file", &format!("{key}/file")).await?;
891        assert_eq!(
892            std::fs::metadata(directory.path().join(&key))?
893                .permissions()
894                .mode()
895                & 0o777,
896            0o700
897        );
898        assert_eq!(
899            std::fs::read(directory.path().join(&key).join("file"))?,
900            b"private"
901        );
902        assert_eq!(
903            std::fs::metadata(directory.path())?.permissions().mode() & 0o777,
904            root_mode
905        );
906    }
907
908    // verifies: ATCH-077
909    #[cfg(unix)]
910    #[xmtp_common::test(unwrap_try = true)]
911    async fn inaccessible_managed_directory_is_repaired_before_open() {
912        use std::os::unix::fs::PermissionsExt as _;
913
914        struct RestoreMode(std::path::PathBuf);
915        impl Drop for RestoreMode {
916            fn drop(&mut self) {
917                let _ = std::fs::set_permissions(&self.0, std::fs::Permissions::from_mode(0o700));
918            }
919        }
920
921        let directory = tempfile::tempdir()?;
922        let store = NativeStore::new(directory.path()).await?;
923        let managed = directory.path().join(".tmp");
924        std::fs::create_dir(&managed)?;
925        let _restore = RestoreMode(managed.clone());
926        std::fs::set_permissions(&managed, std::fs::Permissions::from_mode(0o000))?;
927        let mut writer = store.create_temp(".tmp/file").await?;
928        writer.write(b"private").await?;
929        store.sync(&mut writer).await?;
930        drop(writer);
931        assert_eq!(
932            std::fs::metadata(&managed)?.permissions().mode() & 0o777,
933            0o700
934        );
935        assert_eq!(std::fs::read(managed.join("file"))?, b"private");
936
937        std::fs::set_permissions(&managed, std::fs::Permissions::from_mode(0o000))?;
938        assert!(!store.create_dir_if_absent(".tmp").await?);
939        assert_eq!(
940            std::fs::metadata(&managed)?.permissions().mode() & 0o777,
941            0o700
942        );
943    }
944
945    // verifies: ATCH-077
946    #[cfg(unix)]
947    #[xmtp_common::test(unwrap_try = true)]
948    async fn reconciliation_repairs_managed_directories_before_scan() {
949        use std::os::unix::fs::PermissionsExt as _;
950
951        struct RestoreModes(Vec<std::path::PathBuf>);
952        impl Drop for RestoreModes {
953            fn drop(&mut self) {
954                for path in &self.0 {
955                    let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700));
956                }
957            }
958        }
959
960        let directory = tempfile::tempdir()?;
961        let store = NativeStore::new(directory.path()).await?;
962        let key = "a".repeat(64);
963        let tmp = directory.path().join(".tmp");
964        let key_dir = directory.path().join(&key);
965        std::fs::create_dir(&tmp)?;
966        std::fs::create_dir(&key_dir)?;
967        std::fs::write(key_dir.join("file"), b"plain")?;
968        let _restore = RestoreModes(vec![tmp.clone(), key_dir.clone()]);
969        for path in [&tmp, &key_dir] {
970            std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o000))?;
971        }
972
973        let files = store.list_files().await?;
974        assert_eq!(files.len(), 1);
975        assert_eq!(files[0].path, format!("{key}/file"));
976        for path in [&tmp, &key_dir] {
977            assert_eq!(std::fs::metadata(path)?.permissions().mode() & 0o777, 0o700);
978        }
979
980        let foreign = store.clone().with_forced_foreign_owner();
981        assert_eq!(
982            foreign
983                .list_files()
984                .await
985                .err()
986                .expect("foreign owner fails")
987                .cause,
988            Cause::LocalStorage
989        );
990
991        let outside = tempfile::tempdir()?;
992        std::fs::write(outside.path().join("outside"), b"other")?;
993        std::os::unix::fs::symlink(outside.path(), directory.path().join(".staged"))?;
994        let files = store.list_files().await?;
995        assert_eq!(files.len(), 1);
996        assert_eq!(files[0].path, format!("{key}/file"));
997        assert_eq!(std::fs::read(outside.path().join("outside"))?, b"other");
998    }
999
1000    // verifies: ATCH-077
1001    #[cfg(unix)]
1002    #[xmtp_common::test(unwrap_try = true)]
1003    async fn new_managed_directory_is_repaired_under_restrictive_umask() {
1004        use std::os::unix::fs::PermissionsExt as _;
1005
1006        struct Restore(libc::mode_t, std::path::PathBuf);
1007        impl Drop for Restore {
1008            fn drop(&mut self) {
1009                unsafe { libc::umask(self.0) };
1010                let _ = std::fs::set_permissions(&self.1, std::fs::Permissions::from_mode(0o700));
1011            }
1012        }
1013
1014        let directory = tempfile::tempdir()?;
1015        let store = NativeStore::new(directory.path()).await?;
1016        let managed = directory.path().join(".tmp");
1017        let previous = unsafe { libc::umask(0o777) };
1018        let _restore = Restore(previous, managed.clone());
1019        let mut writer = store.create_temp(".tmp/file").await?;
1020        writer.write(b"private").await?;
1021        store.sync(&mut writer).await?;
1022        drop(writer);
1023        assert_eq!(
1024            std::fs::metadata(&managed)?.permissions().mode() & 0o777,
1025            0o700
1026        );
1027        assert_eq!(std::fs::read(managed.join("file"))?, b"private");
1028    }
1029
1030    // verifies: ATCH-077
1031    #[cfg(unix)]
1032    #[xmtp_common::test(unwrap_try = true)]
1033    async fn chmod_failure_does_not_stop_native_store() {
1034        use std::os::unix::fs::PermissionsExt as _;
1035        let directory = tempfile::tempdir()?;
1036        std::fs::set_permissions(directory.path(), std::fs::Permissions::from_mode(0o755))?;
1037        let store = NativeStore::new(directory.path())
1038            .await?
1039            .with_forced_chmod_error();
1040        assert_eq!(
1041            std::fs::metadata(directory.path())?.permissions().mode() & 0o777,
1042            0o755
1043        );
1044        let mut writer = store.create_temp(".tmp/plaintext").await?;
1045        writer.write(b"private").await?;
1046        drop(writer);
1047        store.rename(".tmp/plaintext", "key/plaintext").await?;
1048        assert!(store.exists("key/plaintext").await?);
1049    }
1050
1051    // verifies: ATCH-077
1052    #[cfg(unix)]
1053    #[xmtp_common::test(unwrap_try = true)]
1054    async fn missing_native_root_is_private_with_zero_umask() {
1055        use std::os::unix::fs::PermissionsExt as _;
1056
1057        struct RestoreUmask(libc::mode_t);
1058        impl Drop for RestoreUmask {
1059            fn drop(&mut self) {
1060                unsafe { libc::umask(self.0) };
1061            }
1062        }
1063
1064        let directory = tempfile::tempdir()?;
1065        let root = directory.path().join("parent/root");
1066        let previous = unsafe { libc::umask(0) };
1067        let _restore = RestoreUmask(previous);
1068        let _store = NativeStore::new(&root).await?;
1069        for path in [&root, &directory.path().join("parent")] {
1070            assert_eq!(std::fs::metadata(path)?.permissions().mode() & 0o777, 0o700);
1071        }
1072    }
1073
1074    // verifies: ATCH-077
1075    #[cfg(unix)]
1076    #[xmtp_common::test(unwrap_try = true)]
1077    async fn existing_foreign_owned_directory_is_rejected() {
1078        let directory = tempfile::tempdir()?;
1079        tokio::fs::create_dir(directory.path().join(".tmp")).await?;
1080        let store = NativeStore::new(directory.path())
1081            .await?
1082            .with_forced_foreign_owner();
1083        let error = store
1084            .create_temp(".tmp/file")
1085            .await
1086            .err()
1087            .expect("foreign-owned directory must fail");
1088        assert_eq!(error.cause, Cause::LocalStorage);
1089        assert!(!directory.path().join(".tmp/file").exists());
1090    }
1091
1092    #[cfg(not(target_arch = "wasm32"))]
1093    #[xmtp_common::test(unwrap_try = true)]
1094    async fn native_reconcile_scan_stops_at_key_level() {
1095        let directory = tempfile::tempdir()?;
1096        let store = NativeStore::new(directory.path()).await?;
1097        let key = "a".repeat(64);
1098        for path in [
1099            format!("{key}/plain.txt"),
1100            format!("{key}/nested/deep.txt"),
1101            "app/file.txt".to_owned(),
1102            ".DS_Store".to_owned(),
1103        ] {
1104            let path = directory.path().join(path);
1105            tokio::fs::create_dir_all(path.parent().unwrap()).await?;
1106            tokio::fs::write(path, b"file").await?;
1107        }
1108        let files = store.list_files().await?;
1109        assert_eq!(files.len(), 1);
1110        assert_eq!(files[0].path, format!("{key}/plain.txt"));
1111    }
1112
1113    // verifies: ATCH-076
1114    #[cfg(not(target_arch = "wasm32"))]
1115    #[xmtp_common::test(unwrap_try = true)]
1116    async fn native_reconcile_scan_reports_modified_time_error() {
1117        let directory = tempfile::tempdir()?;
1118        let store = NativeStore::new(directory.path()).await?;
1119        let key = "a".repeat(64);
1120        tokio::fs::create_dir(directory.path().join(&key)).await?;
1121        tokio::fs::write(directory.path().join(key).join("plain"), b"file").await?;
1122        let error = store
1123            .with_forced_modified_error()
1124            .list_files()
1125            .await
1126            .err()
1127            .expect("unreadable modification time must fail");
1128        assert_eq!(error.cause, Cause::LocalStorage);
1129    }
1130
1131    #[cfg(not(target_arch = "wasm32"))]
1132    #[xmtp_common::test(unwrap_try = true)]
1133    async fn native_writer_reset_rewinds() {
1134        let directory = tempfile::tempdir()?;
1135        let store = NativeStore::new(directory.path()).await?;
1136        let mut writer = store.create_temp(".tmp/repeated").await?;
1137        writer.write_content(ContentChunk::Bytes(b"first")).await?;
1138        writer.write_content(ContentChunk::Reset).await?;
1139        writer.write_content(ContentChunk::Bytes(b"second")).await?;
1140        store.sync(&mut writer).await?;
1141        drop(writer);
1142        assert_eq!(
1143            tokio::fs::read(directory.path().join(".tmp/repeated")).await?,
1144            b"second"
1145        );
1146    }
1147
1148    #[cfg(not(target_arch = "wasm32"))]
1149    #[xmtp_common::test(unwrap_try = true)]
1150    async fn native_rename_refuses_existing_destination() {
1151        let directory = tempfile::tempdir()?;
1152        let store = NativeStore::new(directory.path()).await?;
1153        let mut source = store.create_temp(".tmp/source").await?;
1154        source.write(b"source").await?;
1155        store.sync(&mut source).await?;
1156        drop(source);
1157        let mut destination = store.create_temp(".tmp/destination").await?;
1158        destination.write(b"destination").await?;
1159        store.sync(&mut destination).await?;
1160        drop(destination);
1161        let error = store
1162            .rename(".tmp/source", ".tmp/destination")
1163            .await
1164            .unwrap_err();
1165        assert!(matches!(error, StoreMoveError::DestinationExists));
1166        assert_eq!(
1167            tokio::fs::read(directory.path().join(".tmp/source")).await?,
1168            b"source"
1169        );
1170        assert_eq!(
1171            tokio::fs::read(directory.path().join(".tmp/destination")).await?,
1172            b"destination"
1173        );
1174    }
1175
1176    #[cfg(any(target_vendor = "apple", target_os = "linux", target_os = "android"))]
1177    #[xmtp_common::test(unwrap_try = true)]
1178    async fn native_rename_falls_back_without_hard_links() {
1179        let directory = tempfile::tempdir()?;
1180        let store = NativeStore::new(directory.path())
1181            .await?
1182            .with_forced_hard_link_error(std::io::ErrorKind::PermissionDenied);
1183        let mut source = store.create_temp(".tmp/source").await?;
1184        source.write(b"source").await?;
1185        store.sync(&mut source).await?;
1186        drop(source);
1187        store.rename(".tmp/source", "key/file").await?;
1188        assert!(!store.exists(".tmp/source").await?);
1189        assert_eq!(
1190            tokio::fs::read(directory.path().join("key/file")).await?,
1191            b"source"
1192        );
1193
1194        let mut source = store.create_temp(".tmp/second").await?;
1195        source.write(b"second").await?;
1196        store.sync(&mut source).await?;
1197        drop(source);
1198        let error = store.rename(".tmp/second", "key/file").await.unwrap_err();
1199        assert!(matches!(error, StoreMoveError::DestinationExists));
1200        assert_eq!(
1201            tokio::fs::read(directory.path().join("key/file")).await?,
1202            b"source"
1203        );
1204        assert_eq!(
1205            tokio::fs::read(directory.path().join(".tmp/second")).await?,
1206            b"second"
1207        );
1208    }
1209
1210    #[cfg(windows)]
1211    #[xmtp_common::test(unwrap_try = true)]
1212    async fn windows_rename_fallback_moves_without_replacing() {
1213        let directory = tempfile::tempdir()?;
1214        let store = NativeStore::new(directory.path())
1215            .await?
1216            .with_forced_hard_link_error(std::io::ErrorKind::Unsupported);
1217        let mut source = store.create_temp(".tmp/source").await?;
1218        source.write(b"source").await?;
1219        store.sync(&mut source).await?;
1220        drop(source);
1221        store.rename(".tmp/source", "key/file").await?;
1222        assert!(!store.exists(".tmp/source").await?);
1223        assert_eq!(std::fs::read(directory.path().join("key/file"))?, b"source");
1224
1225        let racing = store.with_fallback_destination_race(b"destination".to_vec());
1226        let mut source = racing.create_temp(".tmp/second").await?;
1227        source.write(b"second").await?;
1228        racing.sync(&mut source).await?;
1229        drop(source);
1230        assert_eq!(
1231            racing
1232                .rename(".tmp/second", "key/raced")
1233                .await
1234                .unwrap_err()
1235                .cause,
1236            Cause::LocalStorage
1237        );
1238        assert_eq!(
1239            std::fs::read(directory.path().join("key/raced"))?,
1240            b"destination"
1241        );
1242        assert_eq!(
1243            std::fs::read(directory.path().join(".tmp/second"))?,
1244            b"second"
1245        );
1246    }
1247
1248    #[cfg(not(target_arch = "wasm32"))]
1249    #[xmtp_common::test(unwrap_try = true)]
1250    async fn native_rename_fallback_never_replaces_racing_destination() {
1251        let directory = tempfile::tempdir()?;
1252        let store = NativeStore::new(directory.path())
1253            .await?
1254            .with_forced_hard_link_error(std::io::ErrorKind::PermissionDenied)
1255            .with_fallback_destination_race(b"destination".to_vec());
1256        let mut source = store.create_temp(".tmp/source").await?;
1257        source.write(b"source").await?;
1258        store.sync(&mut source).await?;
1259        drop(source);
1260
1261        assert_eq!(
1262            store
1263                .rename(".tmp/source", "key/file")
1264                .await
1265                .unwrap_err()
1266                .cause(),
1267            Cause::LocalStorage
1268        );
1269        assert_eq!(
1270            std::fs::read(directory.path().join("key/file"))?,
1271            b"destination"
1272        );
1273        assert_eq!(
1274            std::fs::read(directory.path().join(".tmp/source"))?,
1275            b"source"
1276        );
1277    }
1278
1279    #[cfg(not(target_arch = "wasm32"))]
1280    #[xmtp_common::test(unwrap_try = true)]
1281    async fn native_store_root_is_absolute() {
1282        use std::path::PathBuf;
1283
1284        struct RestoreCwd(PathBuf);
1285        impl Drop for RestoreCwd {
1286            fn drop(&mut self) {
1287                let _ = std::env::set_current_dir(&self.0);
1288            }
1289        }
1290
1291        let original = std::env::current_dir()?;
1292        let directory = tempfile::tempdir_in(&original)?;
1293        let relative = directory.path().strip_prefix(&original)?;
1294        let store = NativeStore::new(relative).await?;
1295        let elsewhere = tempfile::tempdir()?;
1296        let _restore = RestoreCwd(original);
1297        std::env::set_current_dir(elsewhere.path())?;
1298        let mut writer = store.create_temp(".tmp/file").await?;
1299        writer.write(b"original root").await?;
1300        store.sync(&mut writer).await?;
1301        drop(writer);
1302        assert_eq!(
1303            tokio::fs::read(directory.path().join(".tmp/file")).await?,
1304            b"original root"
1305        );
1306        assert!(!elsewhere.path().join(relative).join(".tmp/file").exists());
1307    }
1308
1309    #[cfg(unix)]
1310    #[xmtp_common::test(unwrap_try = true)]
1311    async fn native_created_directory_modes_ignore_umask() {
1312        use std::os::unix::fs::PermissionsExt;
1313
1314        let base = tempfile::tempdir()?;
1315        let app = base.path().join("existing-app-dir");
1316        std::fs::create_dir(&app)?;
1317        std::fs::set_permissions(&app, std::fs::Permissions::from_mode(0o755))?;
1318        let executable = std::env::current_exe()?;
1319        for (mask, suffix) in [("0177", "restrictive"), ("0000", "open")] {
1320            let root = app.join(suffix).join("nested").join("attachments");
1321            let output = std::process::Command::new("sh")
1322                .arg("-c")
1323                .arg(format!(
1324                    "umask {mask}; exec \"$1\" --exact store::tests::native_directory_mode_child"
1325                ))
1326                .arg("sh")
1327                .arg(&executable)
1328                .env("XMTP_TEST_ATTACHMENTS_ROOT", &root)
1329                .output()?;
1330            assert!(
1331                output.status.success(),
1332                "umask {mask}: {}",
1333                String::from_utf8_lossy(&output.stdout)
1334            );
1335            for directory in [
1336                app.join(suffix),
1337                app.join(suffix).join("nested"),
1338                root.clone(),
1339                root.join(".tmp"),
1340                root.join("key"),
1341            ] {
1342                assert_eq!(
1343                    std::fs::metadata(&directory)?.permissions().mode() & 0o777,
1344                    0o700,
1345                    "{} under umask {mask}",
1346                    directory.display()
1347                );
1348            }
1349            assert_eq!(
1350                std::fs::metadata(root.join(".tmp/file"))?
1351                    .permissions()
1352                    .mode()
1353                    & 0o777,
1354                0o600,
1355                "file under umask {mask}"
1356            );
1357            assert_eq!(std::fs::metadata(&app)?.permissions().mode() & 0o777, 0o755);
1358        }
1359    }
1360
1361    #[cfg(unix)]
1362    #[xmtp_common::test(unwrap_try = true)]
1363    async fn native_directory_mode_child() {
1364        let Some(root) = std::env::var_os("XMTP_TEST_ATTACHMENTS_ROOT") else {
1365            return;
1366        };
1367        let verify_initial = std::env::var_os("XMTP_TEST_INITIAL_MODES").is_some();
1368        if verify_initial {
1369            super::native::take_initial_modes();
1370        }
1371        let store = NativeStore::new(std::path::PathBuf::from(root)).await?;
1372        assert!(store.create_dir_if_absent("key").await?);
1373        let mut writer = store.create_temp(".tmp/file").await?;
1374        writer.write(b"private").await?;
1375        store.sync(&mut writer).await?;
1376        if verify_initial {
1377            let modes = super::native::take_initial_modes();
1378            assert_eq!(
1379                modes
1380                    .iter()
1381                    .filter(|(_, expected)| *expected == 0o700)
1382                    .count(),
1383                5
1384            );
1385            assert_eq!(
1386                modes
1387                    .iter()
1388                    .filter(|(_, expected)| *expected == 0o600)
1389                    .count(),
1390                1
1391            );
1392            for (actual, expected) in modes {
1393                assert_eq!(
1394                    actual & !expected,
1395                    0,
1396                    "initial mode {actual:o} exceeds {expected:o}"
1397                );
1398            }
1399        }
1400    }
1401
1402    #[cfg(unix)]
1403    #[xmtp_common::test(unwrap_try = true)]
1404    async fn native_initial_modes_never_exceed_private() {
1405        let base = tempfile::tempdir()?;
1406        let root = base.path().join("new").join("nested").join("attachments");
1407        let output = std::process::Command::new("sh")
1408            .arg("-c")
1409            .arg("umask 0000; exec \"$1\" --exact store::tests::native_directory_mode_child")
1410            .arg("sh")
1411            .arg(std::env::current_exe()?)
1412            .env("XMTP_TEST_ATTACHMENTS_ROOT", &root)
1413            .env("XMTP_TEST_INITIAL_MODES", "1")
1414            .output()?;
1415        assert!(
1416            output.status.success(),
1417            "{}{}",
1418            String::from_utf8_lossy(&output.stdout),
1419            String::from_utf8_lossy(&output.stderr)
1420        );
1421    }
1422
1423    #[cfg(not(target_arch = "wasm32"))]
1424    #[xmtp_common::test(unwrap_try = true)]
1425    async fn rename_rolls_back_when_source_unlink_fails() {
1426        let directory = tempfile::tempdir()?;
1427        let store = NativeStore::new(directory.path())
1428            .await?
1429            .with_forced_source_unlink_error(std::io::ErrorKind::PermissionDenied);
1430        let mut writer = store.create_temp(".tmp/source").await?;
1431        writer.write(b"saved").await?;
1432        drop(writer);
1433        assert_eq!(
1434            store
1435                .rename(".tmp/source", "key/final")
1436                .await
1437                .unwrap_err()
1438                .cause(),
1439            Cause::LocalStorage
1440        );
1441        assert!(store.exists(".tmp/source").await?);
1442        assert!(!store.exists("key/final").await?);
1443    }
1444}