diff --git a/sdk/rust/Cargo.toml b/sdk/rust/Cargo.toml index 191d98e8..aa840dfd 100644 --- a/sdk/rust/Cargo.toml +++ b/sdk/rust/Cargo.toml @@ -11,6 +11,7 @@ tokio = { version = "1", features = ["full"] } async-trait = "0.1" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +ignore = "0.4" libc = "0.2" thiserror = "1.0" lru = "0.12" diff --git a/sdk/rust/src/filesystem/fsignore.rs b/sdk/rust/src/filesystem/fsignore.rs new file mode 100644 index 00000000..ba78e50b --- /dev/null +++ b/sdk/rust/src/filesystem/fsignore.rs @@ -0,0 +1,209 @@ +//! `.fsignore` support for filtering files from the HostFS passthrough layer. +//! +//! This module provides `.gitignore`-compatible pattern matching via the `ignore` +//! crate. When a `.fsignore` file exists in the HostFS root directory, matching +//! files and directories are hidden from `lookup`, `readdir`, and `readdir_plus` +//! operations — as if they don't exist on the host filesystem. +//! +//! The `.fsignore` file uses the same syntax as `.gitignore`: +//! - One pattern per line +//! - `#` for comments +//! - `!` prefix for negation (un-ignore) +//! - Trailing `/` to match directories only +//! - `*`, `**`, `?` glob wildcards +//! +//! The `.fsignore` file itself is never ignored. + +use ignore::gitignore::{Gitignore, GitignoreBuilder}; +use std::path::Path; +use tracing::debug; + +/// The filename used for ignore patterns. +pub const FSIGNORE_FILE: &str = ".fsignore"; + +/// Compiled ignore patterns loaded from a `.fsignore` file. +/// +/// This is cheaply cloneable (wraps an `Arc` internally in the `ignore` crate). +#[derive(Clone)] +pub struct FsIgnore { + matcher: Gitignore, +} + +impl FsIgnore { + /// Load ignore patterns from `/.fsignore`. + /// + /// If the file does not exist or cannot be read, returns an `FsIgnore` that + /// matches nothing (i.e. no files are ignored). + pub fn load(root: &Path) -> Self { + let fsignore_path = root.join(FSIGNORE_FILE); + + let mut builder = GitignoreBuilder::new(root); + + if fsignore_path.is_file() { + if let Some(err) = builder.add(&fsignore_path) { + debug!( + "Warning: failed to parse {}: {}", + fsignore_path.display(), + err + ); + } + } + + let matcher = builder.build().unwrap_or_else(|e| { + debug!("Failed to build ignore matcher: {}", e); + Gitignore::empty() + }); + + Self { matcher } + } + + /// Check whether a path should be ignored. + /// + /// `path` must be an absolute path to the file or directory on the host + /// filesystem. `is_dir` indicates whether the path is a directory (needed + /// for patterns that end with `/`). + /// + /// Returns `true` if the path matches an ignore pattern (and is not + /// negated by a later `!` pattern). + pub fn is_ignored(&self, path: &Path, is_dir: bool) -> bool { + // Never ignore the .fsignore file itself. + if path.file_name().and_then(|n| n.to_str()) == Some(FSIGNORE_FILE) { + // Only protect the root-level .fsignore, not nested ones with the + // same name (which would be unusual but possible). + if path.parent() == self.matcher.path() { + return false; + } + } + + self.matcher + .matched_path_or_any_parents(path, is_dir) + .is_ignore() + } + + /// Returns `true` if no ignore patterns are loaded (the file was absent or + /// empty). Useful for short-circuiting checks. + pub fn is_empty(&self) -> bool { + self.matcher.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + #[test] + fn test_no_fsignore_file() { + let dir = tempdir().unwrap(); + let ignore = FsIgnore::load(dir.path()); + assert!(ignore.is_empty()); + // Nothing should be ignored. + assert!(!ignore.is_ignored(&dir.path().join("anything.txt"), false)); + } + + #[test] + fn test_basic_file_pattern() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join(FSIGNORE_FILE), "*.log\n").unwrap(); + + let ignore = FsIgnore::load(dir.path()); + assert!(!ignore.is_empty()); + assert!(ignore.is_ignored(&dir.path().join("debug.log"), false)); + assert!(!ignore.is_ignored(&dir.path().join("readme.md"), false)); + } + + #[test] + fn test_directory_pattern() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join(FSIGNORE_FILE), "target/\nnode_modules/\n").unwrap(); + + let ignore = FsIgnore::load(dir.path()); + assert!(ignore.is_ignored(&dir.path().join("target"), true)); + assert!(ignore.is_ignored(&dir.path().join("node_modules"), true)); + // A file named "target" (not a directory) should not match "target/" + assert!(!ignore.is_ignored(&dir.path().join("target"), false)); + } + + #[test] + fn test_nested_path() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join(FSIGNORE_FILE), "target/\n").unwrap(); + + let ignore = FsIgnore::load(dir.path()); + // Files inside an ignored directory should be ignored. + assert!(ignore.is_ignored(&dir.path().join("target").join("debug").join("foo"), false)); + } + + #[test] + fn test_negation() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join(FSIGNORE_FILE), + "*.bin\n!important.bin\n", + ) + .unwrap(); + + let ignore = FsIgnore::load(dir.path()); + assert!(ignore.is_ignored(&dir.path().join("data.bin"), false)); + assert!(!ignore.is_ignored(&dir.path().join("important.bin"), false)); + } + + #[test] + fn test_comments_and_blank_lines() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join(FSIGNORE_FILE), + "# This is a comment\n\n*.tmp\n\n# Another comment\n", + ) + .unwrap(); + + let ignore = FsIgnore::load(dir.path()); + assert!(ignore.is_ignored(&dir.path().join("scratch.tmp"), false)); + assert!(!ignore.is_ignored(&dir.path().join("readme.md"), false)); + } + + #[test] + fn test_fsignore_file_itself_not_ignored() { + let dir = tempdir().unwrap(); + // Even if a wildcard matches .fsignore, it should not be hidden. + fs::write(dir.path().join(FSIGNORE_FILE), ".*\n").unwrap(); + + let ignore = FsIgnore::load(dir.path()); + assert!( + !ignore.is_ignored(&dir.path().join(FSIGNORE_FILE), false), + ".fsignore at root should never be ignored" + ); + // Other dotfiles should still be ignored. + assert!(ignore.is_ignored(&dir.path().join(".env"), false)); + } + + #[test] + fn test_double_star_pattern() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join(FSIGNORE_FILE), "**/build/\n").unwrap(); + + let ignore = FsIgnore::load(dir.path()); + assert!(ignore.is_ignored(&dir.path().join("build"), true)); + assert!(ignore.is_ignored(&dir.path().join("src").join("build"), true)); + } + + #[test] + fn test_multiple_patterns() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join(FSIGNORE_FILE), + ".git/\nnode_modules/\ntarget/\n*.pyc\n__pycache__/\n", + ) + .unwrap(); + + let ignore = FsIgnore::load(dir.path()); + assert!(ignore.is_ignored(&dir.path().join(".git"), true)); + assert!(ignore.is_ignored(&dir.path().join("node_modules"), true)); + assert!(ignore.is_ignored(&dir.path().join("target"), true)); + assert!(ignore.is_ignored(&dir.path().join("main.pyc"), false)); + assert!(ignore.is_ignored(&dir.path().join("__pycache__"), true)); + assert!(!ignore.is_ignored(&dir.path().join("src"), true)); + assert!(!ignore.is_ignored(&dir.path().join("main.rs"), false)); + } +} diff --git a/sdk/rust/src/filesystem/hostfs_darwin.rs b/sdk/rust/src/filesystem/hostfs_darwin.rs index 3aac03b2..09c4b1f1 100644 --- a/sdk/rust/src/filesystem/hostfs_darwin.rs +++ b/sdk/rust/src/filesystem/hostfs_darwin.rs @@ -4,6 +4,7 @@ //! O_PATH file descriptors. macOS doesn't support O_PATH or AT_EMPTY_PATH, //! so we use a path-based approach similar to libfuse's passthrough.c example. +use super::fsignore::FsIgnore; use super::{BoxedFile, DirEntry, File, FileSystem, FilesystemStats, FsError, Stats, TimeChange}; use crate::error::{Error, Result}; use async_trait::async_trait; @@ -55,6 +56,8 @@ pub struct HostFS { next_ino: AtomicU64, /// FUSE mountpoint inode to avoid deadlock when overlaying fuse_mountpoint_inode: Option, + /// Compiled `.fsignore` patterns for filtering files + ignore: FsIgnore, } /// An open file handle for HostFS (real fd for I/O) @@ -220,11 +223,12 @@ impl HostFS { ); Ok(Self { - root, + root: root.clone(), inodes: RwLock::new(inodes), src_to_ino: RwLock::new(src_to_ino), next_ino: AtomicU64::new(2), // 1 is root fuse_mountpoint_inode: None, + ignore: FsIgnore::load(&root), }) } @@ -367,6 +371,14 @@ impl FileSystem for HostFS { } } + // Skip files matching .fsignore patterns + if !self.ignore.is_empty() { + let is_dir = (stat.st_mode as u32 & super::S_IFMT) == super::S_IFDIR; + if self.ignore.is_ignored(&child_path, is_dir) { + return Ok(None); + } + } + // Get or create inode let (ino, _is_new) = self.get_or_create_inode(child_path, &stat); @@ -433,7 +445,8 @@ impl FileSystem for HostFS { let c_path = CString::new(path.as_os_str().as_bytes()) .map_err(|_| Error::Internal("invalid path".to_string()))?; - tokio::task::spawn_blocking(move || { + let dir_path = path.clone(); + let entries = tokio::task::spawn_blocking(move || { let dir = unsafe { libc::opendir(c_path.as_ptr()) }; if dir.is_null() { return Err::<_, Error>(std::io::Error::last_os_error().into()); @@ -470,7 +483,26 @@ impl FileSystem for HostFS { Ok(Some(entries)) }) .await - .map_err(|e| Error::Internal(e.to_string()))? + .map_err(|e| Error::Internal(e.to_string()))??; + + // Filter out ignored entries + if let Some(mut entries) = entries { + if !self.ignore.is_empty() { + entries.retain(|name| { + let child_path = dir_path.join(name); + // We don't have stat info here, so check both file and dir. + // A conservative approach: if ignored as a file, hide it. + // Directory-only patterns (trailing /) won't match here unless + // we stat. For correctness, check as non-dir — directory-only + // patterns will still be handled by readdir_plus and lookup. + !self.ignore.is_ignored(&child_path, false) + && !self.ignore.is_ignored(&child_path, true) + }); + } + Ok(Some(entries)) + } else { + Ok(None) + } } async fn readdir_plus(&self, ino: i64) -> Result>> { @@ -537,9 +569,17 @@ impl FileSystem for HostFS { .await .map_err(|e| Error::Internal(e.to_string()))??; - // Now create/lookup inodes for each entry + // Now create/lookup inodes for each entry, filtering ignored paths let mut result = Vec::new(); for (name, child_path, stat) in entries_raw { + // Skip files matching .fsignore patterns + if !self.ignore.is_empty() { + let is_dir = (stat.st_mode as u32 & super::S_IFMT) == super::S_IFDIR; + if self.ignore.is_ignored(&child_path, is_dir) { + continue; + } + } + let (child_ino, _) = self.get_or_create_inode(child_path, &stat); let mut stats = stat_to_stats(&stat); @@ -983,4 +1023,103 @@ mod tests { Ok(()) } + + #[tokio::test] + async fn test_fsignore_hides_files_from_lookup() -> Result<()> { + let dir = tempdir()?; + // Create files before HostFS (so they exist on the host) + std::fs::write(dir.path().join("visible.txt"), b"visible")?; + std::fs::write(dir.path().join("secret.log"), b"secret")?; + std::fs::write( + dir.path().join(".fsignore"), + "*.log\n", + )?; + + let fs = HostFS::new(dir.path())?; + + // visible.txt should be found + assert!(fs.lookup(ROOT_INO, "visible.txt").await?.is_some()); + + // secret.log should be hidden + assert!(fs.lookup(ROOT_INO, "secret.log").await?.is_none()); + + // .fsignore itself should be visible + assert!(fs.lookup(ROOT_INO, ".fsignore").await?.is_some()); + + Ok(()) + } + + #[tokio::test] + async fn test_fsignore_hides_files_from_readdir() -> Result<()> { + let dir = tempdir()?; + std::fs::write(dir.path().join("keep.txt"), b"keep")?; + std::fs::write(dir.path().join("remove.log"), b"remove")?; + std::fs::write(dir.path().join("also_remove.log"), b"also")?; + std::fs::write(dir.path().join(".fsignore"), "*.log\n")?; + + let fs = HostFS::new(dir.path())?; + let entries = fs.readdir(ROOT_INO).await?.unwrap(); + + assert!(entries.contains(&"keep.txt".to_string())); + assert!(entries.contains(&".fsignore".to_string())); + assert!(!entries.contains(&"remove.log".to_string())); + assert!(!entries.contains(&"also_remove.log".to_string())); + + Ok(()) + } + + #[tokio::test] + async fn test_fsignore_hides_files_from_readdir_plus() -> Result<()> { + let dir = tempdir()?; + std::fs::write(dir.path().join("keep.txt"), b"keep")?; + std::fs::write(dir.path().join("remove.log"), b"remove")?; + std::fs::create_dir(dir.path().join("node_modules"))?; + std::fs::write( + dir.path().join(".fsignore"), + "*.log\nnode_modules/\n", + )?; + + let fs = HostFS::new(dir.path())?; + let entries = fs.readdir_plus(ROOT_INO).await?.unwrap(); + let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect(); + + assert!(names.contains(&"keep.txt")); + assert!(names.contains(&".fsignore")); + assert!(!names.contains(&"remove.log")); + assert!(!names.contains(&"node_modules")); + + Ok(()) + } + + #[tokio::test] + async fn test_fsignore_directory_pattern() -> Result<()> { + let dir = tempdir()?; + std::fs::create_dir(dir.path().join("target"))?; + std::fs::write(dir.path().join("target").join("debug.o"), b"obj")?; + std::fs::write(dir.path().join("src.rs"), b"fn main() {}")?; + std::fs::write(dir.path().join(".fsignore"), "target/\n")?; + + let fs = HostFS::new(dir.path())?; + + // Directory should be hidden from lookup + assert!(fs.lookup(ROOT_INO, "target").await?.is_none()); + + // Non-ignored file should be visible + assert!(fs.lookup(ROOT_INO, "src.rs").await?.is_some()); + + Ok(()) + } + + #[tokio::test] + async fn test_no_fsignore_file() -> Result<()> { + let dir = tempdir()?; + std::fs::write(dir.path().join("file.txt"), b"content")?; + + let fs = HostFS::new(dir.path())?; + + // Without .fsignore, everything should be visible + assert!(fs.lookup(ROOT_INO, "file.txt").await?.is_some()); + + Ok(()) + } } diff --git a/sdk/rust/src/filesystem/hostfs_linux.rs b/sdk/rust/src/filesystem/hostfs_linux.rs index 77ff87fe..d3b7d42b 100644 --- a/sdk/rust/src/filesystem/hostfs_linux.rs +++ b/sdk/rust/src/filesystem/hostfs_linux.rs @@ -1,3 +1,4 @@ +use super::fsignore::FsIgnore; use super::{BoxedFile, DirEntry, File, FileSystem, FilesystemStats, FsError, Stats, TimeChange}; use crate::error::{Error, Result}; use async_trait::async_trait; @@ -24,6 +25,8 @@ struct SrcId { struct Inode { /// O_PATH file descriptor - a handle to the file for metadata operations fd: OwnedFd, + /// Full path to the file (used for `.fsignore` matching) + path: PathBuf, /// Source inode number from the host filesystem src_ino: u64, /// Source device id from the host filesystem @@ -52,6 +55,8 @@ pub struct HostFS { /// FUSE mountpoint inode to avoid deadlock when overlaying #[cfg(target_family = "unix")] fuse_mountpoint_inode: Option, + /// Compiled `.fsignore` patterns for filtering files + ignore: FsIgnore, } /// An open file handle for HostFS (real fd for I/O) @@ -204,6 +209,7 @@ impl HostFS { fd: root_fd .try_clone() .map_err(|e| Error::Internal(e.to_string()))?, + path: root.clone(), src_ino: stat.st_ino, src_dev: stat.st_dev, nlookup: AtomicU64::new(1), @@ -222,12 +228,13 @@ impl HostFS { ); Ok(Self { - root, + root: root.clone(), root_fd, inodes: RwLock::new(inodes), src_to_ino: RwLock::new(src_to_ino), next_ino: AtomicU64::new(2), // 1 is root fuse_mountpoint_inode: None, + ignore: FsIgnore::load(&root), }) } @@ -250,6 +257,13 @@ impl HostFS { Ok(inode.fd.as_raw_fd()) } + /// Get the cached path for an inode + fn get_inode_path(&self, ino: i64) -> Result { + let inodes = self.inodes.read().unwrap(); + let inode = inodes.get(&ino).ok_or(FsError::NotFound)?; + Ok(inode.path.clone()) + } + /// Allocate a new inode number fn alloc_ino(&self) -> i64 { self.next_ino.fetch_add(1, Ordering::Relaxed) as i64 @@ -283,7 +297,7 @@ impl HostFS { } /// Create or reuse an inode for the given source identity - fn get_or_create_inode(&self, fd: OwnedFd, stat: &libc::stat) -> (i64, bool) { + fn get_or_create_inode(&self, fd: OwnedFd, path: PathBuf, stat: &libc::stat) -> (i64, bool) { let src_id = SrcId { ino: stat.st_ino, dev: stat.st_dev, @@ -306,6 +320,7 @@ impl HostFS { let ino = self.alloc_ino(); let inode = Inode { fd, + path, src_ino: stat.st_ino, src_dev: stat.st_dev, nlookup: AtomicU64::new(1), @@ -341,6 +356,7 @@ impl HostFS { impl FileSystem for HostFS { async fn lookup(&self, parent_ino: i64, name: &str) -> Result> { let parent_fd = self.get_inode_fd(parent_ino)?; + let parent_path = self.get_inode_path(parent_ino)?; // Check for FUSE mountpoint to avoid deadlock #[cfg(target_family = "unix")] @@ -381,8 +397,17 @@ impl FileSystem for HostFS { } } + // Build child path and skip files matching .fsignore patterns + let child_path = parent_path.join(name); + if !self.ignore.is_empty() { + let is_dir = (stat.st_mode & libc::S_IFMT) == libc::S_IFDIR; + if self.ignore.is_ignored(&child_path, is_dir) { + return Ok(None); + } + } + // Get or create inode - let (ino, _is_new) = self.get_or_create_inode(child_fd, &stat); + let (ino, _is_new) = self.get_or_create_inode(child_fd, child_path, &stat); // Return stats with our inode number let mut stats = stat_to_stats(&stat); @@ -444,10 +469,12 @@ impl FileSystem for HostFS { Err(_) => return Ok(None), }; + let dir_path = self.get_inode_path(ino).ok(); + // Open a real fd for reading directory let dir_fd = Self::open_real_fd(fd, libc::O_RDONLY | libc::O_DIRECTORY)?; - tokio::task::spawn_blocking(move || { + let entries = tokio::task::spawn_blocking(move || { let dir = unsafe { libc::fdopendir(dir_fd.as_raw_fd()) }; if dir.is_null() { return Err::<_, Error>(std::io::Error::last_os_error().into()); @@ -488,7 +515,23 @@ impl FileSystem for HostFS { Ok(Some(entries)) }) .await - .map_err(|e| Error::Internal(e.to_string()))? + .map_err(|e| Error::Internal(e.to_string()))??; + + // Filter out ignored entries + if let Some(mut entries) = entries { + if !self.ignore.is_empty() { + if let Some(ref dir_path) = dir_path { + entries.retain(|name| { + let child_path = dir_path.join(name); + !self.ignore.is_ignored(&child_path, false) + && !self.ignore.is_ignored(&child_path, true) + }); + } + } + Ok(Some(entries)) + } else { + Ok(None) + } } async fn readdir_plus(&self, ino: i64) -> Result>> { @@ -497,6 +540,8 @@ impl FileSystem for HostFS { Err(_) => return Ok(None), }; + let dir_path = self.get_inode_path(ino)?; + // Open a real fd for reading directory let dir_fd = Self::open_real_fd(fd, libc::O_RDONLY | libc::O_DIRECTORY)?; let dir_fd_raw = dir_fd.as_raw_fd(); @@ -504,67 +549,78 @@ impl FileSystem for HostFS { #[cfg(target_family = "unix")] let fuse_mountpoint_inode = self.fuse_mountpoint_inode; - let entries_raw: Vec<(String, libc::stat)> = tokio::task::spawn_blocking(move || { - let dir = unsafe { libc::fdopendir(dir_fd.as_raw_fd()) }; - if dir.is_null() { - return Err::<_, Error>(std::io::Error::last_os_error().into()); - } - std::mem::forget(dir_fd); + let dir_path_clone = dir_path.clone(); + let entries_raw: Vec<(String, PathBuf, libc::stat)> = + tokio::task::spawn_blocking(move || { + let dir = unsafe { libc::fdopendir(dir_fd.as_raw_fd()) }; + if dir.is_null() { + return Err::<_, Error>(std::io::Error::last_os_error().into()); + } + std::mem::forget(dir_fd); - let mut entries = Vec::new(); + let mut entries = Vec::new(); - loop { - unsafe { *libc::__errno_location() = 0 }; - let entry = unsafe { libc::readdir(dir) }; + loop { + unsafe { *libc::__errno_location() = 0 }; + let entry = unsafe { libc::readdir(dir) }; - if entry.is_null() { - let errno = unsafe { *libc::__errno_location() }; - if errno != 0 { - unsafe { libc::closedir(dir) }; - return Err(std::io::Error::from_raw_os_error(errno).into()); + if entry.is_null() { + let errno = unsafe { *libc::__errno_location() }; + if errno != 0 { + unsafe { libc::closedir(dir) }; + return Err(std::io::Error::from_raw_os_error(errno).into()); + } + break; } - break; - } - let name = unsafe { CStr::from_ptr((*entry).d_name.as_ptr()) }; - let name_str = name.to_string_lossy(); + let name = unsafe { CStr::from_ptr((*entry).d_name.as_ptr()) }; + let name_str = name.to_string_lossy(); - if name_str == "." || name_str == ".." { - continue; - } + if name_str == "." || name_str == ".." { + continue; + } - // Get stats for this entry - let mut stat: libc::stat = unsafe { std::mem::zeroed() }; - let result = unsafe { - libc::fstatat( - dir_fd_raw, - (*entry).d_name.as_ptr(), - &mut stat, - libc::AT_SYMLINK_NOFOLLOW, - ) - }; - - if result == 0 { - #[cfg(target_family = "unix")] - if let Some(fuse_ino) = fuse_mountpoint_inode { - if stat.st_ino == fuse_ino { - continue; + // Get stats for this entry + let mut stat: libc::stat = unsafe { std::mem::zeroed() }; + let result = unsafe { + libc::fstatat( + dir_fd_raw, + (*entry).d_name.as_ptr(), + &mut stat, + libc::AT_SYMLINK_NOFOLLOW, + ) + }; + + if result == 0 { + #[cfg(target_family = "unix")] + if let Some(fuse_ino) = fuse_mountpoint_inode { + if stat.st_ino == fuse_ino { + continue; + } } - } - entries.push((name_str.to_string(), stat)); + let child_path = dir_path_clone.join(name_str.as_ref()); + entries.push((name_str.to_string(), child_path, stat)); + } } - } - unsafe { libc::closedir(dir) }; - Ok(entries) - }) - .await - .map_err(|e| Error::Internal(e.to_string()))??; + unsafe { libc::closedir(dir) }; + Ok(entries) + }) + .await + .map_err(|e| Error::Internal(e.to_string()))??; - // Now create/lookup inodes for each entry + // Now create/lookup inodes for each entry, filtering ignored paths let mut result = Vec::new(); - for (name, stat) in entries_raw { + for (name, child_path, stat) in entries_raw { + // Skip files matching .fsignore patterns + if !self.ignore.is_empty() { + let is_dir = (stat.st_mode & libc::S_IFMT) == libc::S_IFDIR; + if self.ignore.is_ignored(&child_path, is_dir) { + continue; + } + } + // Open O_PATH fd for this entry let c_name = CString::new(name.as_str()).map_err(|_| FsError::InvalidPath)?; let child_fd = @@ -575,7 +631,7 @@ impl FileSystem for HostFS { } let child_fd = unsafe { OwnedFd::from_raw_fd(child_fd) }; - let (child_ino, _) = self.get_or_create_inode(child_fd, &stat); + let (child_ino, _) = self.get_or_create_inode(child_fd, child_path, &stat); let mut stats = stat_to_stats(&stat); stats.ino = child_ino; @@ -704,6 +760,7 @@ impl FileSystem for HostFS { _gid: u32, ) -> Result<(Stats, BoxedFile)> { let parent_fd = self.get_inode_fd(parent_ino)?; + let parent_path = self.get_inode_path(parent_ino)?; let c_name = CString::new(name).map_err(|_| FsError::InvalidPath)?; // Create and open the file @@ -733,8 +790,9 @@ impl FileSystem for HostFS { let o_path_fd = unsafe { OwnedFd::from_raw_fd(o_path_fd) }; // Get stats + let child_path = parent_path.join(name); let stat = Self::fstatat_empty_path(o_path_fd.as_raw_fd())?; - let (ino, _) = self.get_or_create_inode(o_path_fd, &stat); + let (ino, _) = self.get_or_create_inode(o_path_fd, child_path, &stat); let mut stats = stat_to_stats(&stat); stats.ino = ino; @@ -1031,4 +1089,97 @@ mod tests { Ok(()) } + + #[tokio::test] + async fn test_fsignore_hides_files_from_lookup() -> Result<()> { + let dir = tempdir()?; + // Create files before HostFS (so they exist on the host) + std::fs::write(dir.path().join("visible.txt"), b"visible")?; + std::fs::write(dir.path().join("secret.log"), b"secret")?; + std::fs::write(dir.path().join(".fsignore"), "*.log\n")?; + + let fs = HostFS::new(dir.path())?; + + // visible.txt should be found + assert!(fs.lookup(ROOT_INO, "visible.txt").await?.is_some()); + + // secret.log should be hidden + assert!(fs.lookup(ROOT_INO, "secret.log").await?.is_none()); + + // .fsignore itself should be visible + assert!(fs.lookup(ROOT_INO, ".fsignore").await?.is_some()); + + Ok(()) + } + + #[tokio::test] + async fn test_fsignore_hides_files_from_readdir() -> Result<()> { + let dir = tempdir()?; + std::fs::write(dir.path().join("keep.txt"), b"keep")?; + std::fs::write(dir.path().join("remove.log"), b"remove")?; + std::fs::write(dir.path().join("also_remove.log"), b"also")?; + std::fs::write(dir.path().join(".fsignore"), "*.log\n")?; + + let fs = HostFS::new(dir.path())?; + let entries = fs.readdir(ROOT_INO).await?.unwrap(); + + assert!(entries.contains(&"keep.txt".to_string())); + assert!(entries.contains(&".fsignore".to_string())); + assert!(!entries.contains(&"remove.log".to_string())); + assert!(!entries.contains(&"also_remove.log".to_string())); + + Ok(()) + } + + #[tokio::test] + async fn test_fsignore_hides_files_from_readdir_plus() -> Result<()> { + let dir = tempdir()?; + std::fs::write(dir.path().join("keep.txt"), b"keep")?; + std::fs::write(dir.path().join("remove.log"), b"remove")?; + std::fs::create_dir(dir.path().join("node_modules"))?; + std::fs::write(dir.path().join(".fsignore"), "*.log\nnode_modules/\n")?; + + let fs = HostFS::new(dir.path())?; + let entries = fs.readdir_plus(ROOT_INO).await?.unwrap(); + let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect(); + + assert!(names.contains(&"keep.txt")); + assert!(names.contains(&".fsignore")); + assert!(!names.contains(&"remove.log")); + assert!(!names.contains(&"node_modules")); + + Ok(()) + } + + #[tokio::test] + async fn test_fsignore_directory_pattern() -> Result<()> { + let dir = tempdir()?; + std::fs::create_dir(dir.path().join("target"))?; + std::fs::write(dir.path().join("target").join("debug.o"), b"obj")?; + std::fs::write(dir.path().join("src.rs"), b"fn main() {}")?; + std::fs::write(dir.path().join(".fsignore"), "target/\n")?; + + let fs = HostFS::new(dir.path())?; + + // Directory should be hidden from lookup + assert!(fs.lookup(ROOT_INO, "target").await?.is_none()); + + // Non-ignored file should be visible + assert!(fs.lookup(ROOT_INO, "src.rs").await?.is_some()); + + Ok(()) + } + + #[tokio::test] + async fn test_no_fsignore_file() -> Result<()> { + let dir = tempdir()?; + std::fs::write(dir.path().join("file.txt"), b"content")?; + + let fs = HostFS::new(dir.path())?; + + // Without .fsignore, everything should be visible + assert!(fs.lookup(ROOT_INO, "file.txt").await?.is_some()); + + Ok(()) + } } diff --git a/sdk/rust/src/filesystem/mod.rs b/sdk/rust/src/filesystem/mod.rs index 5bb4c50b..5fabb3d4 100644 --- a/sdk/rust/src/filesystem/mod.rs +++ b/sdk/rust/src/filesystem/mod.rs @@ -1,4 +1,5 @@ pub mod agentfs; +pub mod fsignore; #[cfg(target_os = "macos")] pub mod hostfs_darwin; #[cfg(target_os = "linux")] @@ -12,6 +13,7 @@ use thiserror::Error; // Re-export implementations pub use agentfs::AgentFS; +pub use fsignore::FsIgnore; #[cfg(target_os = "macos")] pub use hostfs_darwin::HostFS; #[cfg(target_os = "linux")] diff --git a/sdk/rust/src/lib.rs b/sdk/rust/src/lib.rs index 137ba075..0b9ddef5 100644 --- a/sdk/rust/src/lib.rs +++ b/sdk/rust/src/lib.rs @@ -19,9 +19,9 @@ pub use turso::sync::{DatabaseSyncStats, PartialBootstrapStrategy, PartialSyncOp #[cfg(any(target_os = "linux", target_os = "macos"))] pub use filesystem::HostFS; pub use filesystem::{ - BoxedFile, DirEntry, File, FileSystem, FilesystemStats, FsError, OverlayFS, Stats, TimeChange, - DEFAULT_DIR_MODE, DEFAULT_FILE_MODE, S_IFBLK, S_IFCHR, S_IFDIR, S_IFIFO, S_IFLNK, S_IFMT, - S_IFREG, S_IFSOCK, + BoxedFile, DirEntry, File, FileSystem, FilesystemStats, FsError, FsIgnore, OverlayFS, Stats, + TimeChange, DEFAULT_DIR_MODE, DEFAULT_FILE_MODE, S_IFBLK, S_IFCHR, S_IFDIR, S_IFIFO, S_IFLNK, + S_IFMT, S_IFREG, S_IFSOCK, }; pub use kvstore::KvStore; pub use schema::{SchemaVersion, AGENTFS_SCHEMA_VERSION};