diff --git a/crates/monty-datatest/src/main.rs b/crates/monty-datatest/src/main.rs index 3b6705ee1..2eb07a888 100644 --- a/crates/monty-datatest/src/main.rs +++ b/crates/monty-datatest/src/main.rs @@ -906,6 +906,9 @@ fn dispatch_os_call( // Virtual filesystem doesn't have symlinks MontyObject::Bool(false).into() } + OsFunction::Readlink => { + MontyException::new(ExcType::OSError, Some(format!("[Errno 22] Invalid argument: '{path}'"))).into() + } OsFunction::ReadText => { if let Some(file) = get_virtual_file(&path) { match str::from_utf8(&file.content) { @@ -948,6 +951,19 @@ fn dispatch_os_call( .into() } } + OsFunction::Lstat => { + if let Some(file) = get_virtual_file(&path) { + file_stat(file.mode, file.content.len() as i64, VFS_MTIME).into() + } else if is_virtual_dir(&path) { + dir_stat(0o755, VFS_MTIME).into() + } else { + MontyException::new( + ExcType::FileNotFoundError, + Some(format!("[Errno 2] No such file or directory: '{path}'")), + ) + .into() + } + } OsFunction::Iterdir => { if let Some(entries) = get_virtual_dir_entries(&path) { // Return Path objects, not strings @@ -1015,6 +1031,16 @@ fn dispatch_os_call( // write_bytes returns the number of bytes written MontyObject::Int(byte_count as i64).into() } + OsFunction::Chmod => { + let mode = i64::try_from(&args[1]).expect("chmod: second arg must be int"); + MUTABLE_VFS.with(|vfs| { + let mut vfs = vfs.borrow_mut(); + if let Some((_, file_mode)) = vfs.files.get_mut(&path) { + *file_mode = mode; + } + }); + MontyObject::None.into() + } OsFunction::Mkdir => { // Check for parents and exist_ok in kwargs (e.g., mkdir(parents=True, exist_ok=True)) let parents = get_kwarg_bool(kwargs, "parents"); @@ -1121,6 +1147,11 @@ fn dispatch_os_call( .into() } } + OsFunction::SymlinkTo => MontyException::new( + ExcType::OSError, + Some(format!("Path.symlink_to() is not supported in iter mode: '{path}'")), + ) + .into(), } } @@ -2343,6 +2374,121 @@ fn run_test_cases_cpython(path: &Path) -> Result<(), Box> { } // Generate tests for all fixture files using datatest-stable harness macro +#[cfg(test)] +mod tests { + use super::*; + + /// Helper to build a path argument list for `dispatch_os_call()`. + fn path_args(path: &str) -> [MontyObject; 1] { + [MontyObject::Path(path.to_owned())] + } + + /// Helper to read `st_mode` from a stat result. + fn stat_mode(result: ExtFunctionResult) -> i64 { + match result { + ExtFunctionResult::Return(MontyObject::NamedTuple { values, .. }) => match &values[0] { + MontyObject::Int(mode) => *mode, + other => panic!("expected st_mode int, got {other:?}"), + }, + other => panic!("expected stat result, got {other:?}"), + } + } + + /// Boolean kwargs should default to false and only return true for `Bool(true)`. + #[test] + fn get_kwarg_bool_reads_boolean_kwargs() { + let kwargs = vec![ + ( + MontyObject::String("follow_symlinks".to_owned()), + MontyObject::Bool(false), + ), + ( + MontyObject::String("target_is_directory".to_owned()), + MontyObject::Bool(true), + ), + ]; + + assert!(!get_kwarg_bool(&kwargs, "follow_symlinks")); + assert!(get_kwarg_bool(&kwargs, "target_is_directory")); + assert!(!get_kwarg_bool(&kwargs, "missing")); + } + + /// Iter-mode fixtures do not model symlinks, so `readlink()` should report the fallback error. + #[test] + fn dispatch_os_call_readlink_reports_invalid_argument() { + reset_mutable_vfs(); + let result = dispatch_os_call(OsFunction::Readlink, &path_args("/virtual/file.txt"), &[]); + match result { + ExtFunctionResult::Error(exc) => { + assert_eq!(exc.exc_type(), ExcType::OSError); + assert_eq!( + exc.message().unwrap_or(""), + "[Errno 22] Invalid argument: '/virtual/file.txt'" + ); + } + other => panic!("expected readlink error, got {other:?}"), + } + } + + /// `lstat()` should mirror `stat()` in the symlink-free virtual fixture filesystem. + #[test] + fn dispatch_os_call_lstat_matches_stat() { + reset_mutable_vfs(); + let stat = dispatch_os_call(OsFunction::Stat, &path_args("/virtual/file.txt"), &[]); + let lstat = dispatch_os_call(OsFunction::Lstat, &path_args("/virtual/file.txt"), &[]); + assert_eq!(stat_mode(stat), stat_mode(lstat)); + } + + /// `chmod()` should update the tracked mode used by later stat calls. + #[test] + fn dispatch_os_call_chmod_updates_virtual_mode() { + reset_mutable_vfs(); + let write_args = [ + MontyObject::Path("/virtual/new.txt".to_owned()), + MontyObject::String("hello".to_owned()), + ]; + dispatch_os_call(OsFunction::WriteText, &write_args, &[]); + + let chmod_args = [ + MontyObject::Path("/virtual/new.txt".to_owned()), + MontyObject::Int(0o600), + ]; + let chmod_result = dispatch_os_call(OsFunction::Chmod, &chmod_args, &[]); + assert!(matches!(chmod_result, ExtFunctionResult::Return(MontyObject::None))); + + let stat = dispatch_os_call(OsFunction::Stat, &path_args("/virtual/new.txt"), &[]); + assert_eq!(stat_mode(stat) & 0o777, 0o600); + } + + /// `symlink_to()` remains intentionally unsupported in iter-mode fixtures. + #[test] + fn dispatch_os_call_symlink_to_reports_iter_mode_gap() { + reset_mutable_vfs(); + let args = [ + MontyObject::Path("/virtual/link.txt".to_owned()), + MontyObject::Path("/virtual/file.txt".to_owned()), + ]; + let result = dispatch_os_call( + OsFunction::SymlinkTo, + &args, + &[( + MontyObject::String("target_is_directory".to_owned()), + MontyObject::Bool(false), + )], + ); + match result { + ExtFunctionResult::Error(exc) => { + assert_eq!(exc.exc_type(), ExcType::OSError); + assert_eq!( + exc.message().unwrap_or(""), + "Path.symlink_to() is not supported in iter mode: '/virtual/link.txt'" + ); + } + other => panic!("expected symlink_to error, got {other:?}"), + } + } +} + datatest_stable::harness!( run_test_cases_monty, TEST_CASES_DIR, diff --git a/crates/monty-python/python/pydantic_monty/os_access.py b/crates/monty-python/python/pydantic_monty/os_access.py index 8bfae9952..41d578936 100644 --- a/crates/monty-python/python/pydantic_monty/os_access.py +++ b/crates/monty-python/python/pydantic_monty/os_access.py @@ -3,7 +3,7 @@ import datetime from abc import ABC, abstractmethod from pathlib import PurePosixPath -from typing import TYPE_CHECKING, Any, Callable, Literal, NamedTuple, Protocol, Sequence, TypeAlias, TypeGuard +from typing import TYPE_CHECKING, Any, Callable, Literal, NamedTuple, Protocol, Sequence, TypeAlias, TypeGuard, cast from ._monty import NOT_HANDLED @@ -18,6 +18,7 @@ 'Path.is_file', 'Path.is_dir', 'Path.is_symlink', + 'Path.readlink', 'Path.read_text', 'Path.read_bytes', 'Path.write_text', @@ -27,7 +28,10 @@ 'Path.rmdir', 'Path.iterdir', 'Path.stat', + 'Path.lstat', + 'Path.chmod', 'Path.rename', + 'Path.symlink_to', 'Path.resolve', 'Path.absolute', 'os.getenv', @@ -82,6 +86,28 @@ def dir_stat(cls, mode: int = 0o755, mtime: float | None = None) -> Self: mtime = time.time() if mtime is None else mtime return cls(mode, 0, 0, 2, 0, 0, 4096, mtime, mtime, mtime) + @classmethod + def symlink_stat(cls, mode: int = 0o777, mtime: float | None = None) -> Self: + """Creates a stat_result namedtuple for a symbolic link. + + Use this for `Path.lstat()` or `Path.stat(follow_symlinks=False)` when + the path itself is a symlink rather than the symlink target. + + Args: + mode: Symlink permissions as octal (e.g. 0o777) or full mode with symlink type bits + mtime: Modification time as Unix timestamp, defaults to Now. + + Returns: + A namedtuple with stat_result fields. + """ + import time + + if mode < 0o1000: + mode = mode | 0o120_000 + + mtime = time.time() if mtime is None else mtime + return cls(mode, 0, 0, 1, 0, 0, 0, mtime, mtime, mtime) + st_mode: int """protection bits""" @@ -164,6 +190,8 @@ def dispatch(self, function_name: OsFunction, args: tuple[Any, ...], kwargs: dic return self.path_is_dir(*args) case 'Path.is_symlink': return self.path_is_symlink(*args) + case 'Path.readlink': + return self.path_readlink(*args) case 'Path.read_text': return self.path_read_text(*args) case 'Path.read_bytes': @@ -184,9 +212,23 @@ def dispatch(self, function_name: OsFunction, args: tuple[Any, ...], kwargs: dic case 'Path.iterdir': return self.path_iterdir(*args) case 'Path.stat': - return self.path_stat(*args) + assert len(kwargs) <= 1, f'Unexpected keyword arguments: {kwargs}' + follow_symlinks = kwargs.get('follow_symlinks', True) + if follow_symlinks: + return self.path_stat(*args) + return self.path_lstat(*args) + case 'Path.lstat': + return self.path_lstat(*args) + case 'Path.chmod': + assert len(kwargs) <= 1, f'Unexpected keyword arguments: {kwargs}' + follow_symlinks = kwargs.get('follow_symlinks', True) + return self.path_chmod(*args, follow_symlinks=follow_symlinks) case 'Path.rename': return self.path_rename(*args) + case 'Path.symlink_to': + assert len(kwargs) <= 1, f'Unexpected keyword arguments: {kwargs}' + target_is_directory = kwargs.get('target_is_directory', False) + return self.path_symlink_to(*args, target_is_directory=target_is_directory) case 'Path.resolve': return self.path_resolve(*args) case 'Path.absolute': @@ -266,6 +308,21 @@ def path_read_text(self, path: PurePosixPath) -> str: """ raise NotImplementedError + def path_readlink(self, path: PurePosixPath) -> PurePosixPath: + """Read and return the raw target of a symbolic link. + + Args: + path: The symlink path to inspect. + + Returns: + The target path exactly as the filesystem stores it. + + Raises: + FileNotFoundError: If the path does not exist. + OSError: If the path is not a symbolic link or the backend does not support symlinks. + """ + raise NotImplementedError + @abstractmethod def path_read_bytes(self, path: PurePosixPath) -> bytes: """Read the contents of a file as bytes. @@ -374,7 +431,6 @@ def path_iterdir(self, path: PurePosixPath) -> list[PurePosixPath]: """ raise NotImplementedError - @abstractmethod def path_stat(self, path: PurePosixPath) -> StatResult: """Get file status information. @@ -391,6 +447,27 @@ def path_stat(self, path: PurePosixPath) -> StatResult: """ raise NotImplementedError + def path_lstat(self, path: PurePosixPath) -> StatResult: + """Get status information without following the final symlink component. + + Override this when the backend distinguishes symlink metadata from + target metadata. The default implementation preserves compatibility for + regular files and directories, but refuses to silently follow symlinks + because that would report incorrect metadata for `Path.lstat()` and + `Path.stat(follow_symlinks=False)`. + """ + if self.path_is_symlink(path): + raise NotImplementedError + return self.path_stat(path) + + def path_chmod(self, path: PurePosixPath, mode: int, *, follow_symlinks: bool = True) -> None: + """Change the visible mode bits for a file, directory, or symlink. + + Backends may raise `NotImplementedError` when permissions are not + meaningful or when non-following symlink chmod is unsupported. + """ + raise NotImplementedError + @abstractmethod def path_rename(self, path: PurePosixPath, target: PurePosixPath) -> None: """Rename a file or directory. @@ -405,6 +482,22 @@ def path_rename(self, path: PurePosixPath, target: PurePosixPath) -> None: """ raise NotImplementedError + def path_symlink_to( + self, + path: PurePosixPath, + target: PurePosixPath, + *, + target_is_directory: bool = False, + ) -> None: + """Create a symbolic link at `path` that points to `target`. + + Args: + path: The symlink path to create. + target: The target path stored in the symlink. + target_is_directory: Hint used by Windows-style backends. + """ + raise NotImplementedError + @abstractmethod def path_resolve(self, path: PurePosixPath) -> str: """Resolve a path to an absolute path, resolving any symlinks. @@ -715,6 +808,7 @@ class OSAccess(AbstractOS): files: list[AbstractFile] environ: dict[str, str] _tree: Tree + _dir_permissions: dict[PurePosixPath, int] def __init__( self, @@ -743,6 +837,7 @@ def __init__( self.environ = environ or {} # Initialize tree with root directory - / is always present self._tree = {'/': {}} + self._dir_permissions = {PurePosixPath('/'): 0o755} root_dir = PurePosixPath(root_dir) assert root_dir.is_absolute(), f'Root directory must be absolute, got {root_dir}' for file in self.files: @@ -750,10 +845,14 @@ def __init__( file.path = root_dir / file.path subtree = self._tree + current = PurePosixPath('/') *dir_parts, name = file.path.parts for part in dir_parts: entry = subtree.setdefault(part, {}) if _is_dir(entry): + if part != '/': + current /= part + self._dir_permissions.setdefault(current, 0o755) subtree = entry else: raise ValueError(f'Cannot put file {file} within sub-directory of file {entry}') @@ -823,14 +922,18 @@ def path_mkdir(self, path: PurePosixPath, parents: bool, exist_ok: bool) -> None parent_entry = self._parent_entry(path) if _is_dir(parent_entry): parent_entry[PurePosixPath(path).name] = {} + self._dir_permissions[PurePosixPath(path)] = 0o755 return elif _is_file(parent_entry): raise NotADirectoryError(f'[Errno 20] Not a directory: {str(path)!r}') elif parents: - subtree = self._tree - for part in PurePosixPath(path).parts: + subtree = cast(Tree, self._tree['/']) + current = PurePosixPath('/') + for part in PurePosixPath(path).parts[1:]: + current /= part entry = subtree.setdefault(part, {}) if _is_dir(entry): + self._dir_permissions.setdefault(current, 0o755) subtree = entry else: raise NotADirectoryError(f'[Errno 20] Not a directory: {str(path)!r}') @@ -853,6 +956,7 @@ def path_rmdir(self, path: PurePosixPath) -> None: parent_dir = self._parent_entry(path) assert _is_dir(parent_dir), f'Expected parent of a file to always be a directory, got {parent_dir}' del parent_dir[PurePosixPath(path).name] + self._dir_permissions.pop(PurePosixPath(path), None) def path_iterdir(self, path: PurePosixPath) -> list[PurePosixPath]: # Return full paths as PurePosixPath objects (will be converted to MontyObject::Path) @@ -866,7 +970,19 @@ def path_stat(self, path: PurePosixPath) -> StatResult: size = len(content) if isinstance(content, bytes) else len(content.encode()) return StatResult.file_stat(size=size, mode=entry.permissions) else: - return StatResult.dir_stat() + return StatResult.dir_stat(mode=self._dir_permissions.get(PurePosixPath(path), 0o755)) + + def path_lstat(self, path: PurePosixPath) -> StatResult: + return self.path_stat(path) + + def path_chmod(self, path: PurePosixPath, mode: int, *, follow_symlinks: bool = True) -> None: + del follow_symlinks # OSAccess has no symlink entries today. + + entry = self._get_entry_exists(path) + if _is_file(entry): + entry.permissions = mode + else: + self._dir_permissions[PurePosixPath(path)] = mode def path_rename(self, path: PurePosixPath, target: PurePosixPath) -> None: src_entry = self._get_entry(path) @@ -894,6 +1010,8 @@ def path_rename(self, path: PurePosixPath, target: PurePosixPath) -> None: del parent_dir[src_name] # and put it in the new directory target_parent[target_name] = src_entry + src_entry.path = PurePosixPath(target) + src_entry.name = src_entry.path.name else: assert _is_dir(src_entry), 'src path must be a directory here' if _is_file(target_entry): @@ -907,9 +1025,13 @@ def path_rename(self, path: PurePosixPath, target: PurePosixPath) -> None: del parent_dir[src_name] # and put it in the new directory target_parent[target_name] = src_entry + old_path = PurePosixPath(path) + new_path = PurePosixPath(target) + mode = self._dir_permissions.pop(old_path, 0o755) + self._dir_permissions[new_path] = mode # Update paths for all files in the renamed directory - self._update_paths_recursive(src_entry, PurePosixPath(path), PurePosixPath(target)) + self._update_paths_recursive(src_entry, old_path, new_path) def path_resolve(self, path: PurePosixPath) -> str: # No symlinks in OSAccess, so resolve is same as absolute with normalization @@ -973,10 +1095,18 @@ def _update_paths_recursive(self, tree: Tree, old_prefix: PurePosixPath, new_pre AbstractFile objects still have their old paths. This method recursively updates all file paths by replacing old_prefix with new_prefix. """ + for old_path, mode in list(self._dir_permissions.items()): + if old_path == old_prefix or old_prefix not in old_path.parents: + continue + relative = old_path.relative_to(old_prefix) + self._dir_permissions[new_prefix / relative] = mode + del self._dir_permissions[old_path] + for entry in tree.values(): if _is_file(entry): # Replace old prefix with new prefix in file path relative = entry.path.relative_to(old_prefix) entry.path = new_prefix / relative + entry.name = entry.path.name elif _is_dir(entry): self._update_paths_recursive(entry, old_prefix, new_prefix) diff --git a/crates/monty-python/tests/test_os_access.py b/crates/monty-python/tests/test_os_access.py index da6795a90..e9fb6a309 100644 --- a/crates/monty-python/tests/test_os_access.py +++ b/crates/monty-python/tests/test_os_access.py @@ -167,6 +167,21 @@ def test_path_is_symlink_always_false(): assert result == snapshot((False, False, False)) +def test_path_chmod_updates_visible_mode_bits(): + """OSAccess.path_chmod updates the mode returned by Path.stat().""" + fs = OSAccess([MemoryFile('/test/file.txt', content='hello')]) + code = """ +from pathlib import Path +path = Path('/test/file.txt') +before = oct(path.stat().st_mode) +path.chmod(0o600) +after = oct(path.stat().st_mode) +(before, after) +""" + result = Monty(code).run(os=fs) + assert result == snapshot(('0o100644', '0o100600')) + + # ============================================================================= # Reading Files (via Monty) # ============================================================================= diff --git a/crates/monty-python/tests/test_os_access_raw.py b/crates/monty-python/tests/test_os_access_raw.py index 5b22bfcda..b1b298ef7 100644 --- a/crates/monty-python/tests/test_os_access_raw.py +++ b/crates/monty-python/tests/test_os_access_raw.py @@ -22,7 +22,9 @@ class TestOS(AbstractOS): def __init__(self) -> None: self.files: dict[str, bytes] = {} + self.file_permissions: dict[str, int] = {} self.directories: set[str] = {'/'} + self.dir_permissions: dict[str, int] = {'/': 0o755} def _ensure_parent_exists(self, path: str) -> None: """Ensure all parent directories exist.""" @@ -30,6 +32,7 @@ def _ensure_parent_exists(self, path: str) -> None: for i in range(1, len(parts)): parent = '/'.join(parts[:i]) or '/' self.directories.add(parent) + self.dir_permissions.setdefault(parent, 0o755) def path_exists(self, path: PurePosixPath) -> bool: p = str(path) @@ -60,12 +63,14 @@ def path_write_text(self, path: PurePosixPath, data: str) -> int: p = str(path) self._ensure_parent_exists(p) self.files[p] = data.encode('utf-8') + self.file_permissions[p] = 0o644 return len(data) def path_write_bytes(self, path: PurePosixPath, data: bytes) -> int: p = str(path) self._ensure_parent_exists(p) self.files[p] = data + self.file_permissions[p] = 0o644 return len(data) def path_mkdir(self, path: PurePosixPath, parents: bool, exist_ok: bool) -> None: @@ -77,12 +82,14 @@ def path_mkdir(self, path: PurePosixPath, parents: bool, exist_ok: bool) -> None if parents: self._ensure_parent_exists(p) self.directories.add(p) + self.dir_permissions[p] = 0o755 def path_unlink(self, path: PurePosixPath) -> None: p = str(path) if p not in self.files: raise FileNotFoundError(f'No such file: {p}') del self.files[p] + self.file_permissions.pop(p, None) def path_rmdir(self, path: PurePosixPath) -> None: p = str(path) @@ -96,6 +103,7 @@ def path_rmdir(self, path: PurePosixPath) -> None: if d != p and d.startswith(p + '/'): raise OSError(f'Directory not empty: {p}') self.directories.remove(p) + self.dir_permissions.pop(p, None) def path_iterdir(self, path: PurePosixPath) -> list[PurePosixPath]: p = str(path) @@ -124,9 +132,19 @@ def path_iterdir(self, path: PurePosixPath) -> list[PurePosixPath]: def path_stat(self, path: PurePosixPath) -> StatResult: p = str(path) if p in self.files: - return StatResult.file_stat(len(self.files[p]), 0o644, 0.0) + return StatResult.file_stat(len(self.files[p]), self.file_permissions.get(p, 0o644), 0.0) elif p in self.directories: - return StatResult.dir_stat(0o755, 0.0) + return StatResult.dir_stat(self.dir_permissions.get(p, 0o755), 0.0) + else: + raise FileNotFoundError(f'No such file or directory: {p}') + + def path_chmod(self, path: PurePosixPath, mode: int, *, follow_symlinks: bool = True) -> None: + del follow_symlinks + p = str(path) + if p in self.files: + self.file_permissions[p] = mode + elif p in self.directories: + self.dir_permissions[p] = mode else: raise FileNotFoundError(f'No such file or directory: {p}') @@ -189,6 +207,98 @@ def datetime_now(self, tz: datetime.tzinfo | None = None) -> datetime.datetime: return datetime.datetime(2024, 1, 15, 10, 30, 5, 123456, tzinfo=tz) +class SymlinkOS(TestOS): + """A simple virtual filesystem that also models symbolic links.""" + + __test__ = False + + def __init__(self) -> None: + super().__init__() + self.symlinks: dict[str, str] = {} + self.symlink_permissions: dict[str, int] = {} + + def path_exists(self, path: PurePosixPath) -> bool: + p = str(path) + if p in self.symlinks: + return super().path_exists(PurePosixPath(self.symlinks[p])) + return super().path_exists(path) + + def path_is_file(self, path: PurePosixPath) -> bool: + p = str(path) + if p in self.symlinks: + return super().path_is_file(PurePosixPath(self.symlinks[p])) + return super().path_is_file(path) + + def path_is_dir(self, path: PurePosixPath) -> bool: + p = str(path) + if p in self.symlinks: + return super().path_is_dir(PurePosixPath(self.symlinks[p])) + return super().path_is_dir(path) + + def path_is_symlink(self, path: PurePosixPath) -> bool: + return str(path) in self.symlinks + + def path_read_text(self, path: PurePosixPath) -> str: + p = str(path) + if p in self.symlinks: + return super().path_read_text(PurePosixPath(self.symlinks[p])) + return super().path_read_text(path) + + def path_read_bytes(self, path: PurePosixPath) -> bytes: + p = str(path) + if p in self.symlinks: + return super().path_read_bytes(PurePosixPath(self.symlinks[p])) + return super().path_read_bytes(path) + + def path_readlink(self, path: PurePosixPath) -> PurePosixPath: + p = str(path) + if p not in self.symlinks: + raise OSError(f'Not a symbolic link: {p}') + return PurePosixPath(self.symlinks[p]) + + def path_stat(self, path: PurePosixPath) -> StatResult: + p = str(path) + if p in self.symlinks: + return super().path_stat(PurePosixPath(self.symlinks[p])) + return super().path_stat(path) + + def path_lstat(self, path: PurePosixPath) -> StatResult: + p = str(path) + if p in self.symlinks: + return StatResult.symlink_stat(self.symlink_permissions.get(p, 0o777), 0.0) + return super().path_lstat(path) + + def path_chmod(self, path: PurePosixPath, mode: int, *, follow_symlinks: bool = True) -> None: + p = str(path) + if p in self.symlinks and not follow_symlinks: + self.symlink_permissions[p] = mode + return + if p in self.symlinks: + super().path_chmod(PurePosixPath(self.symlinks[p]), mode) + return + super().path_chmod(path, mode, follow_symlinks=follow_symlinks) + + def path_symlink_to( + self, + path: PurePosixPath, + target: PurePosixPath, + *, + target_is_directory: bool = False, + ) -> None: + del target_is_directory + p = str(path) + self._ensure_parent_exists(p) + self.symlinks[p] = str(target) + self.symlink_permissions[p] = 0o777 + + +class LegacySymlinkOS(SymlinkOS): + """Symlink backend that intentionally relies on AbstractOS default lstat behavior.""" + + __test__ = False + path_lstat = AbstractOS.path_lstat + + # ============================================================================= # Basic AbstractOS tests # ============================================================================= @@ -215,6 +325,78 @@ def test_abstract_filesystem_exists_missing(): assert result is False +def test_abstract_os_readlink() -> None: + """AbstractOS.path_readlink() is routed through `Path.readlink()`.""" + fs = SymlinkOS() + fs.path_write_text(PurePosixPath('/target.txt'), 'hello') + fs.path_symlink_to(PurePosixPath('/link.txt'), PurePosixPath('/target.txt')) + + m = pydantic_monty.Monty('from pathlib import Path; str(Path("/link.txt").readlink())') + result = m.run(os=fs) + + assert result == snapshot('/target.txt') + + +def test_abstract_os_lstat() -> None: + """AbstractOS.path_lstat() preserves symlink metadata instead of following.""" + fs = SymlinkOS() + fs.path_write_text(PurePosixPath('/target.txt'), 'hello') + fs.path_symlink_to(PurePosixPath('/link.txt'), PurePosixPath('/target.txt')) + + m = pydantic_monty.Monty('from pathlib import Path; oct(Path("/link.txt").lstat().st_mode)') + result = m.run(os=fs) + + assert result == snapshot('0o120777') + + +def test_legacy_abstract_os_lstat_rejects_symlink_fallback() -> None: + """The default lstat fallback should not silently follow symlinks.""" + fs = LegacySymlinkOS() + fs.path_write_text(PurePosixPath('/target.txt'), 'hello') + fs.path_symlink_to(PurePosixPath('/link.txt'), PurePosixPath('/target.txt')) + + with pytest.raises(NotImplementedError): + fs.path_lstat(PurePosixPath('/link.txt')) + + +def test_abstract_os_stat_follow_symlinks_false() -> None: + """Path.stat(follow_symlinks=False) uses the non-following stat hook.""" + fs = SymlinkOS() + fs.path_write_text(PurePosixPath('/target.txt'), 'hello') + fs.path_symlink_to(PurePosixPath('/link.txt'), PurePosixPath('/target.txt')) + + code = """ +from pathlib import Path +( + oct(Path('/link.txt').stat().st_mode), + oct(Path('/link.txt').stat(follow_symlinks=False).st_mode), +) +""" + result = pydantic_monty.Monty(code).run(os=fs) + assert result == snapshot(('0o100644', '0o120777')) + + +def test_abstract_os_symlink_to_and_chmod() -> None: + """AbstractOS backends can create symlinks and chmod either link or target.""" + fs = SymlinkOS() + fs.path_write_text(PurePosixPath('/target.txt'), 'hello') + + code = """ +from pathlib import Path +link = Path('/link.txt') +link.symlink_to(Path('/target.txt')) +link.chmod(0o600) +link.chmod(0o700, follow_symlinks=False) +( + oct(Path('/target.txt').stat().st_mode), + oct(link.lstat().st_mode), + link.read_text(), +) +""" + result = pydantic_monty.Monty(code).run(os=fs) + assert result == snapshot(('0o100600', '0o120700', 'hello')) + + def test_abstract_os_date_today(): """AbstractOS.date_today() is dispatched through the os callback.""" fs = TestOS() diff --git a/crates/monty-python/tests/test_os_calls.py b/crates/monty-python/tests/test_os_calls.py index 9ddc2ddc7..e88e6b813 100644 --- a/crates/monty-python/tests/test_os_calls.py +++ b/crates/monty-python/tests/test_os_calls.py @@ -43,6 +43,62 @@ def test_path_stat_yields_oscall(): assert result.args == snapshot((PurePosixPath('/etc/passwd'),)) +def test_path_lstat_yields_oscall(): + """Path.lstat() yields a dedicated OS call.""" + m = pydantic_monty.Monty('from pathlib import Path; Path("/etc/link").lstat()') + result = m.start() + + assert isinstance(result, pydantic_monty.FunctionSnapshot) + assert result.is_os_function is True + assert result.function_name == snapshot('Path.lstat') + assert result.args == snapshot((PurePosixPath('/etc/link'),)) + assert result.kwargs == snapshot({}) + + +def test_path_stat_follow_symlinks_false_yields_kwargs(): + """Path.stat(follow_symlinks=False) forwards the follow_symlinks kwarg.""" + m = pydantic_monty.Monty('from pathlib import Path; Path("/etc/link").stat(follow_symlinks=False)') + result = m.start() + + assert isinstance(result, pydantic_monty.FunctionSnapshot) + assert result.function_name == snapshot('Path.stat') + assert result.args == snapshot((PurePosixPath('/etc/link'),)) + assert result.kwargs == snapshot({'follow_symlinks': False}) + + +def test_path_readlink_yields_oscall(): + """Path.readlink() yields an OS call returning a path-like result.""" + m = pydantic_monty.Monty('from pathlib import Path; Path("/tmp/link").readlink()') + result = m.start() + + assert isinstance(result, pydantic_monty.FunctionSnapshot) + assert result.function_name == snapshot('Path.readlink') + assert result.args == snapshot((PurePosixPath('/tmp/link'),)) + assert result.kwargs == snapshot({}) + + +def test_path_chmod_yields_oscall(): + """Path.chmod() yields an OS call with the mode argument.""" + m = pydantic_monty.Monty('from pathlib import Path; Path("/tmp/file.txt").chmod(0o600)') + result = m.start() + + assert isinstance(result, pydantic_monty.FunctionSnapshot) + assert result.function_name == snapshot('Path.chmod') + assert result.args == snapshot((PurePosixPath('/tmp/file.txt'), 0o600)) + assert result.kwargs == snapshot({}) + + +def test_path_symlink_to_yields_oscall(): + """Path.symlink_to() yields an OS call with the link path and target.""" + m = pydantic_monty.Monty('from pathlib import Path; Path("/tmp/link").symlink_to(Path("/tmp/target"))') + result = m.start() + + assert isinstance(result, pydantic_monty.FunctionSnapshot) + assert result.function_name == snapshot('Path.symlink_to') + assert result.args == snapshot((PurePosixPath('/tmp/link'), PurePosixPath('/tmp/target'))) + assert result.kwargs == snapshot({}) + + def test_path_read_text_yields_oscall(): """Path.read_text() yields an OS call.""" m = pydantic_monty.Monty('from pathlib import Path; Path("/tmp/hello.txt").read_text()') diff --git a/crates/monty/src/fs/common.rs b/crates/monty/src/fs/common.rs index f4f959357..ad6b352ad 100644 --- a/crates/monty/src/fs/common.rs +++ b/crates/monty/src/fs/common.rs @@ -4,10 +4,20 @@ //! backend modules can focus on mount semantics rather than repeating the same //! byte decoding, stat conversion, and quota bookkeeping logic. -use std::{fs, io::ErrorKind, path::Path, time::SystemTime}; +#[cfg(unix)] +use std::os::unix::fs::symlink as unix_symlink; +#[cfg(windows)] +use std::os::windows::fs::{symlink_dir as win_symlink_dir, symlink_file as win_symlink_file}; +use std::{ + collections::BTreeMap, + fs, + io::{self, ErrorKind}, + path::{Component, Path, PathBuf}, + time::SystemTime, +}; use super::error::MountError; -use crate::{MontyObject, dir_stat, file_stat}; +use crate::{MontyObject, dir_stat, file_stat, symlink_stat}; /// Per-call mount context shared by the filesystem backends. /// @@ -22,6 +32,8 @@ pub(super) struct MountContext<'a> { pub write_bytes_used: &'a mut u64, /// Optional cumulative write cap for the mount. pub write_bytes_limit: Option, + /// Mount-local chmod overrides used to present stable POSIX-like `stat()` bits. + pub chmod_modes: &'a mut BTreeMap, } /// Reads a file as UTF-8 text, preserving `UnicodeDecodeError` semantics. @@ -116,20 +128,230 @@ pub(super) fn rmdir_fs(path: &Path, vpath: &str) -> Result Result { - let metadata = fs::metadata(path).map_err(|err| MountError::Io(err, vpath.to_owned()))?; +/// +/// Direct mounts optionally supply a mount-local mode override so `chmod()` and +/// `stat()` can round-trip consistently across host platforms whose native +/// permission models do not map neatly onto POSIX bits. +pub(super) fn stat_fs( + path: &Path, + vpath: &str, + follow_symlinks: bool, + mode_override: Option, +) -> Result { + let metadata = if follow_symlinks { + fs::metadata(path) + } else { + fs::symlink_metadata(path) + } + .map_err(|err| MountError::Io(err, vpath.to_owned()))?; let mtime = metadata .modified() .unwrap_or(SystemTime::UNIX_EPOCH) .duration_since(SystemTime::UNIX_EPOCH) .map_or(0.0, |duration| duration.as_secs_f64()); let size = i64::try_from(metadata.len()).unwrap_or(i64::MAX); + let readonly = metadata.permissions().readonly(); + let file_type = metadata.file_type(); + + if file_type.is_symlink() { + let mode = mode_override.unwrap_or(if readonly { 0o555 } else { 0o777 }); + Ok(symlink_stat(mode, mtime)) + } else if file_type.is_dir() { + let mode = mode_override.unwrap_or(if readonly { 0o555 } else { 0o755 }); + Ok(dir_stat(mode, mtime)) + } else { + let mode = mode_override.unwrap_or(if readonly { 0o444 } else { 0o644 }); + Ok(file_stat(mode, size, mtime)) + } +} + +/// Reads a symlink target and converts absolute host paths back into sandbox paths. +pub(super) fn readlink_fs( + path: &Path, + vpath: &str, + mount_virtual: &str, + mount_host: &Path, +) -> Result { + let target = fs::read_link(path).map_err(|err| MountError::Io(err, vpath.to_owned()))?; + let target = host_symlink_target_to_virtual(path, &target, mount_virtual, mount_host, vpath)?; + Ok(MontyObject::Path(target)) +} + +/// Maps a host symlink target back into sandbox-visible path space. +fn host_symlink_target_to_virtual( + link_path: &Path, + target: &Path, + mount_virtual: &str, + mount_host: &Path, + vpath: &str, +) -> Result { + if target.is_absolute() { + let resolved_target = validate_symlink_target(link_path, target, mount_host, vpath)?; + let relative = resolved_target + .strip_prefix(mount_host) + .expect("validated absolute symlink targets stay within the mount"); + return Ok(join_virtual_path(mount_virtual, relative)); + } + + validate_symlink_target(link_path, target, mount_host, vpath)?; + Ok(path_to_posix_string(target)) +} + +/// Validates that a symlink target stays within the mounted host directory. +/// +/// Relative targets are resolved against the symlink's canonical parent. The +/// returned path is lexically normalized so broken-but-in-bounds links still +/// work, while existing targets also receive a canonical boundary check so +/// intermediate symlink escapes are rejected. +fn validate_symlink_target( + link_path: &Path, + target: &Path, + mount_host: &Path, + vpath: &str, +) -> Result { + let link_parent = link_path.parent().expect("resolved host paths always have a parent"); + if target.is_absolute() { + let canonical_target = canonicalize_target_or_parent(target, vpath)?; + if !canonical_target.starts_with(mount_host) { + return Err(MountError::PathEscape { + virtual_path: vpath.to_owned(), + }); + } + return Ok(canonical_target); + } + + let resolved_target = link_parent.join(target); + let lexical_target = lexically_normalize_host_path(&resolved_target); + if !lexical_target.starts_with(mount_host) { + return Err(MountError::PathEscape { + virtual_path: vpath.to_owned(), + }); + } + + if let Ok(canonical_target) = canonicalize_target_or_parent(&resolved_target, vpath) + && !canonical_target.starts_with(mount_host) + { + return Err(MountError::PathEscape { + virtual_path: vpath.to_owned(), + }); + } + + Ok(lexical_target) +} + +/// Lexically normalizes a host path without requiring it to exist. +fn lexically_normalize_host_path(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::Prefix(prefix) => normalized.push(prefix.as_os_str()), + Component::RootDir => normalized.push(component.as_os_str()), + Component::CurDir => {} + Component::ParentDir => { + normalized.pop(); + } + Component::Normal(part) => normalized.push(part), + } + } + normalized +} + +/// Canonicalizes a target path, falling back to the nearest existing ancestor for broken links. +fn canonicalize_target_or_parent(path: &Path, vpath: &str) -> Result { + let mut missing_components = Vec::new(); + let mut current = path; + + loop { + if let Ok(canonical) = fs::canonicalize(current) { + let mut rebuilt = canonical; + for component in missing_components.iter().rev() { + rebuilt.push(component); + } + return Ok(rebuilt); + } + + let file_name = current.file_name().ok_or_else(|| MountError::PathEscape { + virtual_path: vpath.to_owned(), + })?; + missing_components.push(file_name.to_os_string()); + current = current.parent().ok_or_else(|| MountError::PathEscape { + virtual_path: vpath.to_owned(), + })?; + } +} + +/// Joins a mount-relative host path onto the sandbox mount prefix. +fn join_virtual_path(mount_virtual: &str, relative: &Path) -> String { + let suffix = path_to_posix_string(relative); + if suffix.is_empty() || suffix == "." { + mount_virtual.to_owned() + } else if mount_virtual == "/" { + format!("/{suffix}") + } else { + format!("{mount_virtual}/{suffix}") + } +} + +/// Converts a host path into a POSIX-style string for sandbox exposure. +pub(super) fn path_to_posix_string(path: &Path) -> String { + let mut parts = Vec::new(); + for component in path.components() { + parts.push(component.as_os_str().to_string_lossy().into_owned()); + } - if metadata.is_dir() { - Ok(dir_stat(0o755, mtime)) + if path.is_absolute() { + let joined = parts.iter().skip(1).map(String::as_str).collect::>().join("/"); + if joined.is_empty() { + "/".to_owned() + } else { + format!("/{joined}") + } + } else if parts.is_empty() { + ".".to_owned() } else { - Ok(file_stat(0o644, size, mtime)) + parts.join("/") + } +} + +/// Creates a symlink in the host filesystem using the platform's native API. +pub(super) fn symlink_fs( + path: &Path, + target: &Path, + target_is_directory: bool, + vpath: &str, +) -> Result { + create_symlink(target, path, target_is_directory).map_err(|err| MountError::Io(err, vpath.to_owned()))?; + Ok(MontyObject::None) +} + +/// Cross-platform symlink creation helper. +fn create_symlink(target: &Path, link: &Path, target_is_directory: bool) -> io::Result<()> { + #[cfg(unix)] + { + let _ = target_is_directory; + unix_symlink(target, link) + } + + #[cfg(windows)] + { + if target_is_directory { + win_symlink_dir(target, link) + } else { + win_symlink_file(target, link) + } + } +} + +/// Converts a sandbox-relative target string into a host-native relative path. +pub(super) fn relative_target_to_host_path(target: &str) -> PathBuf { + let mut path = PathBuf::new(); + for component in target.split('/') { + if component.is_empty() { + continue; + } + path.push(component); } + path } /// Lists visible directory entries from the real filesystem. diff --git a/crates/monty/src/fs/direct.rs b/crates/monty/src/fs/direct.rs index 13636f94f..58e71e77b 100644 --- a/crates/monty/src/fs/direct.rs +++ b/crates/monty/src/fs/direct.rs @@ -3,16 +3,21 @@ //! This backend resolves a sandbox path to a validated host path and then calls //! the corresponding `std::fs` operation without any overlay indirection. -use std::{fs, path::PathBuf}; +use std::{ + fs, + io::ErrorKind, + path::{Path, PathBuf}, +}; use super::{ common::{ MountContext, check_write_limit, commit_write_bytes, iterdir_fs, mkdir_fs, read_bytes_fs, read_text_fs, - rmdir_fs, stat_fs, unlink_fs, write_bytes_fs, write_text_fs, + readlink_fs, relative_target_to_host_path, rmdir_fs, stat_fs, symlink_fs, unlink_fs, write_bytes_fs, + write_text_fs, }, dispatch::FsRequest, error::MountError, - path_security::{ResolveMode, resolve_path}, + path_security::{ResolveMode, normalize_virtual_path, reject_overlong_path, resolve_path, strip_mount_prefix}, }; use crate::MontyObject; @@ -31,6 +36,7 @@ pub(super) fn execute(request: FsRequest<'_>, ctx: &mut MountContext<'_>) -> Res FsRequest::IsFile { path } => is_file(path, ctx), FsRequest::IsDir { path } => is_dir(path, ctx), FsRequest::IsSymlink { path } => is_symlink(path, ctx), + FsRequest::Readlink { path } => readlink(path, ctx), FsRequest::ReadText { path } => { let resolved = resolve_path(path, ctx.mount_virtual, ctx.mount_host, ResolveMode::Existing)?; read_text_fs(&resolved.host_path, path) @@ -49,16 +55,34 @@ pub(super) fn execute(request: FsRequest<'_>, ctx: &mut MountContext<'_>) -> Res FsRequest::Unlink { path } => unlink(path, ctx), FsRequest::Rmdir { path } => { let resolved = resolve_path(path, ctx.mount_virtual, ctx.mount_host, ResolveMode::Existing)?; - rmdir_fs(&resolved.host_path, path) + let result = rmdir_fs(&resolved.host_path, path)?; + ctx.chmod_modes.remove(&resolved.host_path); + Ok(result) } FsRequest::Iterdir { path } => { let resolved = resolve_path(path, ctx.mount_virtual, ctx.mount_host, ResolveMode::Existing)?; iterdir_fs(&resolved.host_path, path, ctx.mount_host) } - FsRequest::Stat { path } => { - let resolved = resolve_path(path, ctx.mount_virtual, ctx.mount_host, ResolveMode::Existing)?; - stat_fs(&resolved.host_path, path) + FsRequest::Stat { path, follow_symlinks } => { + let mode = if follow_symlinks { + ResolveMode::Existing + } else { + ResolveMode::Lstat + }; + let resolved = resolve_path(path, ctx.mount_virtual, ctx.mount_host, mode)?; + let mode_override = ctx.chmod_modes.get(&resolved.host_path).copied(); + stat_fs(&resolved.host_path, path, follow_symlinks, mode_override) } + FsRequest::Chmod { + path, + mode, + follow_symlinks, + } => chmod(path, mode, follow_symlinks, ctx), + FsRequest::SymlinkTo { + path, + target, + target_is_directory, + } => symlink_to(path, target, target_is_directory, ctx), FsRequest::Rename { src, dst } => rename(src, dst, ctx), FsRequest::Resolve { path } | FsRequest::Absolute { path } => { Ok(MontyObject::Path(super::path_security::normalize_virtual_path(path))) @@ -99,6 +123,12 @@ fn is_symlink(path: &str, ctx: &MountContext<'_>) -> Result) -> Result { + let resolved = resolve_path(path, ctx.mount_virtual, ctx.mount_host, ResolveMode::Lstat)?; + readlink_fs(&resolved.host_path, path, ctx.mount_virtual, ctx.mount_host) +} + /// Writes text after validating quota and creation-path security. fn write_text(path: &str, data: &str, ctx: &mut MountContext<'_>) -> Result { check_write_limit(data.len(), ctx)?; @@ -128,20 +158,91 @@ fn mkdir(path: &str, parents: bool, exist_ok: bool, ctx: &MountContext<'_>) -> R mkdir_fs(&resolved.host_path, parents, exist_ok, path) } +/// Changes writable bits using the cross-platform readonly permission flag. +fn chmod(path: &str, mode: i64, follow_symlinks: bool, ctx: &mut MountContext<'_>) -> Result { + let resolve_mode = if follow_symlinks { + ResolveMode::Existing + } else { + ResolveMode::Lstat + }; + let resolved = resolve_path(path, ctx.mount_virtual, ctx.mount_host, resolve_mode)?; + + if !follow_symlinks && resolved.host_path.is_symlink() { + return Err(MountError::io_err( + ErrorKind::Unsupported, + "Operation not supported", + path, + )); + } + + let mut permissions = fs::metadata(&resolved.host_path) + .map_err(|err| MountError::Io(err, path.to_owned()))? + .permissions(); + permissions.set_readonly(mode & 0o222 == 0); + fs::set_permissions(&resolved.host_path, permissions).map_err(|err| MountError::Io(err, path.to_owned()))?; + ctx.chmod_modes.insert(resolved.host_path, mode); + Ok(MontyObject::None) +} + /// Removes a file or symlink entry itself rather than following symlink targets. -fn unlink(path: &str, ctx: &MountContext<'_>) -> Result { +fn unlink(path: &str, ctx: &mut MountContext<'_>) -> Result { let resolved = resolve_path(path, ctx.mount_virtual, ctx.mount_host, ResolveMode::Lstat)?; - unlink_fs(&resolved.host_path, path) + let result = unlink_fs(&resolved.host_path, path)?; + ctx.chmod_modes.remove(&resolved.host_path); + Ok(result) } /// Renames a filesystem entry within the same mount. -fn rename(src: &str, dst: &str, ctx: &MountContext<'_>) -> Result { +fn rename(src: &str, dst: &str, ctx: &mut MountContext<'_>) -> Result { let src_resolved = resolve_path(src, ctx.mount_virtual, ctx.mount_host, ResolveMode::Lstat)?; let dst_resolved = resolve_path(dst, ctx.mount_virtual, ctx.mount_host, ResolveMode::Creation)?; fs::rename(&src_resolved.host_path, &dst_resolved.host_path).map_err(|err| MountError::Io(err, src.to_owned()))?; + move_chmod_modes_for_rename(&src_resolved.host_path, &dst_resolved.host_path, ctx); Ok(MontyObject::None) } +/// Creates a symlink whose target remains within the mounted virtual namespace. +fn symlink_to( + path: &str, + target: &str, + target_is_directory: bool, + ctx: &MountContext<'_>, +) -> Result { + let resolved = resolve_path(path, ctx.mount_virtual, ctx.mount_host, ResolveMode::Creation)?; + let target_host = symlink_target_to_host_path(path, target, ctx)?; + symlink_fs(&resolved.host_path, &target_host, target_is_directory, path) +} + +/// Converts a sandbox symlink target into the host-native target used on disk. +fn symlink_target_to_host_path(path: &str, target: &str, ctx: &MountContext<'_>) -> Result { + if target.starts_with('/') { + let normalized = normalize_virtual_path(target); + reject_overlong_path(&normalized, target)?; + let relative = strip_mount_prefix(&normalized, ctx.mount_virtual).ok_or_else(|| MountError::PathEscape { + virtual_path: path.to_owned(), + })?; + return Ok(if relative.is_empty() { + ctx.mount_host.to_path_buf() + } else { + ctx.mount_host.join(relative) + }); + } + + let normalized_link = normalize_virtual_path(path); + let parent = normalized_link + .rsplit_once('/') + .map_or("/", |(prefix, _)| if prefix.is_empty() { "/" } else { prefix }); + let normalized_target = normalize_virtual_path(&format!("{parent}/{target}")); + reject_overlong_path(&normalized_target, target)?; + if strip_mount_prefix(&normalized_target, ctx.mount_virtual).is_none() { + return Err(MountError::PathEscape { + virtual_path: path.to_owned(), + }); + } + + Ok(relative_target_to_host_path(target)) +} + /// Resolves a path for boolean existence-style operations. /// /// These calls intentionally collapse host-side I/O misses into `Missing` @@ -157,3 +258,32 @@ fn resolve_existence_state( Err(err) => Err(err), } } + +/// Moves any recorded chmod overrides after a rename succeeds. +/// +/// Directory renames must also retarget descendant overrides so subsequent +/// `stat()` calls continue to report the requested mode bits on moved entries. +fn move_chmod_modes_for_rename(src: &Path, dst: &Path, ctx: &mut MountContext<'_>) { + let moved_keys: Vec = ctx + .chmod_modes + .keys() + .filter(|path| **path == *src || path.starts_with(src)) + .cloned() + .collect(); + + for old_path in moved_keys { + let mode = ctx + .chmod_modes + .remove(&old_path) + .expect("moved chmod keys are collected from chmod_modes"); + let new_path = if old_path == src { + dst.to_path_buf() + } else { + let relative = old_path + .strip_prefix(src) + .expect("moved descendant chmod key starts with rename source"); + dst.join(relative) + }; + ctx.chmod_modes.insert(new_path, mode); + } +} diff --git a/crates/monty/src/fs/dispatch.rs b/crates/monty/src/fs/dispatch.rs index 47b0f4952..00d297bcb 100644 --- a/crates/monty/src/fs/dispatch.rs +++ b/crates/monty/src/fs/dispatch.rs @@ -18,6 +18,8 @@ pub(super) enum FsRequest<'a> { IsDir { path: &'a str }, /// `Path.is_symlink()` IsSymlink { path: &'a str }, + /// `Path.readlink()` + Readlink { path: &'a str }, /// `Path.read_text()` ReadText { path: &'a str }, /// `Path.read_bytes()` @@ -42,9 +44,27 @@ pub(super) enum FsRequest<'a> { /// `Path.iterdir()` Iterdir { path: &'a str }, /// `Path.stat()` - Stat { path: &'a str }, + Stat { path: &'a str, follow_symlinks: bool }, + /// `Path.chmod(mode, follow_symlinks=...)` + Chmod { + /// Target path. + path: &'a str, + /// Requested mode bits. + mode: i64, + /// Whether to follow the final symlink component. + follow_symlinks: bool, + }, /// `Path.rename(dst)` Rename { src: &'a str, dst: &'a str }, + /// `Path.symlink_to(target, target_is_directory=...)` + SymlinkTo { + /// Link path created by the operation. + path: &'a str, + /// Raw symlink target string supplied by Python code. + target: &'a str, + /// Whether Windows should create a directory symlink. + target_is_directory: bool, + }, /// `Path.resolve()` Resolve { path: &'a str }, /// `Path.absolute()` @@ -60,6 +80,7 @@ impl<'a> FsRequest<'a> { | Self::IsFile { path } | Self::IsDir { path } | Self::IsSymlink { path } + | Self::Readlink { path } | Self::ReadText { path } | Self::ReadBytes { path } | Self::WriteText { path, .. } @@ -68,7 +89,9 @@ impl<'a> FsRequest<'a> { | Self::Unlink { path } | Self::Rmdir { path } | Self::Iterdir { path } - | Self::Stat { path } + | Self::Stat { path, .. } + | Self::Chmod { path, .. } + | Self::SymlinkTo { path, .. } | Self::Resolve { path } | Self::Absolute { path } | Self::Rename { src: path, .. } => path, @@ -92,6 +115,8 @@ impl<'a> FsRequest<'a> { Self::WriteText { .. } | Self::WriteBytes { .. } | Self::Mkdir { .. } + | Self::Chmod { .. } + | Self::SymlinkTo { .. } | Self::Unlink { .. } | Self::Rmdir { .. } | Self::Rename { .. } @@ -116,6 +141,7 @@ pub(super) fn parse_fs_request<'a>( OsFunction::IsFile => Ok(FsRequest::IsFile { path }), OsFunction::IsDir => Ok(FsRequest::IsDir { path }), OsFunction::IsSymlink => Ok(FsRequest::IsSymlink { path }), + OsFunction::Readlink => Ok(FsRequest::Readlink { path }), OsFunction::ReadText => Ok(FsRequest::ReadText { path }), OsFunction::ReadBytes => Ok(FsRequest::ReadBytes { path }), OsFunction::WriteText => Ok(FsRequest::WriteText { @@ -137,11 +163,28 @@ pub(super) fn parse_fs_request<'a>( OsFunction::Unlink => Ok(FsRequest::Unlink { path }), OsFunction::Rmdir => Ok(FsRequest::Rmdir { path }), OsFunction::Iterdir => Ok(FsRequest::Iterdir { path }), - OsFunction::Stat => Ok(FsRequest::Stat { path }), + OsFunction::Stat => Ok(FsRequest::Stat { + path, + follow_symlinks: parse_follow_symlinks(kwargs), + }), + OsFunction::Lstat => Ok(FsRequest::Stat { + path, + follow_symlinks: false, + }), + OsFunction::Chmod => Ok(FsRequest::Chmod { + path, + mode: parse_mode_arg(extra_args)?, + follow_symlinks: parse_follow_symlinks(kwargs), + }), OsFunction::Rename => Ok(FsRequest::Rename { src: path, dst: parse_path_arg(extra_args, "rename")?, }), + OsFunction::SymlinkTo => Ok(FsRequest::SymlinkTo { + path, + target: parse_path_arg(extra_args, "symlink_to")?, + target_is_directory: parse_target_is_directory(kwargs), + }), OsFunction::Resolve => Ok(FsRequest::Resolve { path }), OsFunction::Absolute => Ok(FsRequest::Absolute { path }), _ => unreachable!("non-filesystem OS function reached filesystem parser"), @@ -229,3 +272,39 @@ fn parse_mkdir_kwargs(kwargs: &[(MontyObject, MontyObject)]) -> (bool, bool) { (parents, exist_ok) } + +/// Extracts the mode integer for `Path.chmod(mode)`. +fn parse_mode_arg(extra_args: &[MontyObject]) -> Result { + match extra_args.first() { + Some(MontyObject::Int(mode)) => Ok(*mode), + Some(arg) => Err(MountError::InvalidMount(format!( + "chmod: mode must be int, not {}", + arg.type_name() + ))), + None => Err(MountError::InvalidMount( + "Path.chmod() missing 1 required positional argument: 'mode'".to_owned(), + )), + } +} + +/// Extracts `follow_symlinks` for `Path.stat()` and `Path.chmod()`. +fn parse_follow_symlinks(kwargs: &[(MontyObject, MontyObject)]) -> bool { + parse_bool_kwarg(kwargs, "follow_symlinks").unwrap_or(true) +} + +/// Extracts `target_is_directory` for `Path.symlink_to()`. +fn parse_target_is_directory(kwargs: &[(MontyObject, MontyObject)]) -> bool { + parse_bool_kwarg(kwargs, "target_is_directory").unwrap_or(false) +} + +/// Extracts a boolean keyword argument when present. +fn parse_bool_kwarg(kwargs: &[(MontyObject, MontyObject)], name: &str) -> Option { + for (key, value) in kwargs { + if let (MontyObject::String(key_name), MontyObject::Bool(flag)) = (key, value) + && key_name == name + { + return Some(*flag); + } + } + None +} diff --git a/crates/monty/src/fs/mount_table.rs b/crates/monty/src/fs/mount_table.rs index 49939d6a1..47ee8880e 100644 --- a/crates/monty/src/fs/mount_table.rs +++ b/crates/monty/src/fs/mount_table.rs @@ -4,6 +4,7 @@ //! virtual path to a real host directory with a specific access mode. use std::{ + collections::BTreeMap, fs, path::{Path, PathBuf}, sync::{Arc, Mutex}, @@ -219,6 +220,12 @@ pub struct Mount { write_bytes_used: u64, /// Optional cap on cumulative bytes written. When exceeded, writes raise `OSError`. write_bytes_limit: Option, + /// Mount-local mode overrides recorded by `chmod()` for cross-platform `stat()`. + /// + /// Host filesystems vary widely in how much of the POSIX mode space they + /// preserve. Keeping the requested bits here lets Monty report stable mode + /// values back to sandbox code without depending on host-specific behavior. + chmod_modes: BTreeMap, } impl Mount { @@ -261,6 +268,7 @@ impl Mount { mode, write_bytes_used: 0, write_bytes_limit, + chmod_modes: BTreeMap::new(), }) } @@ -301,6 +309,7 @@ impl Mount { mount_host: &self.host_path, write_bytes_used: &mut self.write_bytes_used, write_bytes_limit: self.write_bytes_limit, + chmod_modes: &mut self.chmod_modes, }; dispatch::execute(request, &mut ctx, &mut self.mode) } diff --git a/crates/monty/src/fs/overlay.rs b/crates/monty/src/fs/overlay.rs index 99ba8e446..7e51cd402 100644 --- a/crates/monty/src/fs/overlay.rs +++ b/crates/monty/src/fs/overlay.rs @@ -15,7 +15,7 @@ use ahash::AHashSet; use super::{ common::{ MountContext, bytes_to_utf8, check_write_limit, commit_write_bytes, current_timestamp, dir_mtime, - format_child_path, list_visible_real_dir_entry_names, read_bytes_fs, read_text_fs, stat_fs, + format_child_path, list_visible_real_dir_entry_names, read_bytes_fs, read_text_fs, readlink_fs, stat_fs, }, dispatch::FsRequest, error::MountError, @@ -47,6 +47,7 @@ pub(super) fn execute( FsRequest::IsFile { path } => is_file(state, &relative_path(path, ctx)?, ctx, path), FsRequest::IsDir { path } => is_dir(state, &relative_path(path, ctx)?, ctx, path), FsRequest::IsSymlink { path } => is_symlink(state, &relative_path(path, ctx)?, ctx, path), + FsRequest::Readlink { path } => readlink(state, &relative_path(path, ctx)?, ctx, path), FsRequest::ReadText { path } => read_text(state, &relative_path(path, ctx)?, ctx, path), FsRequest::ReadBytes { path } => read_bytes(state, &relative_path(path, ctx)?, ctx, path), FsRequest::WriteText { path, data } => write_text(state, path, data, ctx), @@ -56,10 +57,14 @@ pub(super) fn execute( parents, exist_ok, } => mkdir(state, &relative_path(path, ctx)?, parents, exist_ok, ctx, path), + FsRequest::Chmod { path, .. } => unsupported(path), + FsRequest::SymlinkTo { path, .. } => unsupported(path), FsRequest::Unlink { path } => unlink(state, &relative_path(path, ctx)?, ctx, path), FsRequest::Rmdir { path } => rmdir(state, &relative_path(path, ctx)?, ctx, path), FsRequest::Iterdir { path } => iterdir(state, &relative_path(path, ctx)?, ctx, path), - FsRequest::Stat { path } => stat(state, &relative_path(path, ctx)?, ctx, path), + FsRequest::Stat { path, follow_symlinks } => { + stat(state, &relative_path(path, ctx)?, ctx, path, follow_symlinks) + } FsRequest::Rename { src, dst } => rename(state, src, dst, ctx), FsRequest::Resolve { path } | FsRequest::Absolute { path } => { Ok(MontyObject::Path(normalize_virtual_path(path))) @@ -93,7 +98,8 @@ fn is_file( vpath: &str, ) -> Result { let is_file = match state.get(relative) { - Some(OverlayEntry::File(_) | OverlayEntry::RealFileRef(_)) => true, + Some(OverlayEntry::File(_)) => true, + Some(OverlayEntry::RealFileRef(file_ref)) => file_ref.host_path.is_file(), Some(OverlayEntry::Directory { .. } | OverlayEntry::Deleted) => false, None => match resolve_real_path_state(vpath, ctx, ResolveMode::Existing)? { RealPathState::Present(host_path) => host_path.is_file(), @@ -112,7 +118,8 @@ fn is_dir( ) -> Result { let is_dir = match state.get(relative) { Some(OverlayEntry::Directory { .. }) => true, - Some(OverlayEntry::File(_) | OverlayEntry::RealFileRef(_) | OverlayEntry::Deleted) => false, + Some(OverlayEntry::File(_) | OverlayEntry::Deleted) => false, + Some(OverlayEntry::RealFileRef(file_ref)) => file_ref.host_path.is_dir(), None => match resolve_real_path_state(vpath, ctx, ResolveMode::Existing)? { RealPathState::Present(host_path) => host_path.is_dir(), RealPathState::Missing => false, @@ -129,6 +136,7 @@ fn is_symlink( vpath: &str, ) -> Result { let is_symlink = match state.get(relative) { + Some(OverlayEntry::RealFileRef(file_ref)) => file_ref.host_path.is_symlink(), Some(_) => false, None => match resolve_real_path_state(vpath, ctx, ResolveMode::Lstat)? { RealPathState::Present(host_path) => host_path.is_symlink(), @@ -138,6 +146,31 @@ fn is_symlink( Ok(MontyObject::Bool(is_symlink)) } +/// Implements `Path.readlink()` for real symlink fallthrough and renamed symlinks. +fn readlink( + state: &OverlayState, + relative: &str, + ctx: &MountContext<'_>, + vpath: &str, +) -> Result { + match state.get(relative) { + Some(OverlayEntry::RealFileRef(file_ref)) if file_ref.host_path.is_symlink() => { + readlink_fs(&file_ref.host_path, vpath, ctx.mount_virtual, ctx.mount_host) + } + Some(OverlayEntry::File(_) | OverlayEntry::Directory { .. }) => { + Err(MountError::io_err(ErrorKind::InvalidInput, "Invalid argument", vpath)) + } + Some(OverlayEntry::Deleted) => Err(MountError::not_found(vpath)), + Some(OverlayEntry::RealFileRef(_)) => { + Err(MountError::io_err(ErrorKind::InvalidInput, "Invalid argument", vpath)) + } + None => { + let resolved = resolve_path(vpath, ctx.mount_virtual, ctx.mount_host, ResolveMode::Lstat)?; + readlink_fs(&resolved.host_path, vpath, ctx.mount_virtual, ctx.mount_host) + } + } +} + /// Reads text from the overlay or from the real filesystem on fallback. fn read_text( state: &OverlayState, @@ -501,22 +534,42 @@ fn real_directory_has_visible_children( } /// Returns the `stat()` result for an overlay or fallthrough path. -fn stat(state: &OverlayState, relative: &str, ctx: &MountContext<'_>, vpath: &str) -> Result { +fn stat( + state: &OverlayState, + relative: &str, + ctx: &MountContext<'_>, + vpath: &str, + follow_symlinks: bool, +) -> Result { match state.get(relative) { Some(OverlayEntry::File(file)) => { let size = i64::try_from(file.content.len()).unwrap_or(i64::MAX); Ok(file_stat(0o644, size, file.mtime)) } - Some(OverlayEntry::RealFileRef(file_ref)) => Ok(file_stat(0o644, file_ref.size, file_ref.mtime)), + Some(OverlayEntry::RealFileRef(file_ref)) => stat_fs(&file_ref.host_path, vpath, follow_symlinks, None), Some(OverlayEntry::Directory { mtime }) => Ok(dir_stat(0o755, *mtime)), Some(OverlayEntry::Deleted) => Err(MountError::not_found(vpath)), None => { - let resolved = resolve_path(vpath, ctx.mount_virtual, ctx.mount_host, ResolveMode::Existing)?; - stat_fs(&resolved.host_path, vpath) + let mode = if follow_symlinks { + ResolveMode::Existing + } else { + ResolveMode::Lstat + }; + let resolved = resolve_path(vpath, ctx.mount_virtual, ctx.mount_host, mode)?; + stat_fs(&resolved.host_path, vpath, follow_symlinks, None) } } } +/// Returns a consistent "operation not supported" error for overlay-only gaps. +fn unsupported(vpath: &str) -> Result { + Err(MountError::io_err( + ErrorKind::Unsupported, + "Operation not supported", + vpath, + )) +} + /// Lists directory contents while merging overlay and real entries. fn iterdir( state: &OverlayState, diff --git a/crates/monty/src/fs/overlay_state.rs b/crates/monty/src/fs/overlay_state.rs index 179940507..82c291cae 100644 --- a/crates/monty/src/fs/overlay_state.rs +++ b/crates/monty/src/fs/overlay_state.rs @@ -9,7 +9,6 @@ use std::{ fs, ops::Bound, path::{Path, PathBuf}, - time::SystemTime, }; /// In-memory overlay state for [`super::MountMode::OverlayMemory`]. @@ -110,10 +109,6 @@ pub(super) struct OverlayFile { pub(super) struct OverlayFileRef { /// Canonical host path for the original file contents. pub host_path: PathBuf, - /// Modification time copied from the original file. - pub mtime: f64, - /// File size in bytes. - pub size: i64, } impl OverlayFileRef { @@ -124,17 +119,9 @@ impl OverlayFileRef { /// the path itself is a symlink that should be preserved as-is. #[must_use] pub fn from_host_path(path: &Path) -> Option { - let metadata = fs::metadata(path).ok()?; - let mtime = metadata - .modified() - .unwrap_or(SystemTime::UNIX_EPOCH) - .duration_since(SystemTime::UNIX_EPOCH) - .map_or(0.0, |duration| duration.as_secs_f64()); - let size = i64::try_from(metadata.len()).unwrap_or(i64::MAX); + fs::metadata(path).ok()?; Some(Self { host_path: path.to_path_buf(), - mtime, - size, }) } @@ -145,17 +132,9 @@ impl OverlayFileRef { /// symlink identity across overlay renames. #[must_use] pub fn from_lstat(path: &Path) -> Option { - let metadata = fs::symlink_metadata(path).ok()?; - let mtime = metadata - .modified() - .unwrap_or(SystemTime::UNIX_EPOCH) - .duration_since(SystemTime::UNIX_EPOCH) - .map_or(0.0, |duration| duration.as_secs_f64()); - let size = i64::try_from(metadata.len()).unwrap_or(i64::MAX); + fs::symlink_metadata(path).ok()?; Some(Self { host_path: path.to_path_buf(), - mtime, - size, }) } } diff --git a/crates/monty/src/intern.rs b/crates/monty/src/intern.rs index 77e9b2e6c..ef69408f6 100644 --- a/crates/monty/src/intern.rs +++ b/crates/monty/src/intern.rs @@ -335,8 +335,11 @@ pub enum StaticStrings { IsFile, IsDir, IsSymlink, + Readlink, #[strum(serialize = "stat")] StatMethod, + Lstat, + Chmod, ReadBytes, ReadText, Iterdir, @@ -347,9 +350,12 @@ pub enum StaticStrings { WriteText, WriteBytes, Mkdir, + SymlinkTo, Unlink, Rmdir, Rename, + FollowSymlinks, + TargetIsDirectory, // Slice attributes Start, diff --git a/crates/monty/src/os.rs b/crates/monty/src/os.rs index c2e46c2d3..dfaa6da92 100644 --- a/crates/monty/src/os.rs +++ b/crates/monty/src/os.rs @@ -39,6 +39,9 @@ pub enum OsFunction { /// Check if path is a symbolic link #[strum(serialize = "Path.is_symlink")] IsSymlink, + /// Read the raw target of a symbolic link + #[strum(serialize = "Path.readlink")] + Readlink, /// Read file contents as text #[strum(serialize = "Path.read_text")] ReadText, @@ -66,9 +69,18 @@ pub enum OsFunction { /// Get file stats #[strum(serialize = "Path.stat")] Stat, + /// Get file stats without following the final symbolic link + #[strum(serialize = "Path.lstat")] + Lstat, + /// Change file mode bits + #[strum(serialize = "Path.chmod")] + Chmod, /// Rename/move file #[strum(serialize = "Path.rename")] Rename, + /// Create a symbolic link + #[strum(serialize = "Path.symlink_to")] + SymlinkTo, /// Get resolved absolute path #[strum(serialize = "Path.resolve")] Resolve, @@ -115,7 +127,14 @@ impl OsFunction { pub fn is_write(&self) -> bool { matches!( self, - Self::WriteText | Self::WriteBytes | Self::Mkdir | Self::Unlink | Self::Rmdir | Self::Rename + Self::WriteText + | Self::WriteBytes + | Self::Mkdir + | Self::SymlinkTo + | Self::Chmod + | Self::Unlink + | Self::Rmdir + | Self::Rename ) } @@ -167,9 +186,12 @@ impl TryFrom for OsFunction { StaticStrings::IsFile => Ok(Self::IsFile), StaticStrings::IsDir => Ok(Self::IsDir), StaticStrings::IsSymlink => Ok(Self::IsSymlink), + StaticStrings::Readlink => Ok(Self::Readlink), StaticStrings::ReadText => Ok(Self::ReadText), StaticStrings::ReadBytes => Ok(Self::ReadBytes), StaticStrings::StatMethod => Ok(Self::Stat), + StaticStrings::Lstat => Ok(Self::Lstat), + StaticStrings::Chmod => Ok(Self::Chmod), StaticStrings::Iterdir => Ok(Self::Iterdir), StaticStrings::Resolve => Ok(Self::Resolve), StaticStrings::Absolute => Ok(Self::Absolute), @@ -177,6 +199,7 @@ impl TryFrom for OsFunction { StaticStrings::WriteText => Ok(Self::WriteText), StaticStrings::WriteBytes => Ok(Self::WriteBytes), StaticStrings::Mkdir => Ok(Self::Mkdir), + StaticStrings::SymlinkTo => Ok(Self::SymlinkTo), StaticStrings::Unlink => Ok(Self::Unlink), StaticStrings::Rmdir => Ok(Self::Rmdir), StaticStrings::Rename => Ok(Self::Rename), diff --git a/crates/monty/test_cases/pathlib__os.py b/crates/monty/test_cases/pathlib__os.py index a8d3936d5..a4d55f31a 100644 --- a/crates/monty/test_cases/pathlib__os.py +++ b/crates/monty/test_cases/pathlib__os.py @@ -49,6 +49,25 @@ assert st_dir.st_mode & 0o170000 == 0o040000, 'stat is directory' assert st_dir.st_mode & 0o777 == 0o755, 'stat dir mode permissions' +# === lstat() and stat(follow_symlinks=False) === +st_lstat = Path('/virtual/file.txt').lstat() +assert st_lstat.st_size == 12, 'lstat size for regular file' +assert st_lstat.st_mode & 0o777 == 0o644, 'lstat mode permissions' + +st_lstat_dir = Path('/virtual/subdir').lstat() +assert st_lstat_dir.st_mode & 0o170000 == 0o040000, 'lstat directory mode type' +assert st_lstat_dir.st_mode & 0o777 == 0o755, 'lstat directory permissions' + +st_no_follow = Path('/virtual/file.txt').stat(follow_symlinks=False) +assert st_no_follow.st_size == 12, 'stat follow_symlinks=False size for regular file' +assert st_no_follow.st_mode & 0o777 == 0o644, 'stat follow_symlinks=False mode permissions' + +try: + Path('/nonexistent').lstat() + assert False, 'expected lstat() on nonexistent path to fail' +except FileNotFoundError as exc: + assert str(exc) == "[Errno 2] No such file or directory: '/nonexistent'", f'unexpected lstat error: {exc}' + # === stat() index access === st2 = Path('/virtual/file.txt').stat() assert st2[6] == 12, 'stat index access for st_size' @@ -109,6 +128,16 @@ Path('/virtual/binary.dat').write_bytes(b'\xff\xfe\xfd') assert Path('/virtual/binary.dat').read_bytes() == b'\xff\xfe\xfd', 'write_bytes creates file' +# === chmod() === +Path('/virtual/file.txt').chmod(0o600) +assert Path('/virtual/file.txt').stat().st_mode & 0o777 == 0o600, 'chmod updates file mode' + +Path('/virtual/file.txt').chmod(0o640, follow_symlinks=False) +assert Path('/virtual/file.txt').stat().st_mode & 0o777 == 0o640, 'chmod follow_symlinks=False updates file mode' + +Path('/nonexistent').chmod(0o600) +assert Path('/nonexistent').exists() == False, 'chmod on nonexistent path remains nonexistent in iter mode' + # === mkdir() === Path('/virtual/new_dir').mkdir() assert Path('/virtual/new_dir').is_dir() == True, 'mkdir creates directory' @@ -135,3 +164,18 @@ Path('/virtual/old_name.txt').rename(Path('/virtual/new_name.txt')) assert Path('/virtual/old_name.txt').exists() == False, 'rename removes old path' assert Path('/virtual/new_name.txt').read_text() == 'rename test', 'rename creates new path' + +# === unsupported readlink() and symlink_to() in iter mode === +try: + Path('/virtual/file.txt').readlink() + assert False, 'expected readlink() on regular file to fail' +except OSError as exc: + assert str(exc) == "[Errno 22] Invalid argument: '/virtual/file.txt'", f'unexpected readlink error: {exc}' + +try: + Path('/virtual/link.txt').symlink_to(Path('/virtual/file.txt')) + assert False, 'expected symlink_to() in iter mode to fail' +except OSError as exc: + assert str(exc) == "Path.symlink_to() is not supported in iter mode: '/virtual/link.txt'", ( + f'unexpected symlink_to error: {exc}' + ) diff --git a/crates/monty/tests/fs.rs b/crates/monty/tests/fs.rs index adb541424..baf61307d 100644 --- a/crates/monty/tests/fs.rs +++ b/crates/monty/tests/fs.rs @@ -55,6 +55,13 @@ fn mount_at_mnt(tmpdir: &TempDir, mode: MountMode) -> MountTable { mt } +/// Creates a `MountTable` with a single mount at `/`. +fn mount_at_root(tmpdir: &TempDir, mode: MountMode) -> MountTable { + let mut mt = MountTable::new(); + mt.mount("/", tmpdir.path(), mode, None).unwrap(); + mt +} + /// Shorthand: call handle_os_call with a single path argument. fn call(mt: &mut MountTable, func: OsFunction, path: &str) -> Option> { mt.handle_os_call(func, &[MontyObject::Path(path.to_owned())], &[]) @@ -91,6 +98,24 @@ fn symlink_file(original: impl AsRef, link: impl AsRef) { } } +/// Creates a directory symlink, handling platform differences. +/// +/// On Unix, uses `std::os::unix::fs::symlink`. On Windows, uses +/// `std::os::windows::fs::symlink_dir`. +fn symlink_dir(original: impl AsRef, link: impl AsRef) { + #[cfg(unix)] + { + use std::os::unix::fs::symlink as unix_symlink; + unix_symlink(original.as_ref(), link.as_ref()).unwrap(); + } + + #[cfg(windows)] + { + use std::os::windows::fs::symlink_dir as win_symlink_dir; + win_symlink_dir(original.as_ref(), link.as_ref()).unwrap(); + } +} + /// Asserts an exception has the expected type and message. #[track_caller] fn assert_exc(exc: &MontyException, expected_type: ExcType, expected_msg: &str) { @@ -134,6 +159,88 @@ fn call_rename(mt: &mut MountTable, src: &str, dst: &str) -> Option Option> { + mt.handle_os_call(OsFunction::Readlink, &[MontyObject::Path(path.to_owned())], &[]) +} + +/// Shorthand for `Path.lstat()`. +fn call_lstat(mt: &mut MountTable, path: &str) -> Option> { + mt.handle_os_call(OsFunction::Lstat, &[MontyObject::Path(path.to_owned())], &[]) +} + +/// Shorthand for `Path.stat(follow_symlinks=...)`. +fn call_stat_follow_symlinks( + mt: &mut MountTable, + path: &str, + follow_symlinks: bool, +) -> Option> { + mt.handle_os_call( + OsFunction::Stat, + &[MontyObject::Path(path.to_owned())], + &[( + MontyObject::String("follow_symlinks".to_owned()), + MontyObject::Bool(follow_symlinks), + )], + ) +} + +/// Shorthand for `Path.chmod(mode, follow_symlinks=...)`. +fn call_chmod( + mt: &mut MountTable, + path: &str, + mode: i64, + follow_symlinks: bool, +) -> Option> { + mt.handle_os_call( + OsFunction::Chmod, + &[MontyObject::Path(path.to_owned()), MontyObject::Int(mode)], + &[( + MontyObject::String("follow_symlinks".to_owned()), + MontyObject::Bool(follow_symlinks), + )], + ) +} + +/// Shorthand for `Path.symlink_to(target, target_is_directory=...)`. +fn call_symlink_to( + mt: &mut MountTable, + path: &str, + target: &str, + target_is_directory: bool, +) -> Option> { + mt.handle_os_call( + OsFunction::SymlinkTo, + &[MontyObject::Path(path.to_owned()), MontyObject::Path(target.to_owned())], + &[( + MontyObject::String("target_is_directory".to_owned()), + MontyObject::Bool(target_is_directory), + )], + ) +} + +/// Extracts `st_mode` from a stat result. +fn stat_mode(obj: &MontyObject) -> i64 { + match obj { + MontyObject::NamedTuple { values, .. } => match &values[0] { + MontyObject::Int(mode) => *mode, + other => panic!("expected st_mode to be Int, got {other:?}"), + }, + other => panic!("expected NamedTuple from stat, got {other:?}"), + } +} + +/// Extracts `st_size` from a stat result. +fn stat_size(obj: &MontyObject) -> i64 { + match obj { + MontyObject::NamedTuple { values, .. } => match &values[6] { + MontyObject::Int(size) => *size, + other => panic!("expected st_size to be Int, got {other:?}"), + }, + other => panic!("expected NamedTuple from stat, got {other:?}"), + } +} + /// Extracts entry names from an iterdir result list, sorted for deterministic comparison. fn sorted_names(obj: &MontyObject) -> Vec { match obj { @@ -700,6 +807,38 @@ fn ro_rename_blocked() { ); } +#[test] +fn ro_chmod_blocked() { + let dir = create_test_dir(); + let mut mt = mount_at_mnt(&dir, MountMode::ReadOnly); + + let exc = call_chmod(&mut mt, "/mnt/hello.txt", 0o600, true) + .unwrap() + .unwrap_err() + .into_exception(); + assert_exc( + &exc, + ExcType::PermissionError, + "[Errno 30] Read-only file system: '/mnt/hello.txt'", + ); +} + +#[test] +fn ro_symlink_to_blocked() { + let dir = create_test_dir(); + let mut mt = mount_at_mnt(&dir, MountMode::ReadOnly); + + let exc = call_symlink_to(&mut mt, "/mnt/new_link.txt", "/mnt/hello.txt", false) + .unwrap() + .unwrap_err() + .into_exception(); + assert_exc( + &exc, + ExcType::PermissionError, + "[Errno 30] Read-only file system: '/mnt/new_link.txt'", + ); +} + // ============================================================================= // OverlayMemory mode // ============================================================================= @@ -1969,203 +2108,755 @@ fn rw_rename_symlink_renames_link_not_target() { ); } -// ============================================================================= -// Failed writes should not consume write quota (Issue #4) -// ============================================================================= - -/// A failed write (e.g. parent doesn't exist) must not burn quota. +/// `readlink()` on an absolute in-mount symlink should return the virtual target path. #[test] -fn rw_failed_write_does_not_consume_quota() { +fn rw_readlink_absolute_target_returns_virtual_path() { let dir = create_test_dir(); - let mut mt = mount_at_mnt_with_limit(&dir, MountMode::ReadWrite, 10); - - // Write to a nonexistent parent — this should fail. - let result = call_write( - &mut mt, - OsFunction::WriteText, - "/mnt/no_such_dir/file.txt", - MontyObject::String("12345".to_owned()), - ); - assert!(result.unwrap().is_err()); + symlink_file(dir.path().join("hello.txt"), dir.path().join("link.txt")); - // Now write exactly at the limit — should succeed since the failed write - // didn't consume any quota. - call_write( - &mut mt, - OsFunction::WriteText, - "/mnt/quota_ok.txt", - MontyObject::String("0123456789".to_owned()), - ) - .unwrap() - .unwrap(); + let mut mt = mount_at_mnt(&dir, MountMode::ReadWrite); + let target = call_readlink(&mut mt, "/mnt/link.txt").unwrap().unwrap(); + assert_eq!(target, MontyObject::Path("/mnt/hello.txt".to_owned())); } -/// Same quota-preservation test for overlay mode. +/// `readlink()` should preserve an in-mount relative target exactly as stored. #[test] -fn ovl_failed_write_does_not_consume_quota() { +#[cfg(unix)] +fn rw_readlink_relative_target_returns_raw_relative_path() { let dir = create_test_dir(); - let mut mt = mount_at_mnt_with_limit(&dir, MountMode::OverlayMemory(OverlayState::new()), 10); + symlink_file("hello.txt", dir.path().join("relative_link.txt")); - // Write to a path whose parent doesn't exist — should fail. - let result = call_write( - &mut mt, - OsFunction::WriteText, - "/mnt/no_such_dir/file.txt", - MontyObject::String("12345".to_owned()), + let mut mt = mount_at_mnt(&dir, MountMode::ReadWrite); + let target = call_readlink(&mut mt, "/mnt/relative_link.txt").unwrap().unwrap(); + assert_eq!(target, MontyObject::Path("hello.txt".to_owned())); +} + +/// Broken absolute symlinks that still point inside the mount should round-trip. +#[test] +#[cfg(unix)] +fn rw_readlink_broken_absolute_target_within_mount() { + let dir = create_test_dir(); + symlink_file( + dir.path().join("missing_parent").join("ghost.txt"), + dir.path().join("broken_abs_link.txt"), ); - assert!(result.unwrap().is_err()); - // Valid write of exactly 10 bytes should succeed. - call_write( - &mut mt, - OsFunction::WriteText, - "/mnt/quota_ok.txt", - MontyObject::String("0123456789".to_owned()), - ) - .unwrap() - .unwrap(); + let mut mt = mount_at_mnt(&dir, MountMode::ReadWrite); + let target = call_readlink(&mut mt, "/mnt/broken_abs_link.txt").unwrap().unwrap(); + assert_eq!(target, MontyObject::Path("/mnt/missing_parent/ghost.txt".to_owned())); } -// ============================================================================= -// Overlay rename preserves access to descendants (Issue #7) -// ============================================================================= +/// Broken but in-bounds relative symlinks should still expose their raw target. +#[test] +#[cfg(unix)] +fn rw_readlink_broken_relative_target_within_mount() { + let dir = create_test_dir(); + symlink_file("missing.txt", dir.path().join("broken_link.txt")); -/// Renaming a directory in overlay mode must make all descendants accessible -/// under the new prefix. + let mut mt = mount_at_mnt(&dir, MountMode::ReadWrite); + let target = call_readlink(&mut mt, "/mnt/broken_link.txt").unwrap().unwrap(); + assert_eq!(target, MontyObject::Path("missing.txt".to_owned())); +} + +/// `readlink()` should also work for real symlinks falling through overlay mode. #[test] -fn ovl_rename_directory_preserves_descendants() { +fn ovl_mem_readlink_real_symlink() { let dir = create_test_dir(); - // test_dir has subdir/nested.txt and subdir/deep/file.txt + symlink_file(dir.path().join("hello.txt"), dir.path().join("link.txt")); let mut mt = mount_at_mnt(&dir, MountMode::OverlayMemory(OverlayState::new())); + let target = call_readlink(&mut mt, "/mnt/link.txt").unwrap().unwrap(); + assert_eq!(target, MontyObject::Path("/mnt/hello.txt".to_owned())); +} - call_rename(&mut mt, "/mnt/subdir", "/mnt/renamed_dir") +/// A renamed real symlink should keep working through the overlay `RealFileRef` path. +#[test] +#[cfg(unix)] +fn ovl_mem_readlink_renamed_symlink() { + let dir = create_test_dir(); + symlink_file(dir.path().join("hello.txt"), dir.path().join("link.txt")); + + let mut mt = mount_at_mnt(&dir, MountMode::OverlayMemory(OverlayState::new())); + call_rename(&mut mt, "/mnt/link.txt", "/mnt/moved_link.txt") .unwrap() .unwrap(); - // Descendants should be accessible under the new prefix. - let result = call_ok(&mut mt, OsFunction::ReadText, "/mnt/renamed_dir/nested.txt"); - assert_eq!(result, MontyObject::String("nested content".to_owned())); - - let result = call_ok(&mut mt, OsFunction::ReadText, "/mnt/renamed_dir/deep/file.txt"); - assert_eq!(result, MontyObject::String("deep file".to_owned())); - - // Old paths should not exist. - let result = call_ok(&mut mt, OsFunction::Exists, "/mnt/subdir/nested.txt"); - assert_eq!(result, MontyObject::Bool(false)); + let target = call_readlink(&mut mt, "/mnt/moved_link.txt").unwrap().unwrap(); + assert_eq!(target, MontyObject::Path("/mnt/hello.txt".to_owned())); } -// ============================================================================= -// Overlay rename: destination type validation -// ============================================================================= - -/// Renaming a file onto an existing directory should raise IsADirectoryError. +/// Root-mounted absolute symlinks should map back to `/` rather than leaking host paths. #[test] -fn ovl_mem_rename_file_onto_directory() { +#[cfg(unix)] +fn rw_readlink_absolute_target_returns_root_virtual_path() { let dir = create_test_dir(); - let mut mt = mount_at_mnt(&dir, MountMode::OverlayMemory(OverlayState::new())); + symlink_dir(dir.path(), dir.path().join("root_link")); - let result = call_rename(&mut mt, "/mnt/hello.txt", "/mnt/subdir"); - let exc = result.unwrap().unwrap_err().into_exception(); - assert_eq!(exc.exc_type(), ExcType::IsADirectoryError); + let mut mt = mount_at_root(&dir, MountMode::ReadWrite); + let target = call_readlink(&mut mt, "/root_link").unwrap().unwrap(); + assert_eq!(target, MontyObject::Path("/".to_owned())); } -/// Renaming a directory onto an existing file should raise NotADirectoryError. +/// Root-mounted absolute symlinks should also preserve non-root virtual suffixes. #[test] -fn ovl_mem_rename_directory_onto_file() { +#[cfg(unix)] +fn rw_readlink_absolute_target_returns_root_subpath_virtual_path() { let dir = create_test_dir(); - let mut mt = mount_at_mnt(&dir, MountMode::OverlayMemory(OverlayState::new())); + symlink_dir(dir.path().join("subdir"), dir.path().join("subdir_link")); - let result = call_rename(&mut mt, "/mnt/subdir", "/mnt/hello.txt"); - let exc = result.unwrap().unwrap_err().into_exception(); - assert_eq!(exc.exc_type(), ExcType::NotADirectoryError); + let mut mt = mount_at_root(&dir, MountMode::ReadWrite); + let target = call_readlink(&mut mt, "/subdir_link").unwrap().unwrap(); + assert_eq!(target, MontyObject::Path("/subdir".to_owned())); } -/// Renaming a directory into its own descendant should raise OSError. +/// Relative `./target` symlinks should preserve the raw target while normalizing safely. #[test] -fn ovl_mem_rename_directory_into_own_subdir() { +#[cfg(unix)] +fn rw_readlink_relative_dot_target_returns_raw_relative_path() { let dir = create_test_dir(); - let mut mt = mount_at_mnt(&dir, MountMode::OverlayMemory(OverlayState::new())); + symlink_file("./hello.txt", dir.path().join("dot_link.txt")); - let result = call_rename(&mut mt, "/mnt/subdir", "/mnt/subdir/deep/moved"); - let exc = result.unwrap().unwrap_err().into_exception(); - assert_eq!(exc.exc_type(), ExcType::OSError); - assert!( - exc.message().unwrap_or("").contains("Invalid argument"), - "expected 'Invalid argument', got: {:?}", - exc.message() - ); + let mut mt = mount_at_mnt(&dir, MountMode::ReadWrite); + let target = call_readlink(&mut mt, "/mnt/dot_link.txt").unwrap().unwrap(); + assert_eq!(target, MontyObject::Path("./hello.txt".to_owned())); } -/// Renaming an overlay file onto an overlay directory should raise IsADirectoryError. +/// `lstat()` should report the symlink itself rather than its target. #[test] -fn ovl_mem_rename_overlay_file_onto_overlay_dir() { +fn rw_lstat_reports_symlink_mode() { let dir = create_test_dir(); - let mut mt = mount_at_mnt(&dir, MountMode::OverlayMemory(OverlayState::new())); - - // Create an overlay file and directory - call_write( - &mut mt, - OsFunction::WriteText, - "/mnt/src.txt", - MontyObject::String("content".to_owned()), - ) - .unwrap() - .unwrap(); - call_mkdir(&mut mt, "/mnt/dst_dir", false, false).unwrap().unwrap(); + symlink_file(dir.path().join("hello.txt"), dir.path().join("link.txt")); - let result = call_rename(&mut mt, "/mnt/src.txt", "/mnt/dst_dir"); - let exc = result.unwrap().unwrap_err().into_exception(); - assert_eq!(exc.exc_type(), ExcType::IsADirectoryError); + let mut mt = mount_at_mnt(&dir, MountMode::ReadWrite); + let stat = call_lstat(&mut mt, "/mnt/link.txt").unwrap().unwrap(); + assert_eq!(stat_mode(&stat) & 0o170_000, 0o120_000); } -// ============================================================================= -// Overlay rename: symlink preservation -// ============================================================================= - -/// Renaming a real symlink in overlay mode should preserve its symlink identity. +/// `stat(follow_symlinks=False)` should also report the symlink itself. #[test] -#[cfg(unix)] -fn ovl_mem_rename_symlink_preserves_symlink() { +fn rw_stat_follow_symlinks_false_reports_symlink_mode() { let dir = create_test_dir(); symlink_file(dir.path().join("hello.txt"), dir.path().join("link.txt")); - let mut mt = mount_at_mnt(&dir, MountMode::OverlayMemory(OverlayState::new())); - - // Before rename: link should be a symlink - let result = call_ok(&mut mt, OsFunction::IsSymlink, "/mnt/link.txt"); - assert_eq!(result, MontyObject::Bool(true)); - - // Rename the symlink - call_rename(&mut mt, "/mnt/link.txt", "/mnt/moved_link.txt") + let mut mt = mount_at_mnt(&dir, MountMode::ReadWrite); + let stat = call_stat_follow_symlinks(&mut mt, "/mnt/link.txt", false) .unwrap() .unwrap(); + assert_eq!(stat_mode(&stat) & 0o170_000, 0o120_000); +} - // After rename: the moved path should still be readable (via the stored host ref) - let result = call_ok(&mut mt, OsFunction::ReadText, "/mnt/moved_link.txt"); - assert_eq!(result, MontyObject::String("hello world\n".to_owned())); +/// `chmod()` should be reflected by a subsequent `stat()` on direct mounts. +#[test] +fn rw_chmod_round_trips_stat_mode() { + let dir = create_test_dir(); + let mut mt = mount_at_mnt(&dir, MountMode::ReadWrite); - // Original symlink path should be gone - let result = call_ok(&mut mt, OsFunction::Exists, "/mnt/link.txt"); - assert_eq!(result, MontyObject::Bool(false)); + call_chmod(&mut mt, "/mnt/hello.txt", 0o600, true).unwrap().unwrap(); - // Original target should still exist - let result = call_ok(&mut mt, OsFunction::Exists, "/mnt/hello.txt"); - assert_eq!(result, MontyObject::Bool(true)); + let stat = call_ok(&mut mt, OsFunction::Stat, "/mnt/hello.txt"); + assert_eq!(stat_mode(&stat) & 0o777, 0o600); + assert_eq!(stat_size(&stat), 12); } -// ============================================================================= -// Overlay rmdir: must check overlay children on real directories -// ============================================================================= - -/// rmdir on a real directory must fail if it has overlay-only children. +/// Direct mounts should also preserve chmod overrides for directories. #[test] -fn ovl_mem_rmdir_real_dir_with_overlay_children() { +fn rw_chmod_directory_round_trips_stat_mode() { let dir = create_test_dir(); - let mut mt = mount_at_mnt(&dir, MountMode::OverlayMemory(OverlayState::new())); + let mut mt = mount_at_mnt(&dir, MountMode::ReadWrite); - // Delete the real child via tombstone - call(&mut mt, OsFunction::Unlink, "/mnt/subdir/nested.txt") + call_chmod(&mut mt, "/mnt/subdir", 0o700, true).unwrap().unwrap(); + + let stat = call_ok(&mut mt, OsFunction::Stat, "/mnt/subdir"); + assert_eq!(stat_mode(&stat) & 0o777, 0o700); + assert_eq!(stat_mode(&stat) & 0o170_000, 0o040_000); +} + +/// Chmod mode overrides should move with renamed files on direct mounts. +#[test] +fn rw_chmod_mode_persists_after_rename() { + let dir = create_test_dir(); + let mut mt = mount_at_mnt(&dir, MountMode::ReadWrite); + + call_chmod(&mut mt, "/mnt/hello.txt", 0o600, true).unwrap().unwrap(); + call_rename(&mut mt, "/mnt/hello.txt", "/mnt/renamed.txt") + .unwrap() + .unwrap(); + + let stat = call_ok(&mut mt, OsFunction::Stat, "/mnt/renamed.txt"); + assert_eq!(stat_mode(&stat) & 0o777, 0o600); +} + +/// Directory renames should move descendant chmod overrides as well. +#[test] +fn rw_chmod_descendant_mode_persists_after_directory_rename() { + let dir = create_test_dir(); + let mut mt = mount_at_mnt(&dir, MountMode::ReadWrite); + + call_chmod(&mut mt, "/mnt/subdir/nested.txt", 0o600, true) + .unwrap() + .unwrap(); + call_rename(&mut mt, "/mnt/subdir", "/mnt/renamed_dir") + .unwrap() + .unwrap(); + + let stat = call_ok(&mut mt, OsFunction::Stat, "/mnt/renamed_dir/nested.txt"); + assert_eq!(stat_mode(&stat) & 0o777, 0o600); +} + +/// Non-following chmod on symlinks should surface the explicit unsupported error. +#[test] +fn rw_chmod_follow_symlinks_false_on_symlink_is_unsupported() { + let dir = create_test_dir(); + symlink_file(dir.path().join("hello.txt"), dir.path().join("link.txt")); + + let mut mt = mount_at_mnt(&dir, MountMode::ReadWrite); + let exc = call_chmod(&mut mt, "/mnt/link.txt", 0o600, false) + .unwrap() + .unwrap_err() + .into_exception(); + assert_eq!(exc.exc_type(), ExcType::OSError); + assert!( + exc.message().unwrap_or("").contains("Operation not supported"), + "expected unsupported error, got {:?}", + exc.message() + ); +} + +/// Missing files should remain missing after iter-style chmod bookkeeping. +#[test] +fn rw_chmod_missing_path_does_not_create_file() { + let dir = create_test_dir(); + let mut mt = mount_at_mnt(&dir, MountMode::ReadWrite); + + let exc = call_chmod(&mut mt, "/mnt/missing.txt", 0o600, true) + .unwrap() + .unwrap_err() + .into_exception(); + assert_eq!(exc.exc_type(), ExcType::FileNotFoundError); + assert_eq!( + exc.message().unwrap_or(""), + "[Errno 2] No such file or directory: '/mnt/missing.txt'" + ); +} + +/// Overlay mounts currently reject chmod because they do not track symlink-aware metadata. +#[test] +fn ovl_mem_chmod_is_unsupported() { + let dir = create_test_dir(); + let mut mt = mount_at_mnt(&dir, MountMode::OverlayMemory(OverlayState::new())); + + let exc = call_chmod(&mut mt, "/mnt/hello.txt", 0o600, true) + .unwrap() + .unwrap_err() + .into_exception(); + assert_eq!(exc.exc_type(), ExcType::OSError); + assert!( + exc.message().unwrap_or("").contains("Operation not supported"), + "expected unsupported error, got {:?}", + exc.message() + ); +} + +/// `symlink_to()` should create a readable file symlink on direct mounts. +#[test] +fn rw_symlink_to_creates_file_link() { + let dir = create_test_dir(); + let mut mt = mount_at_mnt(&dir, MountMode::ReadWrite); + + call_symlink_to(&mut mt, "/mnt/new_link.txt", "/mnt/hello.txt", false) + .unwrap() + .unwrap(); + + assert_eq!( + call_ok(&mut mt, OsFunction::IsSymlink, "/mnt/new_link.txt"), + MontyObject::Bool(true) + ); + assert_eq!( + call_ok(&mut mt, OsFunction::ReadText, "/mnt/new_link.txt"), + MontyObject::String("hello world\n".to_owned()) + ); + assert_eq!( + call_readlink(&mut mt, "/mnt/new_link.txt").unwrap().unwrap(), + MontyObject::Path("/mnt/hello.txt".to_owned()) + ); +} + +/// Relative symlink targets should be stored relative and remain readable. +#[test] +fn rw_symlink_to_relative_target() { + let dir = create_test_dir(); + let mut mt = mount_at_mnt(&dir, MountMode::ReadWrite); + + call_symlink_to(&mut mt, "/mnt/new_link.txt", "hello.txt", false) + .unwrap() + .unwrap(); + + assert_eq!( + call_readlink(&mut mt, "/mnt/new_link.txt").unwrap().unwrap(), + MontyObject::Path("hello.txt".to_owned()) + ); + assert_eq!( + call_ok(&mut mt, OsFunction::ReadText, "/mnt/new_link.txt"), + MontyObject::String("hello world\n".to_owned()) + ); +} + +/// Relative `./target` symlink creation should preserve the exact raw target string. +#[test] +fn rw_symlink_to_relative_dot_target() { + let dir = create_test_dir(); + let mut mt = mount_at_mnt(&dir, MountMode::ReadWrite); + + call_symlink_to(&mut mt, "/mnt/new_link.txt", "./hello.txt", false) + .unwrap() + .unwrap(); + + assert_eq!( + call_readlink(&mut mt, "/mnt/new_link.txt").unwrap().unwrap(), + MontyObject::Path("./hello.txt".to_owned()) + ); + assert_eq!( + call_ok(&mut mt, OsFunction::ReadText, "/mnt/new_link.txt"), + MontyObject::String("hello world\n".to_owned()) + ); +} + +/// Absolute symlink targets outside the mount must be rejected. +#[test] +fn rw_symlink_to_absolute_escape_is_blocked() { + let dir = create_test_dir(); + let mut mt = mount_at_mnt(&dir, MountMode::ReadWrite); + + let exc = call_symlink_to(&mut mt, "/mnt/new_link.txt", "/outside.txt", false) + .unwrap() + .unwrap_err() + .into_exception(); + assert_eq!(exc.exc_type(), ExcType::PermissionError); + assert_eq!( + exc.message().unwrap_or(""), + "[Errno 13] Permission denied: '/mnt/new_link.txt'" + ); +} + +/// Root-mounted absolute symlink targets should convert to the mount host path. +#[test] +fn rw_symlink_to_absolute_root_target() { + let dir = create_test_dir(); + let mut mt = mount_at_root(&dir, MountMode::ReadWrite); + + call_symlink_to(&mut mt, "/root_link", "/", true).unwrap().unwrap(); + + assert_eq!( + call_readlink(&mut mt, "/root_link").unwrap().unwrap(), + MontyObject::Path("/".to_owned()) + ); +} + +/// Relative symlink targets that escape the mount must be rejected. +#[test] +fn rw_symlink_to_relative_escape_is_blocked() { + let dir = create_test_dir(); + let mut mt = mount_at_mnt(&dir, MountMode::ReadWrite); + + let exc = call_symlink_to(&mut mt, "/mnt/new_link.txt", "../outside.txt", false) + .unwrap() + .unwrap_err() + .into_exception(); + assert_eq!(exc.exc_type(), ExcType::PermissionError); + assert_eq!( + exc.message().unwrap_or(""), + "[Errno 13] Permission denied: '/mnt/new_link.txt'" + ); +} + +/// `symlink_to(..., target_is_directory=True)` should create a directory symlink. +#[test] +fn rw_symlink_to_creates_directory_link() { + let dir = create_test_dir(); + let mut mt = mount_at_mnt(&dir, MountMode::ReadWrite); + + call_symlink_to(&mut mt, "/mnt/subdir_link", "/mnt/subdir", true) + .unwrap() + .unwrap(); + + assert_eq!( + call_ok(&mut mt, OsFunction::IsSymlink, "/mnt/subdir_link"), + MontyObject::Bool(true) + ); + assert_eq!( + call_ok(&mut mt, OsFunction::IsDir, "/mnt/subdir_link"), + MontyObject::Bool(true) + ); + let names = sorted_names(&call_ok(&mut mt, OsFunction::Iterdir, "/mnt/subdir_link")); + assert_eq!(names, vec!["deep", "nested.txt"]); +} + +/// Overlay mounts currently reject symlink creation because they lack an in-memory symlink model. +#[test] +fn ovl_mem_symlink_to_is_unsupported() { + let dir = create_test_dir(); + let mut mt = mount_at_mnt(&dir, MountMode::OverlayMemory(OverlayState::new())); + + let exc = call_symlink_to(&mut mt, "/mnt/new_link.txt", "/mnt/hello.txt", false) + .unwrap() + .unwrap_err() + .into_exception(); + assert_eq!(exc.exc_type(), ExcType::OSError); + assert!( + exc.message().unwrap_or("").contains("Operation not supported"), + "expected unsupported error, got {:?}", + exc.message() + ); +} + +/// Renamed real files become `RealFileRef` overlay entries for file-like queries. +#[test] +fn ovl_mem_renamed_real_file_reports_file_metadata() { + let dir = create_test_dir(); + let mut mt = mount_at_mnt(&dir, MountMode::OverlayMemory(OverlayState::new())); + + call_rename(&mut mt, "/mnt/hello.txt", "/mnt/moved.txt") + .unwrap() + .unwrap(); + + assert_eq!( + call_ok(&mut mt, OsFunction::IsFile, "/mnt/moved.txt"), + MontyObject::Bool(true) + ); + assert_eq!( + call_ok(&mut mt, OsFunction::IsDir, "/mnt/moved.txt"), + MontyObject::Bool(false) + ); + let stat = call_ok(&mut mt, OsFunction::Stat, "/mnt/moved.txt"); + assert_eq!(stat_size(&stat), 12); +} + +/// Overlay files should answer `False` for `Path.is_dir()`. +#[test] +fn ovl_mem_overlay_file_reports_not_dir() { + let dir = create_test_dir(); + let mut mt = mount_at_mnt(&dir, MountMode::OverlayMemory(OverlayState::new())); + + call_write( + &mut mt, + OsFunction::WriteText, + "/mnt/overlay.txt", + MontyObject::String("overlay".to_owned()), + ) + .unwrap() + .unwrap(); + + assert_eq!( + call_ok(&mut mt, OsFunction::IsDir, "/mnt/overlay.txt"), + MontyObject::Bool(false) + ); +} + +/// Renamed real directories should continue to answer directory queries through `RealFileRef`. +#[test] +fn ovl_mem_renamed_real_dir_reports_directory_queries() { + let dir = create_test_dir(); + let mut mt = mount_at_mnt(&dir, MountMode::OverlayMemory(OverlayState::new())); + + call_rename(&mut mt, "/mnt/subdir", "/mnt/moved_dir").unwrap().unwrap(); + + assert_eq!( + call_ok(&mut mt, OsFunction::IsDir, "/mnt/moved_dir"), + MontyObject::Bool(true) + ); + assert_eq!( + call_ok(&mut mt, OsFunction::IsFile, "/mnt/moved_dir"), + MontyObject::Bool(false) + ); +} + +/// Renamed real symlinks should still answer symlink queries through `RealFileRef`. +#[test] +fn ovl_mem_renamed_real_symlink_reports_is_symlink() { + let dir = create_test_dir(); + symlink_file(dir.path().join("hello.txt"), dir.path().join("link.txt")); + + let mut mt = mount_at_mnt(&dir, MountMode::OverlayMemory(OverlayState::new())); + call_rename(&mut mt, "/mnt/link.txt", "/mnt/moved_link.txt") + .unwrap() + .unwrap(); + + assert_eq!( + call_ok(&mut mt, OsFunction::IsSymlink, "/mnt/moved_link.txt"), + MontyObject::Bool(true) + ); +} + +/// `readlink()` on renamed real non-symlinks should still reject invalid arguments. +#[test] +fn ovl_mem_readlink_renamed_real_file_is_invalid() { + let dir = create_test_dir(); + let mut mt = mount_at_mnt(&dir, MountMode::OverlayMemory(OverlayState::new())); + + call_rename(&mut mt, "/mnt/hello.txt", "/mnt/moved.txt") + .unwrap() + .unwrap(); + + let exc = call_readlink(&mut mt, "/mnt/moved.txt") + .unwrap() + .unwrap_err() + .into_exception(); + assert_eq!(exc.exc_type(), ExcType::OSError); + assert_eq!(exc.message().unwrap_or(""), "Invalid argument: '/mnt/moved.txt'"); +} + +/// Overlay files should reject `readlink()` just like real non-symlinks. +#[test] +fn ovl_mem_readlink_overlay_file_is_invalid() { + let dir = create_test_dir(); + let mut mt = mount_at_mnt(&dir, MountMode::OverlayMemory(OverlayState::new())); + + call_write( + &mut mt, + OsFunction::WriteText, + "/mnt/overlay.txt", + MontyObject::String("overlay".to_owned()), + ) + .unwrap() + .unwrap(); + + let exc = call_readlink(&mut mt, "/mnt/overlay.txt") + .unwrap() + .unwrap_err() + .into_exception(); + assert_eq!(exc.exc_type(), ExcType::OSError); + assert_eq!(exc.message().unwrap_or(""), "Invalid argument: '/mnt/overlay.txt'"); +} + +/// Deleted overlay entries should stay missing for `readlink()`. +#[test] +fn ovl_mem_readlink_deleted_entry_is_not_found() { + let dir = create_test_dir(); + let mut mt = mount_at_mnt(&dir, MountMode::OverlayMemory(OverlayState::new())); + + call(&mut mt, OsFunction::Unlink, "/mnt/hello.txt").unwrap().unwrap(); + + let exc = call_readlink(&mut mt, "/mnt/hello.txt") + .unwrap() + .unwrap_err() + .into_exception(); + assert_eq!(exc.exc_type(), ExcType::FileNotFoundError); + assert_eq!( + exc.message().unwrap_or(""), + "[Errno 2] No such file or directory: '/mnt/hello.txt'" + ); +} + +/// Overlay fallthrough `stat(follow_symlinks=False)` should use `lstat` semantics. +#[test] +fn ovl_mem_stat_follow_symlinks_false_reports_symlink_mode() { + let dir = create_test_dir(); + symlink_file(dir.path().join("hello.txt"), dir.path().join("link.txt")); + + let mut mt = mount_at_mnt(&dir, MountMode::OverlayMemory(OverlayState::new())); + let stat = call_stat_follow_symlinks(&mut mt, "/mnt/link.txt", false) + .unwrap() + .unwrap(); + assert_eq!(stat_mode(&stat) & 0o170_000, 0o120_000); +} + +// ============================================================================= +// Failed writes should not consume write quota (Issue #4) +// ============================================================================= + +/// A failed write (e.g. parent doesn't exist) must not burn quota. +#[test] +fn rw_failed_write_does_not_consume_quota() { + let dir = create_test_dir(); + let mut mt = mount_at_mnt_with_limit(&dir, MountMode::ReadWrite, 10); + + // Write to a nonexistent parent — this should fail. + let result = call_write( + &mut mt, + OsFunction::WriteText, + "/mnt/no_such_dir/file.txt", + MontyObject::String("12345".to_owned()), + ); + assert!(result.unwrap().is_err()); + + // Now write exactly at the limit — should succeed since the failed write + // didn't consume any quota. + call_write( + &mut mt, + OsFunction::WriteText, + "/mnt/quota_ok.txt", + MontyObject::String("0123456789".to_owned()), + ) + .unwrap() + .unwrap(); +} + +/// Same quota-preservation test for overlay mode. +#[test] +fn ovl_failed_write_does_not_consume_quota() { + let dir = create_test_dir(); + let mut mt = mount_at_mnt_with_limit(&dir, MountMode::OverlayMemory(OverlayState::new()), 10); + + // Write to a path whose parent doesn't exist — should fail. + let result = call_write( + &mut mt, + OsFunction::WriteText, + "/mnt/no_such_dir/file.txt", + MontyObject::String("12345".to_owned()), + ); + assert!(result.unwrap().is_err()); + + // Valid write of exactly 10 bytes should succeed. + call_write( + &mut mt, + OsFunction::WriteText, + "/mnt/quota_ok.txt", + MontyObject::String("0123456789".to_owned()), + ) + .unwrap() + .unwrap(); +} + +// ============================================================================= +// Overlay rename preserves access to descendants (Issue #7) +// ============================================================================= + +/// Renaming a directory in overlay mode must make all descendants accessible +/// under the new prefix. +#[test] +fn ovl_rename_directory_preserves_descendants() { + let dir = create_test_dir(); + // test_dir has subdir/nested.txt and subdir/deep/file.txt + + let mut mt = mount_at_mnt(&dir, MountMode::OverlayMemory(OverlayState::new())); + + call_rename(&mut mt, "/mnt/subdir", "/mnt/renamed_dir") + .unwrap() + .unwrap(); + + // Descendants should be accessible under the new prefix. + let result = call_ok(&mut mt, OsFunction::ReadText, "/mnt/renamed_dir/nested.txt"); + assert_eq!(result, MontyObject::String("nested content".to_owned())); + + let result = call_ok(&mut mt, OsFunction::ReadText, "/mnt/renamed_dir/deep/file.txt"); + assert_eq!(result, MontyObject::String("deep file".to_owned())); + + // Old paths should not exist. + let result = call_ok(&mut mt, OsFunction::Exists, "/mnt/subdir/nested.txt"); + assert_eq!(result, MontyObject::Bool(false)); +} + +// ============================================================================= +// Overlay rename: destination type validation +// ============================================================================= + +/// Renaming a file onto an existing directory should raise IsADirectoryError. +#[test] +fn ovl_mem_rename_file_onto_directory() { + let dir = create_test_dir(); + let mut mt = mount_at_mnt(&dir, MountMode::OverlayMemory(OverlayState::new())); + + let result = call_rename(&mut mt, "/mnt/hello.txt", "/mnt/subdir"); + let exc = result.unwrap().unwrap_err().into_exception(); + assert_eq!(exc.exc_type(), ExcType::IsADirectoryError); +} + +/// Renaming a directory onto an existing file should raise NotADirectoryError. +#[test] +fn ovl_mem_rename_directory_onto_file() { + let dir = create_test_dir(); + let mut mt = mount_at_mnt(&dir, MountMode::OverlayMemory(OverlayState::new())); + + let result = call_rename(&mut mt, "/mnt/subdir", "/mnt/hello.txt"); + let exc = result.unwrap().unwrap_err().into_exception(); + assert_eq!(exc.exc_type(), ExcType::NotADirectoryError); +} + +/// Renaming a directory into its own descendant should raise OSError. +#[test] +fn ovl_mem_rename_directory_into_own_subdir() { + let dir = create_test_dir(); + let mut mt = mount_at_mnt(&dir, MountMode::OverlayMemory(OverlayState::new())); + + let result = call_rename(&mut mt, "/mnt/subdir", "/mnt/subdir/deep/moved"); + let exc = result.unwrap().unwrap_err().into_exception(); + assert_eq!(exc.exc_type(), ExcType::OSError); + assert!( + exc.message().unwrap_or("").contains("Invalid argument"), + "expected 'Invalid argument', got: {:?}", + exc.message() + ); +} + +/// Renaming an overlay file onto an overlay directory should raise IsADirectoryError. +#[test] +fn ovl_mem_rename_overlay_file_onto_overlay_dir() { + let dir = create_test_dir(); + let mut mt = mount_at_mnt(&dir, MountMode::OverlayMemory(OverlayState::new())); + + // Create an overlay file and directory + call_write( + &mut mt, + OsFunction::WriteText, + "/mnt/src.txt", + MontyObject::String("content".to_owned()), + ) + .unwrap() + .unwrap(); + call_mkdir(&mut mt, "/mnt/dst_dir", false, false).unwrap().unwrap(); + + let result = call_rename(&mut mt, "/mnt/src.txt", "/mnt/dst_dir"); + let exc = result.unwrap().unwrap_err().into_exception(); + assert_eq!(exc.exc_type(), ExcType::IsADirectoryError); +} + +// ============================================================================= +// Overlay rename: symlink preservation +// ============================================================================= + +/// Renaming a real symlink in overlay mode should preserve its symlink identity. +#[test] +#[cfg(unix)] +fn ovl_mem_rename_symlink_preserves_symlink() { + let dir = create_test_dir(); + symlink_file(dir.path().join("hello.txt"), dir.path().join("link.txt")); + + let mut mt = mount_at_mnt(&dir, MountMode::OverlayMemory(OverlayState::new())); + + // Before rename: link should be a symlink + let result = call_ok(&mut mt, OsFunction::IsSymlink, "/mnt/link.txt"); + assert_eq!(result, MontyObject::Bool(true)); + + // Rename the symlink + call_rename(&mut mt, "/mnt/link.txt", "/mnt/moved_link.txt") + .unwrap() + .unwrap(); + + // After rename: the moved path should still be readable (via the stored host ref) + let result = call_ok(&mut mt, OsFunction::ReadText, "/mnt/moved_link.txt"); + assert_eq!(result, MontyObject::String("hello world\n".to_owned())); + + // Original symlink path should be gone + let result = call_ok(&mut mt, OsFunction::Exists, "/mnt/link.txt"); + assert_eq!(result, MontyObject::Bool(false)); + + // Original target should still exist + let result = call_ok(&mut mt, OsFunction::Exists, "/mnt/hello.txt"); + assert_eq!(result, MontyObject::Bool(true)); +} + +// ============================================================================= +// Overlay rmdir: must check overlay children on real directories +// ============================================================================= + +/// rmdir on a real directory must fail if it has overlay-only children. +#[test] +fn ovl_mem_rmdir_real_dir_with_overlay_children() { + let dir = create_test_dir(); + let mut mt = mount_at_mnt(&dir, MountMode::OverlayMemory(OverlayState::new())); + + // Delete the real child via tombstone + call(&mut mt, OsFunction::Unlink, "/mnt/subdir/nested.txt") .unwrap() .unwrap(); call(&mut mt, OsFunction::Unlink, "/mnt/subdir/deep/file.txt") @@ -2291,3 +2982,124 @@ fn take_shared_mounts_rollback_restores_all_prior() { assert!(slot1.lock().unwrap().is_some(), "slot 1 must be restored"); assert!(slot2.lock().unwrap().is_some(), "slot 2 must be restored"); } + +// ============================================================================= +// Dispatch parsing errors +// ============================================================================= + +/// Filesystem dispatch should validate argument types before reaching the backend. +#[test] +fn fs_dispatch_rejects_write_text_non_string_data() { + let dir = create_test_dir(); + let mut mt = mount_at_mnt(&dir, MountMode::ReadWrite); + + let exc = mt + .handle_os_call( + OsFunction::WriteText, + &[MontyObject::Path("/mnt/file.txt".to_owned()), MontyObject::Int(1)], + &[], + ) + .unwrap() + .unwrap_err() + .into_exception(); + assert_eq!(exc.exc_type(), ExcType::TypeError); + assert_eq!(exc.message().unwrap_or(""), "data must be str, not int"); +} + +#[test] +fn fs_dispatch_rejects_write_bytes_missing_data() { + let dir = create_test_dir(); + let mut mt = mount_at_mnt(&dir, MountMode::ReadWrite); + + let exc = mt + .handle_os_call( + OsFunction::WriteBytes, + &[MontyObject::Path("/mnt/file.bin".to_owned())], + &[], + ) + .unwrap() + .unwrap_err() + .into_exception(); + assert_eq!(exc.exc_type(), ExcType::TypeError); + assert_eq!( + exc.message().unwrap_or(""), + "Path.write_bytes() missing 1 required positional argument: 'data'" + ); +} + +#[test] +fn fs_dispatch_rejects_chmod_non_integer_mode() { + let dir = create_test_dir(); + let mut mt = mount_at_mnt(&dir, MountMode::ReadWrite); + + let exc = mt + .handle_os_call( + OsFunction::Chmod, + &[ + MontyObject::Path("/mnt/hello.txt".to_owned()), + MontyObject::String("bad".to_owned()), + ], + &[], + ) + .unwrap() + .unwrap_err() + .into_exception(); + assert_eq!(exc.exc_type(), ExcType::TypeError); + assert_eq!(exc.message().unwrap_or(""), "chmod: mode must be int, not str"); +} + +#[test] +fn fs_dispatch_rejects_chmod_missing_mode() { + let dir = create_test_dir(); + let mut mt = mount_at_mnt(&dir, MountMode::ReadWrite); + + let exc = mt + .handle_os_call( + OsFunction::Chmod, + &[MontyObject::Path("/mnt/hello.txt".to_owned())], + &[], + ) + .unwrap() + .unwrap_err() + .into_exception(); + assert_eq!(exc.exc_type(), ExcType::TypeError); + assert_eq!( + exc.message().unwrap_or(""), + "Path.chmod() missing 1 required positional argument: 'mode'" + ); +} + +#[test] +fn fs_dispatch_rejects_rename_non_path_destination() { + let dir = create_test_dir(); + let mut mt = mount_at_mnt(&dir, MountMode::ReadWrite); + + let exc = mt + .handle_os_call( + OsFunction::Rename, + &[MontyObject::Path("/mnt/hello.txt".to_owned()), MontyObject::Int(1)], + &[], + ) + .unwrap() + .unwrap_err() + .into_exception(); + assert_eq!(exc.exc_type(), ExcType::TypeError); + assert_eq!(exc.message().unwrap_or(""), "rename: expected path argument"); +} + +#[test] +fn fs_dispatch_rejects_missing_primary_path() { + let dir = create_test_dir(); + let mut mt = mount_at_mnt(&dir, MountMode::ReadWrite); + + let exc = mt + .handle_os_call(OsFunction::Exists, &[], &[]) + .unwrap() + .unwrap_err() + .into_exception(); + assert_eq!(exc.exc_type(), ExcType::TypeError); + assert_eq!( + exc.message().unwrap_or(""), + "filesystem operation missing path argument" + ); +} diff --git a/crates/monty/tests/fs_security.rs b/crates/monty/tests/fs_security.rs index 47f6a8693..320cc1f16 100644 --- a/crates/monty/tests/fs_security.rs +++ b/crates/monty/tests/fs_security.rs @@ -340,6 +340,70 @@ mod symlink_tests { assert_blocked(&mut mt, OsFunction::Iterdir, "/mnt/rel_escape"); } + #[test] + #[cfg(unix)] // Relative symlink targets are not supported on Windows + fn relative_symlink_escape_readlink() { + for (label, mode) in all_modes() { + let dir = create_test_dir(); + + // The raw target itself points outside the mount and must not be exposed. + symlink_file("../secret.txt", dir.path().join("rel_escape_file")); + + let mut mt = mount_at_mnt(&dir, mode); + assert_blocked(&mut mt, OsFunction::Readlink, "/mnt/rel_escape_file"); + eprintln!(" {label}: passed"); + } + } + + #[test] + fn absolute_symlink_escape_readlink() { + for (label, mode) in all_modes() { + let dir = create_test_dir(); + let outside = TempDir::new().unwrap(); + symlink_file(outside.path().join("secret.txt"), dir.path().join("abs_escape_file")); + + let mut mt = mount_at_mnt(&dir, mode); + assert_blocked(&mut mt, OsFunction::Readlink, "/mnt/abs_escape_file"); + eprintln!(" {label}: passed"); + } + } + + #[test] + #[cfg(unix)] // Relative symlink targets are not supported on Windows + fn relative_symlink_escape_via_intermediate_symlink_readlink() { + for (label, mode) in all_modes() { + let dir = create_test_dir(); + let outside = TempDir::new().unwrap(); + + fs::create_dir_all(outside.path().join("escape")).unwrap(); + symlink_dir(outside.path(), dir.path().join("symlink_parent")); + symlink_file( + "symlink_parent/escape/file.txt", + dir.path().join("indirect_escape_file"), + ); + + let mut mt = mount_at_mnt(&dir, mode); + assert_blocked(&mut mt, OsFunction::Readlink, "/mnt/indirect_escape_file"); + eprintln!(" {label}: passed"); + } + } + + #[test] + #[cfg(unix)] // Relative symlink targets are not supported on Windows + fn absolute_broken_symlink_missing_parent_readlink() { + for (label, mode) in all_modes() { + let dir = create_test_dir(); + symlink_file( + "/definitely-missing-parent/ghost.txt", + dir.path().join("broken_abs_file"), + ); + + let mut mt = mount_at_mnt(&dir, mode); + assert_blocked(&mut mt, OsFunction::Readlink, "/mnt/broken_abs_file"); + eprintln!(" {label}: passed"); + } + } + #[test] fn symlink_escape_no_info_leak() { // Error messages should only contain virtual path, not host path. diff --git a/crates/monty/tests/os_tests.rs b/crates/monty/tests/os_tests.rs index 8b571b28f..9d082137b 100644 --- a/crates/monty/tests/os_tests.rs +++ b/crates/monty/tests/os_tests.rs @@ -11,9 +11,9 @@ use monty::{ /// Helper to run code and extract the OsCall progress. /// /// Runs the provided Python code and asserts that it yields an `OsCall`. -/// Returns the `OsFunction` and positional arguments from the call. +/// Returns the `OsFunction`, positional arguments, and keyword arguments from the call. /// The state is resumed with a mock result to properly clean up ref counts. -fn run_to_oscall(code: &str) -> (OsFunction, Vec) { +fn run_to_oscall(code: &str) -> (OsFunction, Vec, Vec<(MontyObject, MontyObject)>) { let runner = MontyRun::new(code.to_owned(), "test.py", vec![]).unwrap(); let progress = runner.start(vec![], NoLimitTracker, PrintWriter::Stdout).unwrap(); @@ -27,15 +27,18 @@ fn run_to_oscall(code: &str) -> (OsFunction, Vec) { OsFunction::ReadText | OsFunction::Resolve | OsFunction::Absolute => { MontyObject::String("mock".to_owned()) } + OsFunction::Readlink => MontyObject::Path("mock".to_owned()), OsFunction::ReadBytes => MontyObject::Bytes(vec![]), - OsFunction::Stat => MontyObject::None, + OsFunction::Stat | OsFunction::Lstat => MontyObject::None, OsFunction::Iterdir => MontyObject::List(vec![]), OsFunction::WriteText | OsFunction::WriteBytes | OsFunction::Mkdir + | OsFunction::Chmod | OsFunction::Unlink | OsFunction::Rmdir - | OsFunction::Rename => MontyObject::None, + | OsFunction::Rename + | OsFunction::SymlinkTo => MontyObject::None, OsFunction::Getenv => MontyObject::String("mock_env_value".to_owned()), OsFunction::GetEnviron => MontyObject::Dict(vec![].into()), OsFunction::DateToday => MontyObject::Date(MontyDate { @@ -57,8 +60,9 @@ fn run_to_oscall(code: &str) -> (OsFunction, Vec) { }; let function = call.function; let args = call.args.clone(); + let kwargs = call.kwargs.clone(); let _ = call.resume(mock_result, PrintWriter::Stdout); - (function, args) + (function, args, kwargs) } _ => panic!("expected OsCall, got {progress:?}"), } @@ -87,72 +91,176 @@ fn run_oscall_with_result(code: &str, mock_result: MontyObject) -> (OsFunction, #[test] fn path_exists() { - let (func, args) = run_to_oscall("from pathlib import Path; Path('/tmp/test.txt').exists()"); + let (func, args, kwargs) = run_to_oscall("from pathlib import Path; Path('/tmp/test.txt').exists()"); assert_eq!(func, OsFunction::Exists); assert_eq!(args, vec![MontyObject::Path("/tmp/test.txt".to_owned())]); + assert!(kwargs.is_empty()); } #[test] fn path_is_file() { - let (func, args) = run_to_oscall("from pathlib import Path; Path('/tmp/test.txt').is_file()"); + let (func, args, kwargs) = run_to_oscall("from pathlib import Path; Path('/tmp/test.txt').is_file()"); assert_eq!(func, OsFunction::IsFile); assert_eq!(args, vec![MontyObject::Path("/tmp/test.txt".to_owned())]); + assert!(kwargs.is_empty()); } #[test] fn path_is_dir() { - let (func, args) = run_to_oscall("from pathlib import Path; Path('/tmp').is_dir()"); + let (func, args, kwargs) = run_to_oscall("from pathlib import Path; Path('/tmp').is_dir()"); assert_eq!(func, OsFunction::IsDir); assert_eq!(args, vec![MontyObject::Path("/tmp".to_owned())]); + assert!(kwargs.is_empty()); } #[test] fn path_is_symlink() { - let (func, args) = run_to_oscall("from pathlib import Path; Path('/tmp/link').is_symlink()"); + let (func, args, kwargs) = run_to_oscall("from pathlib import Path; Path('/tmp/link').is_symlink()"); assert_eq!(func, OsFunction::IsSymlink); assert_eq!(args, vec![MontyObject::Path("/tmp/link".to_owned())]); + assert!(kwargs.is_empty()); +} + +#[test] +fn path_readlink() { + let (func, args, kwargs) = run_to_oscall("from pathlib import Path; Path('/tmp/link').readlink()"); + assert_eq!(func, OsFunction::Readlink); + assert_eq!(args, vec![MontyObject::Path("/tmp/link".to_owned())]); + assert!(kwargs.is_empty()); } #[test] fn path_read_text() { - let (func, args) = run_to_oscall("from pathlib import Path; Path('/tmp/file.txt').read_text()"); + let (func, args, kwargs) = run_to_oscall("from pathlib import Path; Path('/tmp/file.txt').read_text()"); assert_eq!(func, OsFunction::ReadText); assert_eq!(args, vec![MontyObject::Path("/tmp/file.txt".to_owned())]); + assert!(kwargs.is_empty()); } #[test] fn path_read_bytes() { - let (func, args) = run_to_oscall("from pathlib import Path; Path('/tmp/file.bin').read_bytes()"); + let (func, args, kwargs) = run_to_oscall("from pathlib import Path; Path('/tmp/file.bin').read_bytes()"); assert_eq!(func, OsFunction::ReadBytes); assert_eq!(args, vec![MontyObject::Path("/tmp/file.bin".to_owned())]); + assert!(kwargs.is_empty()); } #[test] fn path_stat() { - let (func, args) = run_to_oscall("from pathlib import Path; Path('/tmp/file.txt').stat()"); + let (func, args, kwargs) = run_to_oscall("from pathlib import Path; Path('/tmp/file.txt').stat()"); assert_eq!(func, OsFunction::Stat); assert_eq!(args, vec![MontyObject::Path("/tmp/file.txt".to_owned())]); + assert!(kwargs.is_empty()); +} + +#[test] +fn path_stat_follow_symlinks_false() { + let (func, args, kwargs) = run_to_oscall("from pathlib import Path; Path('/tmp/link').stat(follow_symlinks=False)"); + assert_eq!(func, OsFunction::Stat); + assert_eq!(args, vec![MontyObject::Path("/tmp/link".to_owned())]); + assert_eq!( + kwargs, + vec![( + MontyObject::String("follow_symlinks".to_owned()), + MontyObject::Bool(false) + )] + ); +} + +#[test] +fn path_lstat() { + let (func, args, kwargs) = run_to_oscall("from pathlib import Path; Path('/tmp/link').lstat()"); + assert_eq!(func, OsFunction::Lstat); + assert_eq!(args, vec![MontyObject::Path("/tmp/link".to_owned())]); + assert!(kwargs.is_empty()); +} + +#[test] +fn path_chmod() { + let (func, args, kwargs) = run_to_oscall("from pathlib import Path; Path('/tmp/file.txt').chmod(0o600)"); + assert_eq!(func, OsFunction::Chmod); + assert_eq!( + args, + vec![MontyObject::Path("/tmp/file.txt".to_owned()), MontyObject::Int(0o600)] + ); + assert!(kwargs.is_empty()); +} + +#[test] +fn path_chmod_follow_symlinks_false() { + let (func, args, kwargs) = + run_to_oscall("from pathlib import Path; Path('/tmp/link').chmod(0o600, follow_symlinks=False)"); + assert_eq!(func, OsFunction::Chmod); + assert_eq!( + args, + vec![MontyObject::Path("/tmp/link".to_owned()), MontyObject::Int(0o600)] + ); + assert_eq!( + kwargs, + vec![( + MontyObject::String("follow_symlinks".to_owned()), + MontyObject::Bool(false) + )] + ); +} + +#[test] +fn path_symlink_to() { + let (func, args, kwargs) = run_to_oscall("from pathlib import Path; Path('/tmp/link').symlink_to('/tmp/target')"); + assert_eq!(func, OsFunction::SymlinkTo); + assert_eq!( + args, + vec![ + MontyObject::Path("/tmp/link".to_owned()), + MontyObject::String("/tmp/target".to_owned()), + ] + ); + assert!(kwargs.is_empty()); +} + +#[test] +fn path_symlink_to_target_is_directory() { + let (func, args, kwargs) = + run_to_oscall("from pathlib import Path; Path('/tmp/link').symlink_to('/tmp/dir', target_is_directory=True)"); + assert_eq!(func, OsFunction::SymlinkTo); + assert_eq!( + args, + vec![ + MontyObject::Path("/tmp/link".to_owned()), + MontyObject::String("/tmp/dir".to_owned()), + ] + ); + assert_eq!( + kwargs, + vec![( + MontyObject::String("target_is_directory".to_owned()), + MontyObject::Bool(true) + )] + ); } #[test] fn path_iterdir() { - let (func, args) = run_to_oscall("from pathlib import Path; Path('/tmp').iterdir()"); + let (func, args, kwargs) = run_to_oscall("from pathlib import Path; Path('/tmp').iterdir()"); assert_eq!(func, OsFunction::Iterdir); assert_eq!(args, vec![MontyObject::Path("/tmp".to_owned())]); + assert!(kwargs.is_empty()); } #[test] fn path_resolve() { - let (func, args) = run_to_oscall("from pathlib import Path; Path('./relative').resolve()"); + let (func, args, kwargs) = run_to_oscall("from pathlib import Path; Path('./relative').resolve()"); assert_eq!(func, OsFunction::Resolve); assert_eq!(args, vec![MontyObject::Path("relative".to_owned())]); + assert!(kwargs.is_empty()); } #[test] fn path_absolute() { - let (func, args) = run_to_oscall("from pathlib import Path; Path('./relative').absolute()"); + let (func, args, kwargs) = run_to_oscall("from pathlib import Path; Path('./relative').absolute()"); assert_eq!(func, OsFunction::Absolute); assert_eq!(args, vec![MontyObject::Path("relative".to_owned())]); + assert!(kwargs.is_empty()); } // ============================================================================= @@ -161,21 +269,21 @@ fn path_absolute() { #[test] fn path_with_spaces() { - let (func, args) = run_to_oscall("from pathlib import Path; Path('/path/with spaces/file.txt').exists()"); + let (func, args, _) = run_to_oscall("from pathlib import Path; Path('/path/with spaces/file.txt').exists()"); assert_eq!(func, OsFunction::Exists); assert_eq!(args[0], MontyObject::Path("/path/with spaces/file.txt".to_owned())); } #[test] fn path_with_unicode() { - let (func, args) = run_to_oscall("from pathlib import Path; Path('/путь/文件.txt').exists()"); + let (func, args, _) = run_to_oscall("from pathlib import Path; Path('/путь/文件.txt').exists()"); assert_eq!(func, OsFunction::Exists); assert_eq!(args[0], MontyObject::Path("/путь/文件.txt".to_owned())); } #[test] fn path_concatenation_yields_correct_path() { - let (func, args) = run_to_oscall( + let (func, args, _) = run_to_oscall( r" from pathlib import Path base = Path('/home') @@ -339,7 +447,7 @@ fn os_getenv_yields_oscall() { import os os.getenv('PATH') "; - let (func, args) = run_to_oscall(code); + let (func, args, _) = run_to_oscall(code); assert_eq!(func, OsFunction::Getenv); // First arg is key, second is default (None if not provided) assert_eq!(args[0], MontyObject::String("PATH".to_owned())); @@ -352,7 +460,7 @@ fn os_getenv_with_default() { import os os.getenv('MISSING', 'fallback') "; - let (func, args) = run_to_oscall(code); + let (func, args, _) = run_to_oscall(code); assert_eq!(func, OsFunction::Getenv); assert_eq!(args[0], MontyObject::String("MISSING".to_owned())); assert_eq!(args[1], MontyObject::String("fallback".to_owned())); @@ -379,7 +487,7 @@ fn os_environ_yields_oscall() { import os os.environ "; - let (func, args) = run_to_oscall(code); + let (func, args, _) = run_to_oscall(code); assert_eq!(func, OsFunction::GetEnviron); // GetEnviron takes no arguments assert!(args.is_empty(), "expected empty args, got {args:?}"); diff --git a/scripts/iter_test_methods.py b/scripts/iter_test_methods.py index c9b46a488..6f79742ed 100644 --- a/scripts/iter_test_methods.py +++ b/scripts/iter_test_methods.py @@ -264,6 +264,12 @@ def stat( # pyright: ignore[reportIncompatibleMethodOverride] raise FileNotFoundError(2, 'No such file or directory', path_str) return super().stat(follow_symlinks=follow_symlinks) + def lstat(self) -> VirtualStatResult | os.stat_result: # pyright: ignore[reportIncompatibleMethodOverride] + path_str = str(self) + if is_virtual_path(path_str): + return self.stat(follow_symlinks=False) + return super().lstat() + def iterdir(self): # pyright: ignore[reportUnknownParameterType] path_str = str(self) if is_virtual_path(path_str): @@ -313,6 +319,15 @@ def write_bytes(self, data: bytes) -> int: # pyright: ignore[reportIncompatible return len(data) return super().write_bytes(data) + def chmod(self, mode: int, *, follow_symlinks: bool = True) -> None: + path_str = str(self) + if is_virtual_path(path_str): + if path_str in VIRTUAL_FILES: + content, _ = VIRTUAL_FILES[path_str] + VIRTUAL_FILES[path_str] = (content, mode) + return + super().chmod(mode, follow_symlinks=follow_symlinks) + def mkdir(self, mode: int = 0o777, parents: bool = False, exist_ok: bool = False) -> None: path_str = str(self) if is_virtual_path(path_str): @@ -383,6 +398,22 @@ def rename(self, target: 'VirtualPath | str') -> 'VirtualPath': # pyright: igno raise FileNotFoundError(2, 'No such file or directory', path_str) return VirtualPath(super().rename(target)) + def readlink(self) -> 'VirtualPath': + path_str = str(self) + if is_virtual_path(path_str): + raise OSError(22, 'Invalid argument', path_str) + return VirtualPath(super().readlink()) + + def symlink_to( # pyright: ignore[reportIncompatibleMethodOverride] + self, + target: 'VirtualPath | str', + target_is_directory: bool = False, + ) -> None: + path_str = str(self) + if is_virtual_path(path_str): + raise OSError(f"Path.symlink_to() is not supported in iter mode: '{path_str}'") + super().symlink_to(target, target_is_directory=target_is_directory) + # __truediv__ is NOT overridden - the parent class already uses type(self) # to create new paths, which will be VirtualPath instances