diff --git a/Cargo.lock b/Cargo.lock index 626c9324f3..5944c82034 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4076,6 +4076,7 @@ dependencies = [ "grep-searcher", "hkdf", "ignore", + "libc", "libloading", "lru", "mime_guess", diff --git a/crates/ragfs-python/src/lib.rs b/crates/ragfs-python/src/lib.rs index c415e33bf0..f600a038e8 100644 --- a/crates/ragfs-python/src/lib.rs +++ b/crates/ragfs-python/src/lib.rs @@ -173,6 +173,7 @@ fn pathlock_err_to_py(err: PathLockError) -> PyErr { }) } PathLockError::InvalidRequest(_) => PyValueError::new_err(err.to_string()), + PathLockError::InvalidPath(_) => new_py_err("AGFSInvalidPathError", err.to_string()), PathLockError::Io(_) | PathLockError::InvalidToken(_) | PathLockError::Internal(_) => { new_py_err("AGFSInternalError", err.to_string()) } @@ -3090,6 +3091,25 @@ mod tests { }); } + #[test] + fn pathlock_invalid_path_preserves_public_error_type() { + Python::initialize(); + Python::attach(|py| { + let invalid_path_type = get_exception(py, "AGFSInvalidPathError").unwrap(); + for wrapped in [false, true] { + let error = PathLockError::InvalidPath( + "path must match existing entry names exactly".to_string(), + ); + let error = if wrapped { + to_py_err(error.into()) + } else { + pathlock_err_to_py(error) + }; + assert!(error.is_instance(py, &invalid_path_type)); + } + }); + } + #[test] fn pathlock_contention_survives_filesystem_wrappers() { Python::initialize(); diff --git a/crates/ragfs/Cargo.toml b/crates/ragfs/Cargo.toml index b941490a7c..d0166cd3a7 100644 --- a/crates/ragfs/Cargo.toml +++ b/crates/ragfs/Cargo.toml @@ -118,6 +118,9 @@ grep-searcher = "0.1.16" [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem"] } +[target.'cfg(any(target_os = "macos", target_os = "linux"))'.dependencies] +libc = "0.2" + # Development dependencies [dev-dependencies] tempfile = "3.12" diff --git a/crates/ragfs/src/lock/provider.rs b/crates/ragfs/src/lock/provider.rs index d6ec86b89b..ce476b61a0 100644 --- a/crates/ragfs/src/lock/provider.rs +++ b/crates/ragfs/src/lock/provider.rs @@ -282,6 +282,10 @@ impl FilesystemPathLockProvider { crate::core::Error::NotFound(_) | crate::core::Error::MountPointNotFound(_) ) { Ok(None) + } else if let crate::core::Error::InvalidPath(message) = e { + Err(PathLockError::InvalidPath(format!( + "failed to read lock token at '{lock_path}': {message}" + ))) } else { Err(PathLockError::Io(format!( "failed to read lock token at '{lock_path}': {e}" diff --git a/crates/ragfs/src/lock/types.rs b/crates/ragfs/src/lock/types.rs index 8cdbba3a7a..9db8a0dd3a 100644 --- a/crates/ragfs/src/lock/types.rs +++ b/crates/ragfs/src/lock/types.rs @@ -188,6 +188,10 @@ pub enum PathLockError { operation: String, }, + /// Invalid path while resolving lock storage. + #[error("invalid path: {0}")] + InvalidPath(String), + /// I/O error during lock operation. #[error("lock I/O error: {0}")] Io(String), diff --git a/crates/ragfs/src/plugins/localfs/mod.rs b/crates/ragfs/src/plugins/localfs/mod.rs index 463f7ae3b4..4353d4ad96 100644 --- a/crates/ragfs/src/plugins/localfs/mod.rs +++ b/crates/ragfs/src/plugins/localfs/mod.rs @@ -3,10 +3,14 @@ //! This plugin mounts a local directory into RAGFS virtual file system, //! providing direct access to local files and directories. -use async_trait::async_trait; +#[cfg(any(target_os = "macos", target_os = "linux"))] +use std::ffi::CString; +use std::ffi::OsStr; use std::fs; use std::path::{Path, PathBuf}; +use async_trait::async_trait; + use std::io::{self, ErrorKind}; use grep_regex::RegexMatcher; @@ -124,11 +128,46 @@ impl LocalFileSystem { let canonical = fs::canonicalize(&path) .map_err(|e| Error::plugin(format!("failed to canonicalize base path: {}", e)))?; + #[cfg(target_os = "linux")] + Self::verify_proc_fd_support(&canonical)?; + Ok(Self { base_path: canonical, }) } + #[cfg(target_os = "linux")] + fn verify_proc_fd_support(path: &Path) -> Result<()> { + use std::os::fd::{AsRawFd, FromRawFd}; + use std::os::unix::ffi::OsStrExt; + + let path = CString::new(path.as_os_str().as_bytes()) + .map_err(|_| Error::invalid_path(path.to_string_lossy()))?; + // SAFETY: `path` is a valid NUL-terminated string. The returned fd is + // immediately owned by `File` so it is closed on every return path. + let fd = unsafe { + libc::open( + path.as_ptr(), + libc::O_PATH | libc::O_NOFOLLOW | libc::O_CLOEXEC, + ) + }; + if fd < 0 { + return Err(Error::plugin(format!( + "failed to inspect localfs base path: {}", + io::Error::last_os_error() + ))); + } + // SAFETY: `fd` was just returned by `open` and has no other owner. + let file = unsafe { fs::File::from_raw_fd(fd) }; + fs::read_link(format!("/proc/self/fd/{}", file.as_raw_fd())).map_err(|e| { + Error::plugin(format!( + "localfs requires accessible /proc/self/fd on Linux: {}", + e + )) + })?; + Ok(()) + } + /// Resolve a virtual path to actual local path fn resolve_path(&self, path: &str) -> Result { validate_virtual_path(path)?; @@ -139,12 +178,220 @@ impl LocalFileSystem { return Err(Error::invalid_path(path)); } - // Join with base path - if relative.is_empty() { - Ok(self.base_path.clone()) + let local_path = if relative.is_empty() { + self.base_path.clone() } else { - Ok(self.base_path.join(relative)) + self.base_path.join(relative) + }; + self.reject_non_exact_existing_components(&local_path, path)?; + Ok(local_path) + } + + fn reject_non_exact_existing_components( + &self, + local_path: &Path, + display_path: &str, + ) -> Result<()> { + let relative = local_path + .strip_prefix(&self.base_path) + .map_err(|_| Error::invalid_path(display_path))?; + let mut parent = self.base_path.clone(); + + for component in relative.components() { + let name = component.as_os_str(); + let candidate = parent.join(name); + match fs::symlink_metadata(&candidate) { + Ok(_) => { + if !Self::path_has_exact_name(&candidate, name)? { + return Err(Error::invalid_path(format!( + "path must match existing entry names exactly: '{}'", + display_path + ))); + } + parent = candidate; + } + Err(e) if matches!(e.kind(), ErrorKind::NotFound | ErrorKind::NotADirectory) => { + break; + } + Err(e) => { + return Err(Error::plugin(format!( + "failed to inspect path '{}': {}", + display_path, e + ))); + } + } + } + Ok(()) + } + + #[cfg(target_os = "macos")] + fn path_has_exact_name(path: &Path, name: &OsStr) -> Result { + use std::os::unix::ffi::OsStrExt; + + let path = CString::new(path.as_os_str().as_bytes()) + .map_err(|_| Error::invalid_path(path.to_string_lossy()))?; + let mut attrs = libc::attrlist { + bitmapcount: libc::ATTR_BIT_MAP_COUNT, + reserved: 0, + commonattr: libc::ATTR_CMN_NAME, + volattr: 0, + dirattr: 0, + fileattr: 0, + forkattr: 0, + }; + let mut buffer = [0_u8; libc::PATH_MAX as usize + 16]; + + // SAFETY: `path`, `attrs`, and `buffer` remain valid for this call, and + // `buffer` is large enough for one PATH_MAX-sized entry name. + let result = unsafe { + libc::getattrlist( + path.as_ptr(), + (&mut attrs as *mut libc::attrlist).cast(), + buffer.as_mut_ptr().cast(), + buffer.len(), + libc::FSOPT_NOFOLLOW, + ) + }; + if result != 0 { + return Err(Error::plugin(format!( + "failed to inspect path entry: {}", + io::Error::last_os_error() + ))); + } + + let data_offset = i32::from_ne_bytes(buffer[4..8].try_into().unwrap()); + let data_length = u32::from_ne_bytes(buffer[8..12].try_into().unwrap()) as usize; + let start = 4_usize + .checked_add_signed(data_offset as isize) + .ok_or_else(|| Error::plugin("invalid path entry name offset"))?; + let end = start + .checked_add(data_length) + .filter(|end| *end <= buffer.len()) + .ok_or_else(|| Error::plugin("invalid path entry name length"))?; + let actual = buffer[start..end] + .strip_suffix(&[0]) + .unwrap_or(&buffer[start..end]); + Ok(actual == name.as_bytes()) + } + + #[cfg(target_os = "linux")] + fn path_has_exact_name(path: &Path, name: &OsStr) -> Result { + use std::os::fd::{AsRawFd, FromRawFd}; + use std::os::unix::ffi::OsStrExt; + + let path = CString::new(path.as_os_str().as_bytes()) + .map_err(|_| Error::invalid_path(path.to_string_lossy()))?; + // SAFETY: `path` is a valid NUL-terminated string. The returned fd is + // immediately owned by `File` so it is closed on every return path. + let fd = unsafe { + libc::open( + path.as_ptr(), + libc::O_PATH | libc::O_NOFOLLOW | libc::O_CLOEXEC, + ) + }; + if fd < 0 { + return Err(Error::plugin(format!( + "failed to inspect path entry: {}", + io::Error::last_os_error() + ))); + } + // SAFETY: `fd` was just returned by `open` and has no other owner. + let file = unsafe { fs::File::from_raw_fd(fd) }; + let actual_path = fs::read_link(format!("/proc/self/fd/{}", file.as_raw_fd())) + .map_err(|e| Error::plugin(format!("failed to inspect path entry: {}", e)))?; + Ok(actual_path.file_name().is_some_and(|actual| actual == name)) + } + + #[cfg(windows)] + fn path_has_exact_name(path: &Path, name: &OsStr) -> Result { + use std::os::windows::ffi::OsStrExt; + use std::os::windows::io::{AsRawHandle, FromRawHandle}; + use windows_sys::Win32::Foundation::{HANDLE, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::Storage::FileSystem::{ + CreateFileW, FileNameInfo, GetFileInformationByHandleEx, FILE_FLAG_BACKUP_SEMANTICS, + FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, + OPEN_EXISTING, + }; + + #[repr(C)] + struct FileNameInfoBuffer { + length: u32, + name: [u16; 32_768], + } + + let mut path_wide: Vec = path.as_os_str().encode_wide().collect(); + path_wide.push(0); + // SAFETY: `path_wide` is NUL-terminated and all pointer arguments remain + // valid for the call. Zero desired access is sufficient for metadata. + let handle = unsafe { + CreateFileW( + path_wide.as_ptr(), + 0, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + std::ptr::null(), + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, + std::ptr::null_mut(), + ) + }; + if handle == INVALID_HANDLE_VALUE { + return Err(Error::plugin(format!( + "failed to inspect path entry: {}", + io::Error::last_os_error() + ))); } + // SAFETY: `handle` was just returned by `CreateFileW` and has no other owner. + let file = unsafe { fs::File::from_raw_handle(handle) }; + let mut info = Box::new(FileNameInfoBuffer { + length: 0, + name: [0; 32_768], + }); + // SAFETY: `file` owns a valid handle and `info` is writable for its full length. + let succeeded = unsafe { + GetFileInformationByHandleEx( + file.as_raw_handle() as HANDLE, + FileNameInfo, + (&mut *info as *mut FileNameInfoBuffer).cast(), + std::mem::size_of::() as u32, + ) + }; + if succeeded == 0 { + return Err(Error::plugin(format!( + "failed to inspect path entry: {}", + io::Error::last_os_error() + ))); + } + let length = info.length as usize; + if length % 2 != 0 || length > info.name.len() * std::mem::size_of::() { + return Err(Error::plugin("invalid path entry name length")); + } + let actual_name = info.name[..length / 2] + .rsplit(|character| *character == b'\\' as u16 || *character == b'/' as u16) + .next() + .unwrap_or_default(); + Ok(actual_name.iter().copied().eq(name.encode_wide())) + } + + #[cfg(not(any(target_os = "macos", target_os = "linux", windows)))] + fn path_has_exact_name(path: &Path, name: &OsStr) -> Result { + Self::directory_contains_exact_name(path, name) + } + + #[cfg(not(any(target_os = "macos", target_os = "linux", windows)))] + fn directory_contains_exact_name(path: &Path, name: &OsStr) -> Result { + let parent = path + .parent() + .ok_or_else(|| Error::invalid_path(path.to_string_lossy()))?; + for entry in fs::read_dir(parent) + .map_err(|e| Error::plugin(format!("failed to read directory: {}", e)))? + { + let entry = entry + .map_err(|e| Error::plugin(format!("failed to read directory entry: {}", e)))?; + if entry.file_name() == name { + return Ok(true); + } + } + Ok(false) } #[cfg(windows)] @@ -1267,6 +1514,80 @@ mod tests { std::fs::write(path, content).unwrap(); } + #[tokio::test] + async fn test_localfs_case_aliases_follow_filesystem_semantics() { + let (dir, fs) = localfs(); + write_file(dir.path(), "folder/a.md", "owner-a"); + let directory_case_insensitive = dir.path().join("Folder").exists(); + let file_case_insensitive = dir.path().join("folder/A.md").exists(); + + if directory_case_insensitive { + assert!(matches!( + fs.read("/Folder/a.md", 0, 0).await, + Err(Error::InvalidPath(_)) + )); + } else { + assert!(matches!( + fs.read("/Folder/a.md", 0, 0).await, + Err(Error::NotFound(_)) + )); + } + + if file_case_insensitive { + assert!(matches!( + fs.read("/folder/A.md", 0, 0).await, + Err(Error::InvalidPath(_)) + )); + assert!(matches!( + fs.write("/folder/A.md", b"owner-b", 0, WriteFlag::Create) + .await, + Err(Error::InvalidPath(_)) + )); + } else { + assert!(matches!( + fs.read("/folder/A.md", 0, 0).await, + Err(Error::NotFound(_)) + )); + fs.write("/folder/A.md", b"owner-b", 0, WriteFlag::Create) + .await + .unwrap(); + assert_eq!( + std::fs::read_to_string(dir.path().join("folder/A.md")).unwrap(), + "owner-b" + ); + } + + fs.write("/folder/a.md", b"updated", 0, WriteFlag::Create) + .await + .unwrap(); + assert_eq!( + std::fs::read_to_string(dir.path().join("folder/a.md")).unwrap(), + "updated" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn test_localfs_reads_exact_path_in_non_listable_directory() { + use std::os::unix::fs::PermissionsExt; + + let (dir, fs) = localfs(); + write_file(dir.path(), "blocked/a.md", "exact"); + let blocked = dir.path().join("blocked"); + let original_mode = std::fs::metadata(&blocked).unwrap().permissions().mode(); + let mut permissions = std::fs::metadata(&blocked).unwrap().permissions(); + permissions.set_mode(0o111); + std::fs::set_permissions(&blocked, permissions).unwrap(); + + let result = fs.read("/blocked/a.md", 0, 0).await; + + let mut permissions = std::fs::metadata(&blocked).unwrap().permissions(); + permissions.set_mode(original_mode); + std::fs::set_permissions(&blocked, permissions).unwrap(); + + assert_eq!(result.unwrap(), b"exact"); + } + #[test] fn test_open_file_identity_matches_only_the_current_path_entry() { let dir = TempDir::new().unwrap();