monty 0.0.19-beta.2

A sandboxed, snapshotable Python interpreter written in Rust.
Documentation
//! Shared filesystem helpers used by both direct and overlay backends.
//!
//! These helpers keep low-level host filesystem behavior in one place so the
//! backend modules can focus on mount semantics rather than repeating the same
//! byte decoding, stat conversion, and quota bookkeeping logic.

use std::{
    fs::{self, OpenOptions},
    io::{ErrorKind, Write},
    path::Path,
    time::SystemTime,
};

use super::error::MountError;
use crate::{MontyObject, UnicodeErrorData, codecs, dir_stat, file_stat};

/// Per-call mount context shared by the filesystem backends.
///
/// The context carries immutable mount identity and mutable write accounting so
/// the backends do not need long parameter lists or ad hoc state threading.
pub(super) struct MountContext<'a> {
    /// Virtual mount prefix such as `"/mnt/data"`.
    pub mount_virtual: &'a str,
    /// Canonical host directory that backs the mount.
    pub mount_host: &'a Path,
    /// Cumulative bytes written through this mount.
    pub write_bytes_used: &'a mut u64,
    /// Optional cumulative write cap for the mount.
    pub write_bytes_limit: Option<u64>,
}

/// Reads a file as UTF-8 text, preserving `UnicodeDecodeError` semantics.
///
/// On Windows, `fs::read()` on a directory returns `PermissionDenied` instead of
/// `IsADirectory`, so we check explicitly before reading.
pub(super) fn read_text_fs(path: &Path, vpath: &str) -> Result<MontyObject, MountError> {
    reject_directory(path, vpath)?;
    let bytes = fs::read(path).map_err(|err| MountError::Io(err, vpath.to_owned()))?;
    let content = bytes_to_utf8(bytes)?;
    Ok(MontyObject::String(content))
}

/// Reads a file as raw bytes.
///
/// On Windows, `fs::read()` on a directory returns `PermissionDenied` instead of
/// `IsADirectory`, so we check explicitly before reading.
pub(super) fn read_bytes_fs(path: &Path, vpath: &str) -> Result<MontyObject, MountError> {
    reject_directory(path, vpath)?;
    let content = fs::read(path).map_err(|err| MountError::Io(err, vpath.to_owned()))?;
    Ok(MontyObject::Bytes(content))
}

/// Writes text to a file and returns the number of characters written.
///
/// On Windows, `fs::write()` on a directory returns `PermissionDenied` instead of
/// `IsADirectory`, so we check explicitly before writing.
pub(super) fn write_text_fs(path: &Path, content: &str, vpath: &str) -> Result<MontyObject, MountError> {
    reject_directory(path, vpath)?;
    fs::write(path, content).map_err(|err| MountError::Io(err, vpath.to_owned()))?;
    Ok(MontyObject::Int(
        i64::try_from(content.chars().count()).unwrap_or(i64::MAX),
    ))
}

/// Writes bytes to a file and returns the number of bytes written.
///
/// On Windows, `fs::write()` on a directory returns `PermissionDenied` instead of
/// `IsADirectory`, so we check explicitly before writing.
pub(super) fn write_bytes_fs(path: &Path, content: &[u8], vpath: &str) -> Result<MontyObject, MountError> {
    reject_directory(path, vpath)?;
    fs::write(path, content).map_err(|err| MountError::Io(err, vpath.to_owned()))?;
    Ok(MontyObject::Int(i64::try_from(content.len()).unwrap_or(i64::MAX)))
}

/// Appends text to a file and returns the number of characters written.
///
/// The host file is opened only for the duration of this call, preserving the
/// sandbox invariant that Monty never keeps native file handles alive.
pub(super) fn append_text_fs(path: &Path, content: &str, vpath: &str) -> Result<MontyObject, MountError> {
    append_bytes_to_file(path, content.as_bytes(), vpath)?;
    Ok(MontyObject::Int(
        i64::try_from(content.chars().count()).unwrap_or(i64::MAX),
    ))
}

/// Appends bytes to a file and returns the number of bytes written.
///
/// This is the binary counterpart of [`append_text_fs`].
pub(super) fn append_bytes_fs(path: &Path, content: &[u8], vpath: &str) -> Result<MontyObject, MountError> {
    append_bytes_to_file(path, content, vpath)?;
    Ok(MontyObject::Int(i64::try_from(content.len()).unwrap_or(i64::MAX)))
}

/// Opens `path` in append mode, writes all bytes, and closes it before returning.
fn append_bytes_to_file(path: &Path, content: &[u8], vpath: &str) -> Result<(), MountError> {
    reject_directory(path, vpath)?;
    let mut file = OpenOptions::new()
        .create(true)
        .append(true)
        .open(path)
        .map_err(|err| MountError::Io(err, vpath.to_owned()))?;
    file.write_all(content)
        .map_err(|err| MountError::Io(err, vpath.to_owned()))
}

/// Creates a directory, matching CPython `pathlib.Path.mkdir()` semantics:
///
/// - `exist_ok=False`: always raises `FileExistsError` if the path already exists
///   (whether file or directory), even with `parents=True`.
/// - `exist_ok=True`: silently succeeds only if the path is an existing **directory**.
///   If the path is an existing **file**, raises `FileExistsError` regardless.
pub(super) fn mkdir_fs(path: &Path, parents: bool, exist_ok: bool, vpath: &str) -> Result<MontyObject, MountError> {
    let result = if parents {
        // `create_dir_all` silently returns `Ok(())` when the directory already exists,
        // so we must check for pre-existing paths ourselves.
        match path.symlink_metadata() {
            Ok(meta) if meta.is_dir() => {
                return if exist_ok {
                    Ok(MontyObject::None)
                } else {
                    Err(MountError::io_err(ErrorKind::AlreadyExists, "File exists", vpath))
                };
            }
            Ok(_) => {
                // Path exists but is a file — always an error.
                return Err(MountError::io_err(ErrorKind::AlreadyExists, "File exists", vpath));
            }
            Err(_) => {} // Path doesn't exist, proceed with creation.
        }
        fs::create_dir_all(path)
    } else {
        fs::create_dir(path)
    };

    match result {
        Ok(()) => Ok(MontyObject::None),
        Err(err) if err.kind() == ErrorKind::AlreadyExists && exist_ok && path.is_dir() => Ok(MontyObject::None),
        Err(err) => Err(MountError::Io(err, vpath.to_owned())),
    }
}

/// Removes a file.
pub(super) fn unlink_fs(path: &Path, vpath: &str) -> Result<MontyObject, MountError> {
    fs::remove_file(path).map_err(|err| MountError::Io(err, vpath.to_owned()))?;
    Ok(MontyObject::None)
}

/// Removes an empty directory.
pub(super) fn rmdir_fs(path: &Path, vpath: &str) -> Result<MontyObject, MountError> {
    fs::remove_dir(path).map_err(|err| MountError::Io(err, vpath.to_owned()))?;
    Ok(MontyObject::None)
}

/// Returns a `stat_result`-shaped object for a file or directory.
pub(super) fn stat_fs(path: &Path, vpath: &str) -> Result<MontyObject, MountError> {
    let metadata = fs::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);

    if metadata.is_dir() {
        Ok(dir_stat(0o755, mtime))
    } else {
        Ok(file_stat(0o644, size, mtime))
    }
}

/// Lists visible directory entries from the real filesystem.
pub(super) fn iterdir_fs(host_path: &Path, vpath: &str, mount_host_path: &Path) -> Result<MontyObject, MountError> {
    let mut result = Vec::new();
    for name in list_visible_real_dir_entry_names(host_path, mount_host_path, vpath)? {
        result.push(MontyObject::Path(format_child_path(vpath, &name)));
    }
    Ok(MontyObject::List(result))
}

/// Validates that writing `bytes` would not exceed the mount's quota.
pub(super) fn check_write_limit(bytes: usize, ctx: &MountContext<'_>) -> Result<(), MountError> {
    if let Some(limit) = ctx.write_bytes_limit {
        let bytes = u64::try_from(bytes).unwrap_or(u64::MAX);
        if (*ctx.write_bytes_used).saturating_add(bytes) > limit {
            return Err(MountError::WriteLimitExceeded(limit));
        }
    }
    Ok(())
}

/// Records a successful write against the mount's cumulative quota counter.
pub(super) fn commit_write_bytes(bytes: usize, ctx: &mut MountContext<'_>) {
    if ctx.write_bytes_limit.is_some() {
        *ctx.write_bytes_used = (*ctx.write_bytes_used).saturating_add(u64::try_from(bytes).unwrap_or(u64::MAX));
    }
}

/// Returns visible real directory entry names for `iterdir()`.
///
/// Symlinks are only exposed when their canonical target remains within the
/// mount boundary so directory iteration does not leak the existence of
/// outbound or broken links.
pub(super) fn list_visible_real_dir_entry_names(
    host_path: &Path,
    mount_host_path: &Path,
    vpath: &str,
) -> Result<Vec<String>, MountError> {
    let read_dir = fs::read_dir(host_path).map_err(|err| MountError::Io(err, vpath.to_owned()))?;
    let mut names = Vec::new();

    for entry in read_dir {
        let entry = entry.map_err(|err| MountError::Io(err, vpath.to_owned()))?;
        let file_type = entry.file_type().map_err(|err| MountError::Io(err, vpath.to_owned()))?;

        if file_type.is_symlink() {
            match fs::canonicalize(entry.path()) {
                Ok(canonical) if !canonical.starts_with(mount_host_path) => continue,
                Err(_) => continue,
                _ => {}
            }
        }

        names.push(entry.file_name().to_string_lossy().to_string());
    }

    Ok(names)
}

/// Converts raw bytes to UTF-8 or returns the exact decode failure details
/// (byte range, first bad byte, and CPython's reason wording) so the
/// resulting `UnicodeDecodeError` matches `bytes.decode('utf-8')`.
pub(super) fn bytes_to_utf8(bytes: Vec<u8>) -> Result<String, MountError> {
    String::from_utf8(bytes).map_err(|err| {
        let utf8_error = err.utf8_error();
        let start = utf8_error.valid_up_to();
        let end = utf8_error.error_len().map_or(err.as_bytes().len(), |len| start + len);
        let reason = codecs::utf8_error_reason(err.as_bytes()[start], utf8_error.error_len());
        MountError::InvalidUtf8 {
            start,
            end,
            first_byte: err.as_bytes()[start],
            reason,
            data: UnicodeErrorData::decode("utf-8", err.as_bytes(), start, end, reason),
        }
    })
}

/// Returns the current Unix timestamp as seconds since the epoch.
pub(super) fn current_timestamp() -> f64 {
    SystemTime::now()
        .duration_since(SystemTime::UNIX_EPOCH)
        .map_or(0.0, |duration| duration.as_secs_f64())
}

/// Reads a directory modification time, falling back to `now` if needed.
pub(super) fn dir_mtime(path: &Path) -> f64 {
    fs::metadata(path)
        .and_then(|metadata| metadata.modified())
        .unwrap_or_else(|_| SystemTime::now())
        .duration_since(SystemTime::UNIX_EPOCH)
        .map_or(0.0, |duration| duration.as_secs_f64())
}

/// Returns an `IsADirectory` error if `path` is a directory.
///
/// On Windows, many `std::fs` operations on directories return
/// `ErrorKind::PermissionDenied` instead of `ErrorKind::IsADirectory`.
/// This helper normalises the behaviour across platforms so callers get
/// the correct Python exception regardless of host OS.
pub(super) fn reject_directory(path: &Path, vpath: &str) -> Result<(), MountError> {
    if path.is_dir() {
        return Err(MountError::io_err(ErrorKind::IsADirectory, "Is a directory", vpath));
    }
    Ok(())
}

/// Formats a child virtual path without introducing duplicate separators.
pub(super) fn format_child_path(parent: &str, child: &str) -> String {
    if parent.ends_with('/') {
        format!("{parent}{child}")
    } else {
        format!("{parent}/{child}")
    }
}