1use 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
20pub const CHUNK_SIZE: usize = 64 * 1024;
22
23#[cfg(target_arch = "wasm32")]
24const MAX_EXACT_JS_OFFSET: u64 = (1_u64 << 53) - 1;
25
26pub 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
47pub 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#[derive(Clone)]
83pub struct StagedFile {
84 #[cfg(not(target_arch = "wasm32"))]
85 pub(crate) path: std::path::PathBuf,
86 #[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
100pub 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 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
142pub struct StoreFile {
144 pub path: String,
145 pub modified_at_ns: i64,
146}
147
148#[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#[derive(Debug, thiserror::Error)]
168#[error("{error}")]
169pub struct StoreRemoveError {
170 pub error: AttachmentError,
171 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 async fn is_regular_file(&self, path: &str) -> Result<bool, AttachmentError> {
197 self.open_read(path).await?.is_regular_file().await
198 }
199 async fn create_temp(&self, path: &str) -> Result<StoreWriter, AttachmentError>;
202 async fn create_dir_if_absent(&self, path: &str) -> Result<bool, AttachmentError>;
204 async fn rename(&self, from: &str, to: &str) -> Result<(), StoreMoveError>;
206 async fn replace(&self, from: &str, to: &str) -> Result<(), AttachmentError>;
208 async fn remove_dir_all(&self, path: &str) -> Result<(), StoreRemoveError>;
211 async fn prepare_remove_dir(&self, path: &str) -> Result<bool, AttachmentError>;
215 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 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 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 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 #[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 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#[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#[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#[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#[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 #[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 #[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 #[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 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 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 #[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 #[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 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 #[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 #[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 std::fs::create_dir(root.path().join("partial"))?;
787 std::fs::write(root.path().join("partial/file"), b"plain")?;
788 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}