diff --git a/lib/cache/async_backed.rs b/lib/cache/async_backed.rs index dba1721..5467acd 100644 --- a/lib/cache/async_backed.rs +++ b/lib/cache/async_backed.rs @@ -395,7 +395,14 @@ where /// /// Suitable for seeding the cache before async operations begin. pub fn insert_sync(&self, key: K, value: V) { - drop(self.map.insert_sync(key, Slot::Ready(value))); + match self.map.entry_sync(key) { + scc::hash_map::Entry::Occupied(mut occ) => { + *occ.get_mut() = Slot::Ready(value); + } + scc::hash_map::Entry::Vacant(vac) => { + vac.insert_entry(Slot::Ready(value)); + } + } } /// Synchronously remove the entry for `key`, returning `true` if it was present. @@ -419,6 +426,14 @@ where }) .is_some() } + + /// Synchronously check whether an entry exists for `key`. + /// + /// Returns `true` for both `Ready` and `InFlight` entries. + #[must_use] + pub fn contains_sync(&self, key: &K) -> bool { + self.map.read_sync(key, |_, _| ()).is_some() + } } /// Drop guard that synchronously promotes an `InFlight` entry to `Ready` if the caller diff --git a/lib/fs/async_fs.rs b/lib/fs/async_fs.rs index 8aa4bd4..d5654be 100644 --- a/lib/fs/async_fs.rs +++ b/lib/fs/async_fs.rs @@ -83,6 +83,19 @@ pub trait FsDataProvider: Clone + Send + Sync + 'static { /// Never called directly -- [`InodeForget::delete`] invokes it /// automatically when the refcount drops to zero. fn forget(&self, _addr: InodeAddr) {} + + /// Write data to a file at the given offset. + /// + /// The default implementation returns `EROFS` (read-only filesystem). + /// Providers that support writes should override this. + fn write( + &self, + _inode: INode, + _offset: u64, + _data: Bytes, + ) -> impl Future> + Send { + async { Err(std::io::Error::from_raw_os_error(libc::EROFS)) } + } } /// Zero-sized cleanup tag for inode eviction. @@ -114,17 +127,25 @@ pub struct ForgetContext { pub lookup_cache: Arc, /// The data provider for provider-specific cleanup. pub provider: DP, + /// Write overlay — inodes present here must not be evicted. + pub write_overlay: Arc>, } /// Evicts the inode from the table, directory cache, and lookup cache, then /// delegates to [`FsDataProvider::forget`] so the provider can clean up its /// own auxiliary state. /// +/// Inodes that have locally-written data (present in `write_overlay`) are +/// skipped — they must persist for the lifetime of the mount. +/// /// The lookup cache cleanup removes all entries referencing the forgotten /// inode (as parent or child) via the [`IndexedLookupCache`]'s reverse /// index, ensuring O(k) eviction instead of O(N) full-cache scan. impl StatelessDrop, InodeAddr> for InodeForget { fn delete(ctx: &ForgetContext, key: &InodeAddr) { + if ctx.write_overlay.contains_sync(key) { + return; + } let addr = *key; ctx.inode_table.remove_sync(key); ctx.dcache.evict(LoadedAddr::new_unchecked(addr)); @@ -144,6 +165,42 @@ pub struct ResolvedINode { pub inode: INode, } +/// A file reader that checks the write overlay first, falling back to the +/// underlying provider reader. +pub struct OverlayReader { + /// The inode address this reader is for. + pub addr: InodeAddr, + write_overlay: Arc>, + inner: R, +} + +impl std::fmt::Debug for OverlayReader { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OverlayReader") + .field("addr", &self.addr) + .finish_non_exhaustive() + } +} + +impl FileReader for OverlayReader { + #[expect( + clippy::cast_possible_truncation, + reason = "offset and size fit in usize on supported 64-bit platforms" + )] + async fn read(&self, offset: u64, size: u32) -> Result { + if let Some(data) = self.write_overlay.get(&self.addr).await { + let start = (offset as usize).min(data.len()); + let end = (start + size as usize).min(data.len()); + return Ok(data.slice(start..end)); + } + self.inner.read(offset, size).await + } + + async fn close(&self) -> Result<(), std::io::Error> { + self.inner.close().await + } +} + /// An open file that provides read access. /// /// Returned by [`AsyncFs::open`]. The caller owns this handle and uses @@ -337,6 +394,13 @@ pub struct AsyncFs { /// Bounds the number of concurrent background prefetch tasks. prefetch_semaphore: Arc, + + /// Overlay cache for locally-written file data. + /// + /// Keyed by inode address, valued by the full file content. Written + /// entries are never evicted by `InodeForget` — they persist for the + /// lifetime of the mount. + write_overlay: Arc>, } impl AsyncFs { @@ -357,6 +421,7 @@ impl AsyncFs { data_provider, next_fh: AtomicU64::new(1), prefetch_semaphore: Arc::new(Semaphore::new(MAX_PREFETCH_CONCURRENCY)), + write_overlay: Arc::new(FutureBackedCache::default()), } } @@ -376,6 +441,7 @@ impl AsyncFs { data_provider, next_fh: AtomicU64::new(1), prefetch_semaphore: Arc::new(Semaphore::new(MAX_PREFETCH_CONCURRENCY)), + write_overlay: Arc::new(FutureBackedCache::default()), } } @@ -528,25 +594,96 @@ impl AsyncFs { /// Open a file for reading. /// /// Validates the inode is not a directory, delegates to the data provider - /// to create a [`FileReader`], and returns an [`OpenFile`] that the caller - /// owns. Reads go through [`OpenFile::read`]. + /// to create a [`FileReader`], and returns an [`OpenFile`] wrapping an + /// [`OverlayReader`] that checks the write overlay first, falling back + /// to the provider reader. pub async fn open( &self, addr: LoadedAddr, flags: OpenFlags, - ) -> Result, std::io::Error> { + ) -> Result>, std::io::Error> { let inode = self.loaded_inode(addr).await?; if inode.itype == INodeType::Directory { return Err(std::io::Error::from_raw_os_error(libc::EISDIR)); } let reader = self.data_provider.open(inode, flags).await?; + let overlay_reader = OverlayReader { + addr: addr.addr(), + write_overlay: Arc::clone(&self.write_overlay), + inner: reader, + }; let fh = self.next_fh.fetch_add(1, Ordering::Relaxed); Ok(OpenFile { fh, - reader: Arc::new(reader), + reader: Arc::new(overlay_reader), }) } + /// Write data to a file at the given offset. + /// + /// Stores the result in the write overlay cache. The overlay takes + /// precedence over the data provider on subsequent reads. Also calls + /// [`FsDataProvider::write`] so the provider can log or forward as needed. + pub async fn write( + &self, + addr: LoadedAddr, + offset: u64, + data: Bytes, + ) -> Result { + let inode = self.loaded_inode(addr).await?; + if inode.itype == INodeType::Directory { + return Err(std::io::Error::from_raw_os_error(libc::EISDIR)); + } + + #[expect( + clippy::cast_possible_truncation, + reason = "data.len() fits in u32 (FUSE writes are at most 128 KiB)" + )] + let bytes_written = data.len() as u32; + + // Merge with existing overlay content, if any. + let existing = self.write_overlay.get(&addr.addr()).await; + let mut buf = existing.map_or_else(Vec::new, |b| b.to_vec()); + + #[expect( + clippy::cast_possible_truncation, + reason = "offset fits in usize on supported 64-bit platforms" + )] + let offset_usize = offset as usize; + if offset_usize > buf.len() { + buf.resize(offset_usize, 0); + } + let end = offset_usize + data.len(); + if end > buf.len() { + buf.resize(end, 0); + } + buf[offset_usize..end].copy_from_slice(&data); + + let new_content = Bytes::from(buf); + let new_size = new_content.len() as u64; + self.write_overlay.insert_sync(addr.addr(), new_content); + + // Update inode size in the table. + let mut updated = inode; + updated.size = new_size; + updated.last_modified_at = std::time::SystemTime::now(); + self.inode_table.insert_sync(addr.addr(), updated); + + // Notify the data provider (for logging / future forwarding). + drop(self.data_provider.write(inode, offset, data).await); + + Ok(bytes_written) + } + + /// Returns a clone of the write overlay handle. + /// + /// Used by the FUSE adapter to pass into `ForgetContext` so that + /// `InodeForget` can check whether an inode has locally-written data. + #[must_use] + pub fn write_overlay(&self) -> Arc> { + Arc::clone(&self.write_overlay) + } + /// Evict an inode from the inode table and notify the data provider. /// /// Called by the composite layer when propagating `forget` to a child diff --git a/lib/fs/composite.rs b/lib/fs/composite.rs index ef7c921..473e6ff 100644 --- a/lib/fs/composite.rs +++ b/lib/fs/composite.rs @@ -14,7 +14,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use bytes::Bytes; use crate::cache::async_backed::FutureBackedCache; -use crate::fs::async_fs::{AsyncFs, FileReader, FsDataProvider, OpenFile}; +use crate::fs::async_fs::{AsyncFs, FileReader, FsDataProvider, OpenFile, OverlayReader}; use crate::fs::bridge::ConcurrentBridge; use crate::fs::{INode, INodeType, InodeAddr, InodePerms, LoadedAddr, OpenFlags, ROOT_INO}; @@ -283,7 +283,8 @@ where R::ChildDP: Clone, <::ChildDP as FsDataProvider>::Reader: 'static, { - type Reader = CompositeReader<<::ChildDP as FsDataProvider>::Reader>; + type Reader = + CompositeReader::ChildDP as FsDataProvider>::Reader>>; async fn lookup(&self, parent: INode, name: &OsStr) -> Result { if parent.addr == ROOT_INO { @@ -416,7 +417,9 @@ where let inner_ino = inner_ino.ok_or_else(|| std::io::Error::from_raw_os_error(libc::ENOENT))?; - let open_file: OpenFile<<::ChildDP as FsDataProvider>::Reader> = child + let open_file: OpenFile< + OverlayReader<<::ChildDP as FsDataProvider>::Reader>, + > = child .get_fs() .open(LoadedAddr::new_unchecked(inner_ino), flags) .await?; @@ -426,6 +429,34 @@ where }) } + async fn write(&self, inode: INode, offset: u64, data: Bytes) -> Result { + if inode.addr == ROOT_INO { + return Err(std::io::Error::from_raw_os_error(libc::EROFS)); + } + + let slot_idx = self + .inner + .addr_to_slot + .read_sync(&inode.addr, |_, &v| v) + .ok_or_else(|| std::io::Error::from_raw_os_error(libc::ENOENT))?; + + let (child, inner_addr) = self + .inner + .slots + .read_sync(&slot_idx, |_, slot| { + (Arc::clone(&slot.inner), slot.bridge.forward(inode.addr)) + }) + .ok_or_else(|| std::io::Error::from_raw_os_error(libc::ENOENT))?; + + let inner_addr = + inner_addr.ok_or_else(|| std::io::Error::from_raw_os_error(libc::ENOENT))?; + + child + .get_fs() + .write(LoadedAddr::new_unchecked(inner_addr), offset, data) + .await + } + /// Removes the composite-level address from the child's bridge map and /// then from `addr_to_slot`. When the bridge becomes empty, the slot /// and its `name_to_slot` entry are garbage-collected. diff --git a/lib/fs/fuser.rs b/lib/fs/fuser.rs index 61e4a93..04d0b8c 100644 --- a/lib/fs/fuser.rs +++ b/lib/fs/fuser.rs @@ -4,7 +4,9 @@ use std::collections::HashMap; use std::ffi::{OsStr, OsString}; use std::sync::Arc; -use super::async_fs::{FileReader as _, FsDataProvider}; +use bytes::Bytes; + +use super::async_fs::{FileReader as _, FsDataProvider, OverlayReader}; use super::{FileHandle, INode, INodeType, InodeAddr, LoadedAddr, OpenFlags}; use crate::cache::async_backed::FutureBackedCache; use tracing::{debug, error, instrument}; @@ -47,6 +49,7 @@ impl_fuse_reply!( fuser::ReplyDirectory, fuser::ReplyOpen, fuser::ReplyData, + fuser::ReplyWrite, ); /// Extension trait on `Result` for FUSE reply handling. @@ -89,6 +92,7 @@ impl FuseBridgeInner { dcache: fs.directory_cache(), lookup_cache: fs.lookup_cache(), provider, + write_overlay: fs.write_overlay(), }; let ward = crate::drop_ward::DropWard::new(ctx); Self { ward, fs } @@ -159,7 +163,7 @@ enum DirSnapshot { /// for blocking on async ops. pub struct FuserAdapter { inner: FuseBridgeInner, - open_files: HashMap>, + open_files: HashMap>>, dir_handles: HashMap, next_dir_fh: u64, runtime: tokio::runtime::Handle, @@ -409,6 +413,57 @@ impl fuser::Filesystem for FuserAdapter { }); } + #[instrument( + name = "FuserAdapter::write", + skip( + self, + _req, + _ino, + fh, + offset, + data, + _write_flags, + _flags, + _lock_owner, + reply + ) + )] + fn write( + &mut self, + _req: &fuser::Request<'_>, + _ino: u64, + fh: u64, + offset: i64, + data: &[u8], + _write_flags: u32, + _flags: i32, + _lock_owner: Option, + reply: fuser::ReplyWrite, + ) { + let Some(reader) = self.open_files.get(&fh) else { + reply.error(libc::EBADF); + return; + }; + let addr = reader.addr; + let data = Bytes::from(data.to_vec()); + + self.runtime + .block_on(async { + self.inner + .get_fs() + .write( + LoadedAddr::new_unchecked(addr), + offset.cast_unsigned(), + data, + ) + .await + }) + .fuse_reply(reply, |written, reply| { + debug!(bytes_written = written, "replying..."); + reply.written(written); + }); + } + #[instrument( name = "FuserAdapter::release", skip(self, _req, _ino, fh, _flags, _lock_owner, _flush, reply) diff --git a/src/fs/mescloud/roots.rs b/src/fs/mescloud/roots.rs index bdd676e..17c7543 100644 --- a/src/fs/mescloud/roots.rs +++ b/src/fs/mescloud/roots.rs @@ -12,12 +12,13 @@ use std::sync::Arc; use std::time::SystemTime; use base64::Engine as _; +use bytes::Bytes; use futures::TryStreamExt as _; use mesa_dev::MesaClient; use tracing::warn; use git_fs::cache::fcache::FileCache; -use git_fs::fs::async_fs::{FileReader, FsDataProvider}; +use git_fs::fs::async_fs::{FileReader, FsDataProvider, OverlayReader}; use git_fs::fs::composite::{ChildDescriptor, CompositeFs, CompositeReader, CompositeRoot}; use git_fs::fs::{INode, INodeType, InodeAddr, InodePerms, OpenFlags, ROOT_INO}; @@ -392,6 +393,21 @@ impl FsDataProvider for OrgChildDP { } } + fn write( + &self, + inode: INode, + offset: u64, + data: Bytes, + ) -> impl Future> + Send { + let this = self.clone(); + async move { + match this { + Self::Standard(c) => c.write(inode, offset, data).await, + Self::Github(c) => c.write(inode, offset, data).await, + } + } + } + fn forget(&self, addr: InodeAddr) { match self { Self::Standard(c) => c.forget(addr), @@ -401,8 +417,8 @@ impl FsDataProvider for OrgChildDP { } pub enum OrgChildReader { - Standard(CompositeReader), - Github(CompositeReader>), + Standard(CompositeReader>), + Github(CompositeReader>>>), } impl std::fmt::Debug for OrgChildReader { @@ -419,7 +435,7 @@ impl FileReader for OrgChildReader { &self, offset: u64, size: u32, - ) -> impl Future> + Send { + ) -> impl Future> + Send { match self { Self::Standard(r) => futures::future::Either::Left(r.read(offset, size)), Self::Github(r) => futures::future::Either::Right(r.read(offset, size)), diff --git a/tests/async_fs_write_tests.rs b/tests/async_fs_write_tests.rs new file mode 100644 index 0000000..4835e93 --- /dev/null +++ b/tests/async_fs_write_tests.rs @@ -0,0 +1,338 @@ +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::doc_markdown, + clippy::similar_names, + clippy::iter_on_single_items, + missing_docs +)] + +mod common; + +use std::sync::Arc; + +use bytes::Bytes; +use git_fs::cache::async_backed::FutureBackedCache; +use git_fs::drop_ward::StatelessDrop as _; +use git_fs::fs::async_fs::{AsyncFs, ForgetContext, FsDataProvider as _}; +use git_fs::fs::{INodeType, InodeForget, LoadedAddr, OpenFlags}; + +use common::async_fs_mocks::{MockFsDataProvider, MockFsState, make_inode}; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn write_method_exists_on_provider() { + let _root = make_inode(1, INodeType::Directory, 0, None); + let file = make_inode(2, INodeType::File, 0, Some(1)); + + let state = MockFsState { + file_contents: [(2, Bytes::from_static(b""))].into_iter().collect(), + ..MockFsState::default() + }; + let provider = MockFsDataProvider::new(state); + + let result = provider.write(file, 0, Bytes::from_static(b"hello")).await; + assert!(result.is_ok()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn async_fs_write_stores_data_in_overlay() { + let root = make_inode(1, INodeType::Directory, 0, None); + let file = make_inode(2, INodeType::File, 0, Some(1)); + + let state = MockFsState { + lookups: [((1, "test.txt".into()), file)].into_iter().collect(), + directories: [(1, vec![("test.txt".into(), file)])].into_iter().collect(), + file_contents: [(2, Bytes::from_static(b"original"))].into_iter().collect(), + ..MockFsState::default() + }; + let provider = MockFsDataProvider::new(state); + let table = Arc::new(FutureBackedCache::default()); + let fs = AsyncFs::new(provider, root, Arc::clone(&table)).await; + + // Load the file inode into the table via lookup. + let _ = fs + .lookup(LoadedAddr::new_unchecked(1), "test.txt".as_ref()) + .await + .unwrap(); + + // Write to the overlay + let written = fs + .write( + LoadedAddr::new_unchecked(2), + 0, + Bytes::from_static(b"new content"), + ) + .await + .unwrap(); + assert_eq!(written, 11); + + // Read back — should return the overlay data, not "original" + let open = fs + .open(LoadedAddr::new_unchecked(2), OpenFlags::RDONLY) + .await + .unwrap(); + let data = open.read(0, 1024).await.unwrap(); + assert_eq!(&data[..], b"new content"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn write_at_offset_merges_correctly() { + let root = make_inode(1, INodeType::Directory, 0, None); + let file = make_inode(2, INodeType::File, 0, Some(1)); + + let state = MockFsState { + lookups: [((1, "test.txt".into()), file)].into_iter().collect(), + directories: [(1, vec![("test.txt".into(), file)])].into_iter().collect(), + file_contents: [(2, Bytes::from_static(b""))].into_iter().collect(), + ..MockFsState::default() + }; + let provider = MockFsDataProvider::new(state); + let table = Arc::new(FutureBackedCache::default()); + let fs = AsyncFs::new(provider, root, Arc::clone(&table)).await; + + let _ = fs + .lookup(LoadedAddr::new_unchecked(1), "test.txt".as_ref()) + .await + .unwrap(); + + // Write "hello" at offset 0 + fs.write( + LoadedAddr::new_unchecked(2), + 0, + Bytes::from_static(b"hello"), + ) + .await + .unwrap(); + + // Write " world" at offset 5 + fs.write( + LoadedAddr::new_unchecked(2), + 5, + Bytes::from_static(b" world"), + ) + .await + .unwrap(); + + // Read back + let open = fs + .open(LoadedAddr::new_unchecked(2), OpenFlags::RDONLY) + .await + .unwrap(); + let data = open.read(0, 1024).await.unwrap(); + assert_eq!(&data[..], b"hello world"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn write_updates_inode_size() { + let root = make_inode(1, INodeType::Directory, 0, None); + let file = make_inode(2, INodeType::File, 0, Some(1)); + + let state = MockFsState { + lookups: [((1, "test.txt".into()), file)].into_iter().collect(), + directories: [(1, vec![("test.txt".into(), file)])].into_iter().collect(), + file_contents: [(2, Bytes::from_static(b""))].into_iter().collect(), + ..MockFsState::default() + }; + let provider = MockFsDataProvider::new(state); + let table = Arc::new(FutureBackedCache::default()); + let fs = AsyncFs::new(provider, root, Arc::clone(&table)).await; + + let _ = fs + .lookup(LoadedAddr::new_unchecked(1), "test.txt".as_ref()) + .await + .unwrap(); + + fs.write( + LoadedAddr::new_unchecked(2), + 0, + Bytes::from_static(b"12345"), + ) + .await + .unwrap(); + + let inode = fs.getattr(LoadedAddr::new_unchecked(2)).await.unwrap(); + assert_eq!(inode.size, 5); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn write_to_directory_returns_eisdir() { + let root = make_inode(1, INodeType::Directory, 0, None); + + let state = MockFsState::default(); + let provider = MockFsDataProvider::new(state); + let table = Arc::new(FutureBackedCache::default()); + let fs = AsyncFs::new(provider, root, Arc::clone(&table)).await; + + let result = fs + .write(LoadedAddr::new_unchecked(1), 0, Bytes::from_static(b"data")) + .await; + assert!(result.is_err()); + assert_eq!(result.unwrap_err().raw_os_error(), Some(libc::EISDIR)); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn written_inode_survives_forget() { + let root = make_inode(1, INodeType::Directory, 0, None); + let file = make_inode(2, INodeType::File, 10, Some(1)); + + let state = MockFsState { + lookups: [((1, "test.txt".into()), file)].into_iter().collect(), + directories: [(1, vec![("test.txt".into(), file)])].into_iter().collect(), + file_contents: [(2, Bytes::from_static(b"original"))].into_iter().collect(), + ..MockFsState::default() + }; + let provider = MockFsDataProvider::new(state); + let table = Arc::new(FutureBackedCache::default()); + let fs = AsyncFs::new(provider.clone(), root, Arc::clone(&table)).await; + + // Look up the file to load it into the table. + let _ = fs + .lookup(LoadedAddr::new_unchecked(1), "test.txt".as_ref()) + .await + .unwrap(); + + // Write to the file — this puts it in the overlay. + fs.write( + LoadedAddr::new_unchecked(2), + 0, + Bytes::from_static(b"written"), + ) + .await + .unwrap(); + + // Simulate forget by calling InodeForget::delete directly. + let ctx = ForgetContext { + inode_table: Arc::clone(&table), + dcache: fs.directory_cache(), + lookup_cache: fs.lookup_cache(), + provider: provider.clone(), + write_overlay: fs.write_overlay(), + }; + InodeForget::delete(&ctx, &2); + + // The inode should still be in the table because it was written to. + assert!( + table.get(&2).await.is_some(), + "written inode must survive forget" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unwritten_inode_is_evicted_on_forget() { + let root = make_inode(1, INodeType::Directory, 0, None); + let file = make_inode(2, INodeType::File, 10, Some(1)); + + let state = MockFsState { + lookups: [((1, "test.txt".into()), file)].into_iter().collect(), + directories: [(1, vec![("test.txt".into(), file)])].into_iter().collect(), + file_contents: [(2, Bytes::from_static(b"original"))].into_iter().collect(), + ..MockFsState::default() + }; + let provider = MockFsDataProvider::new(state); + let table = Arc::new(FutureBackedCache::default()); + let fs = AsyncFs::new(provider.clone(), root, Arc::clone(&table)).await; + + // Look up the file to load it into the table. + let _ = fs + .lookup(LoadedAddr::new_unchecked(1), "test.txt".as_ref()) + .await + .unwrap(); + assert!(table.get(&2).await.is_some()); + + // Forget without writing — should be evicted. + let ctx = ForgetContext { + inode_table: Arc::clone(&table), + dcache: fs.directory_cache(), + lookup_cache: fs.lookup_cache(), + provider: provider.clone(), + write_overlay: fs.write_overlay(), + }; + InodeForget::delete(&ctx, &2); + + assert!( + table.get(&2).await.is_none(), + "unwritten inode should be evicted" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn write_then_read_round_trip() { + let root = make_inode(1, INodeType::Directory, 0, None); + let file = make_inode(2, INodeType::File, 0, Some(1)); + + let state = MockFsState { + lookups: [((1, "data.bin".into()), file)].into_iter().collect(), + directories: [(1, vec![("data.bin".into(), file)])].into_iter().collect(), + file_contents: [(2, Bytes::new())].into_iter().collect(), + ..MockFsState::default() + }; + let provider = MockFsDataProvider::new(state); + let table = Arc::new(FutureBackedCache::default()); + let fs = AsyncFs::new(provider, root, Arc::clone(&table)).await; + + let _ = fs + .lookup(LoadedAddr::new_unchecked(1), "data.bin".as_ref()) + .await + .unwrap(); + + // Write 3 chunks at different offsets. + fs.write(LoadedAddr::new_unchecked(2), 0, Bytes::from_static(b"AAA")) + .await + .unwrap(); + fs.write(LoadedAddr::new_unchecked(2), 3, Bytes::from_static(b"BBB")) + .await + .unwrap(); + fs.write(LoadedAddr::new_unchecked(2), 6, Bytes::from_static(b"CCC")) + .await + .unwrap(); + + // Verify full content. + let open = fs + .open(LoadedAddr::new_unchecked(2), OpenFlags::RDONLY) + .await + .unwrap(); + let data = open.read(0, 1024).await.unwrap(); + assert_eq!(&data[..], b"AAABBBCCC"); + + // Verify inode size. + let inode = fs.getattr(LoadedAddr::new_unchecked(2)).await.unwrap(); + assert_eq!(inode.size, 9); + + // Verify partial read. + let partial = open.read(3, 3).await.unwrap(); + assert_eq!(&partial[..], b"BBB"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn write_with_gap_fills_zeros() { + let root = make_inode(1, INodeType::Directory, 0, None); + let file = make_inode(2, INodeType::File, 0, Some(1)); + + let state = MockFsState { + lookups: [((1, "gap.bin".into()), file)].into_iter().collect(), + directories: [(1, vec![("gap.bin".into(), file)])].into_iter().collect(), + file_contents: [(2, Bytes::new())].into_iter().collect(), + ..MockFsState::default() + }; + let provider = MockFsDataProvider::new(state); + let table = Arc::new(FutureBackedCache::default()); + let fs = AsyncFs::new(provider, root, Arc::clone(&table)).await; + + let _ = fs + .lookup(LoadedAddr::new_unchecked(1), "gap.bin".as_ref()) + .await + .unwrap(); + + // Write at offset 5 with no prior data — should zero-fill [0..5). + fs.write(LoadedAddr::new_unchecked(2), 5, Bytes::from_static(b"XY")) + .await + .unwrap(); + + let open = fs + .open(LoadedAddr::new_unchecked(2), OpenFlags::RDONLY) + .await + .unwrap(); + let data = open.read(0, 1024).await.unwrap(); + assert_eq!(&data[..], b"\0\0\0\0\0XY"); + assert_eq!(data.len(), 7); +} diff --git a/tests/common/async_fs_mocks.rs b/tests/common/async_fs_mocks.rs index ae7c4e1..5a764ce 100644 --- a/tests/common/async_fs_mocks.rs +++ b/tests/common/async_fs_mocks.rs @@ -120,4 +120,13 @@ impl FsDataProvider for MockFsDataProvider { fn forget(&self, addr: git_fs::fs::InodeAddr) { let _ = self.state.forgotten_addrs.insert_sync(addr); } + + #[expect( + clippy::cast_possible_truncation, + reason = "test mock — data stays small" + )] + async fn write(&self, _inode: INode, _offset: u64, data: Bytes) -> Result { + println!("[MockFsDataProvider] write called, {} bytes", data.len()); + Ok(data.len() as u32) + } }