diff --git a/docs/ABI-FFI-BOUNDARY.adoc b/docs/ABI-FFI-BOUNDARY.adoc index b7639dec..92dec2b7 100644 --- a/docs/ABI-FFI-BOUNDARY.adoc +++ b/docs/ABI-FFI-BOUNDARY.adoc @@ -69,8 +69,8 @@ languages link against. The C-ABI-_provider_ role belongs to (11 exports, wired to the Idris2 ABI declarations). `+impl/zig/+` is an experimental fast-path lib plus a Lean-runtime wrapper, gated on the not-yet-built BEAM daemon. Collapsing the two is future work. -* *The primary Rust CLI (`+impl/rust-cli/+`) does not link the FFI.* Its -`+build.rs+` is intentionally a no-op; `+vsh+` is a self-contained +* *The primary Rust CLI (`+impl/rust-cli/+`) does not link the FFI.* It has +no `+build.rs+` and no link flags; `+vsh+` is a self-contained pure-Rust shell today. Wiring it to the Zig FFI is future work. === Deferred (code-level consolidation — needs owner sign-off) @@ -80,8 +80,9 @@ the shipping shell: [arabic] . Port `+ffi/rust+` to Zig, or fold it behind the Zig FFI. -. Wire `+impl/rust-cli+` to the Zig FFI (replace the no-op -`+build.rs+`). +. Wire `+impl/rust-cli+` to the Zig FFI (would need a `+build.rs+` +and link flags; the former no-op `+build.rs+` and broken +`+.cargo/config.toml+` link path were removed). . Collapse `+impl/zig+` into `+ffi/zig+`. Until then, the spine above is the documented contract, and the three diff --git a/impl/rust-cli/.cargo/config.toml b/impl/rust-cli/.cargo/config.toml deleted file mode 100644 index 6791b166..00000000 --- a/impl/rust-cli/.cargo/config.toml +++ /dev/null @@ -1,8 +0,0 @@ -# Cargo configuration for Lean FFI linking - -[target.x86_64-unknown-linux-gnu] -# Link paths for Lean runtime verification (when lean-runtime-checks feature is enabled) -rustflags = [ - "-L", "/var$REPOS_DIR/valence-shell/impl/zig/zig-out/lib", - "-C", "link-arg=-Wl,-rpath,/var$REPOS_DIR/valence-shell/impl/zig/zig-out/lib", -] diff --git a/impl/rust-cli/build.rs b/impl/rust-cli/build.rs deleted file mode 100644 index 1c801474..00000000 --- a/impl/rust-cli/build.rs +++ /dev/null @@ -1,12 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 -// Copyright (c) Jonathan D.A. Jewell -//! Build script for Valence Shell -//! -//! Currently a no-op. Will be used when mechanized -//! Lean -> Rust extraction pipeline is implemented. - -fn main() { - // No build-time linking needed currently. - // When Lean runtime verification is implemented, - // this will link to the extracted verification library. -} diff --git a/impl/rust-cli/src/commands.rs b/impl/rust-cli/src/commands.rs index 076a50eb..95342b58 100644 --- a/impl/rust-cli/src/commands.rs +++ b/impl/rust-cli/src/commands.rs @@ -78,8 +78,7 @@ pub enum CommandError { pub fn mkdir(state: &mut ShellState, path: &str, verbose: bool) -> Result<()> { let full_path = state.resolve_path(path); - // Optional Lean 4 verification (compile-time feature flag) - // Provides mathematical guarantee that preconditions are satisfied + // Runtime check of the Lean 4 precondition (see verification.rs) verification::verify_mkdir(state.root(), path)?; // Check preconditions (matching Coq) @@ -146,7 +145,7 @@ pub fn mkdir(state: &mut ShellState, path: &str, verbose: bool) -> Result<()> { pub fn rmdir(state: &mut ShellState, path: &str, verbose: bool) -> Result<()> { let full_path = state.resolve_path(path); - // Optional Lean 4 verification + // Runtime check of the Lean 4 precondition (see verification.rs) verification::verify_rmdir(state.root(), path)?; // Check preconditions @@ -210,7 +209,7 @@ pub fn rmdir(state: &mut ShellState, path: &str, verbose: bool) -> Result<()> { pub fn touch(state: &mut ShellState, path: &str, verbose: bool) -> Result<()> { let full_path = state.resolve_path(path); - // Optional Lean 4 verification + // Runtime check of the Lean 4 precondition (see verification.rs) verification::verify_create_file(state.root(), path)?; if full_path.exists() { @@ -273,7 +272,7 @@ pub fn touch(state: &mut ShellState, path: &str, verbose: bool) -> Result<()> { pub fn rm(state: &mut ShellState, path: &str, verbose: bool) -> Result<()> { let full_path = state.resolve_path(path); - // Optional Lean 4 verification + // Runtime check of the Lean 4 precondition (see verification.rs) verification::verify_delete_file(state.root(), path)?; if !full_path.exists() { @@ -341,7 +340,7 @@ pub fn cp(state: &mut ShellState, src: &str, dst: &str, verbose: bool) -> Result let src_path = state.resolve_path(src); let dst_path = state.resolve_path(dst); - // Optional verification + // Runtime check of the Lean 4 precondition (see verification.rs) verification::verify_copy_file(state.root(), src, dst)?; // Check preconditions (matching Lean 4 copyFilePrecondition) @@ -418,7 +417,7 @@ pub fn mv(state: &mut ShellState, src: &str, dst: &str, verbose: bool) -> Result let src_path = state.resolve_path(src); let dst_path = state.resolve_path(dst); - // Optional verification + // Runtime check of the Lean 4 precondition (see verification.rs) verification::verify_move(state.root(), src, dst)?; // Check preconditions (matching Lean 4 movePrecondition) @@ -496,7 +495,7 @@ pub fn mv(state: &mut ShellState, src: &str, dst: &str, verbose: bool) -> Result pub fn symlink(state: &mut ShellState, target: &str, link: &str, verbose: bool) -> Result<()> { let link_path = state.resolve_path(link); - // Optional verification + // Runtime check of the Lean 4 precondition (see verification.rs) verification::verify_symlink(state.root(), target, link)?; // Check preconditions (matching Lean 4 SymlinkPrecondition) diff --git a/impl/rust-cli/src/state.rs b/impl/rust-cli/src/state.rs index e28b554f..7f75389b 100644 --- a/impl/rust-cli/src/state.rs +++ b/impl/rust-cli/src/state.rs @@ -11,7 +11,7 @@ use colored::Colorize; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use uuid::Uuid; use crate::functions::FunctionTable; @@ -561,38 +561,7 @@ impl ShellState { /// Resolve a path relative to sandbox root /// Prevents path traversal attacks via `..` components pub fn resolve_path(&self, path: &str) -> PathBuf { - let raw = if let Some(stripped) = path.strip_prefix('/') { - self.root.join(stripped) - } else { - self.root.join(path) - }; - - // Normalize path components to prevent traversal via .. - let mut normalized = PathBuf::new(); - for component in raw.components() { - match component { - std::path::Component::ParentDir => { - // Only pop if we're still within the sandbox root - if normalized.starts_with(&self.root) && normalized != self.root { - normalized.pop(); - } - // If popping would escape root, silently clamp to root - } - std::path::Component::CurDir => { - // Skip . components - } - other => { - normalized.push(other); - } - } - } - - // Final safety check: ensure result is within sandbox - if !normalized.starts_with(&self.root) { - self.root.clone() - } else { - normalized - } + resolve_under_root(&self.root, path) } /// Get root path as string (for Lean FFI) @@ -949,6 +918,47 @@ struct SerializableState { previous_dir: Option, } +/// Resolve `path` against the sandbox `root`, clamping any `..` traversal so +/// the result never escapes `root`. +/// +/// A leading `/` is treated as relative to `root`; `.` components are dropped. +/// This is the single path-resolution rule shared by [`ShellState::resolve_path`] +/// and the precondition checks in [`crate::verification`]. +pub fn resolve_under_root(root: &Path, path: &str) -> PathBuf { + let raw = if let Some(stripped) = path.strip_prefix('/') { + root.join(stripped) + } else { + root.join(path) + }; + + // Normalize path components to prevent traversal via .. + let mut normalized = PathBuf::new(); + for component in raw.components() { + match component { + std::path::Component::ParentDir => { + // Only pop if we're still within the sandbox root + if normalized.starts_with(root) && normalized != root { + normalized.pop(); + } + // If popping would escape root, silently clamp to root + } + std::path::Component::CurDir => { + // Skip . components + } + other => { + normalized.push(other); + } + } + } + + // Final safety check: ensure result is within sandbox + if !normalized.starts_with(root) { + root.to_path_buf() + } else { + normalized + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/impl/rust-cli/src/verification.rs b/impl/rust-cli/src/verification.rs index c8d14ce1..0148fd07 100644 --- a/impl/rust-cli/src/verification.rs +++ b/impl/rust-cli/src/verification.rs @@ -1,48 +1,713 @@ // SPDX-License-Identifier: MPL-2.0 // Copyright (c) Jonathan D.A. Jewell -//! Verification Stubs +//! Runtime precondition checks mirroring the Lean 4 model. //! -//! Placeholder for future Lean 4 runtime verification layer. -//! Currently returns Ok(()) for all operations — verification is -//! done via the 28 correspondence tests, not at runtime. +//! Each `verify_*` function checks, against the real filesystem under the +//! sandbox root, the precondition structure that the corresponding Lean 4 +//! operation is proved under (see `proofs/lean4/`). Commands call these +//! before mutating anything, so an operation only runs in a state where the +//! Lean reversibility theorem's hypotheses hold. //! -//! When a mechanized Lean -> Rust extraction pipeline exists, -//! this module will call verified precondition checks before -//! executing POSIX operations. +//! These are hand-written mirrors of the Lean definitions, not code +//! extracted from the proofs: correspondence is by review and by the unit +//! tests below, not by construction. +//! +//! # Mapping from the Lean model to the real filesystem +//! +//! * `pathExists p` — a node exists at `p` *without following a final +//! symlink* (`lstat`). The Lean model represents a symlink as a file node +//! (`SymlinkOperations.lean`), so a dangling symlink still "exists". +//! * `isDirectory p` / `isFile p` — the `lstat` file type, except where noted +//! per function (copy and move follow a symlinked source, as the command +//! does). A symlink counts as a file node. +//! * `parentExists p` / `isDirectory (parentPath p)` — the parent is resolved +//! normally (following symlinks), as the kernel does for path traversal. +//! * `hasWritePermission` / `hasReadPermission` — `access(2)` with `W_OK` / +//! `R_OK` for the current process. As root these always succeed, as the +//! kernel's own checks would. +//! +//! # Error messages +//! +//! Commands in `commands.rs` keep their own inline checks and their +//! user-visible error text. To preserve that text, every check a command +//! already makes is performed here first, in the command's order and with +//! the command's exact message; the Lean-only conditions follow. -use anyhow::Result; +use anyhow::{Context, Result}; +use std::fs; +use std::path::{Path, PathBuf}; -/// Verify mkdir preconditions (stub — always succeeds) -pub fn verify_mkdir(_root: &str, _path: &str) -> Result<()> { - Ok(()) +use crate::state::resolve_under_root; + +/// Resolve `path` against `root` with the same rule the commands use. +fn resolve(root: &str, path: &str) -> PathBuf { + resolve_under_root(Path::new(root), path) +} + +/// Lean `pathExists`: a node is present at `p`, without following a final +/// symlink. +fn node_exists(p: &Path) -> bool { + fs::symlink_metadata(p).is_ok() +} + +/// Lean `isDirectory` on the node itself (`lstat`): true only for a real +/// directory, not a symlink to one. +fn node_is_dir(p: &Path) -> bool { + fs::symlink_metadata(p) + .map(|m| m.file_type().is_dir()) + .unwrap_or(false) } -/// Verify rmdir preconditions (stub — always succeeds) -pub fn verify_rmdir(_root: &str, _path: &str) -> Result<()> { +/// Lean `isFile` on the node itself (`lstat`): a regular file or a symlink +/// (the Lean model represents a symlink as a file node). FIFOs, sockets and +/// devices are not file nodes in the model. +fn node_is_file(p: &Path) -> bool { + fs::symlink_metadata(p) + .map(|m| m.file_type().is_file() || m.file_type().is_symlink()) + .unwrap_or(false) +} + +/// Whether the current process may access `p` with `mode` (`libc::R_OK` or +/// `libc::W_OK`), as decided by `access(2)`. +#[cfg(unix)] +fn accessible(p: &Path, mode: libc::c_int) -> bool { + use std::os::unix::ffi::OsStrExt; + let Ok(c_path) = std::ffi::CString::new(p.as_os_str().as_bytes()) else { + return false; + }; + // SAFETY: `c_path` is a valid NUL-terminated C string that outlives the call. + unsafe { libc::access(c_path.as_ptr(), mode) == 0 } +} + +/// Lean `hasWritePermission`: the current process may write to `p`. +fn writable(p: &Path) -> bool { + #[cfg(unix)] + { + accessible(p, libc::W_OK) + } + #[cfg(not(unix))] + { + fs::metadata(p) + .map(|m| !m.permissions().readonly()) + .unwrap_or(false) + } +} + +/// Lean `hasReadPermission`: the current process may read `p`. +fn readable(p: &Path) -> bool { + #[cfg(unix)] + { + accessible(p, libc::R_OK) + } + #[cfg(not(unix))] + { + fs::metadata(p).is_ok() + } +} + +/// Check the conditions shared by every "create a new node at `p`" +/// precondition (`notExists`, `parentExists`, `parentIsDir`, +/// `parentWritable`), using the mkdir/touch error messages. +fn verify_new_node(full: &Path) -> Result<()> { + if node_exists(full) { + anyhow::bail!("Path already exists (EEXIST)"); + } + let parent = full.parent().context("Invalid path")?; + if !parent.exists() { + anyhow::bail!("Parent directory does not exist (ENOENT)"); + } + if !parent.is_dir() { + anyhow::bail!("Parent is not a directory (ENOTDIR)"); + } + if !writable(parent) { + anyhow::bail!("Parent directory is not writable (EACCES)"); + } Ok(()) } -/// Verify file creation preconditions (stub — always succeeds) -pub fn verify_create_file(_root: &str, _path: &str) -> Result<()> { +/// Check Lean `MkdirPrecondition` (`proofs/lean4/FilesystemModel.lean`): +/// `notExists`, `parentExists`, `parentIsDir`, `parentWritable`. +/// +/// `path` is resolved against `root` exactly as the `mkdir` command does. +pub fn verify_mkdir(root: &str, path: &str) -> Result<()> { + verify_new_node(&resolve(root, path)) +} + +/// Check Lean `RmdirPrecondition` (`proofs/lean4/FilesystemModel.lean`): +/// `isDir`, `isEmpty`, `parentWritable`, `notRoot`. +/// +/// `notRoot` is mapped to "the resolved path is not the sandbox root", since +/// `/` and any `..` that would escape both resolve to the root. +/// +/// `isEmpty` is implemented as "the directory has no entries". The Lean +/// `isEmptyDir` (FilesystemModel.lean) quantifies over `child.isPrefixOf p`, +/// i.e. over the *ancestors* of `p`, not its descendants; read literally it +/// forbids the parent from existing, which contradicts `parentWritable`, so +/// the structure as written is unsatisfiable for any non-root path. This +/// check follows the evident intent instead. +pub fn verify_rmdir(root: &str, path: &str) -> Result<()> { + let full = resolve(root, path); + if !node_exists(&full) { + anyhow::bail!("Path does not exist (ENOENT)"); + } + if !node_is_dir(&full) { + anyhow::bail!("Path is not a directory (ENOTDIR)"); + } + let mut entries = fs::read_dir(&full)?; + if entries.next().is_some() { + anyhow::bail!("Directory is not empty (ENOTEMPTY)"); + } + if full == Path::new(root) { + anyhow::bail!("Cannot remove the sandbox root"); + } + let parent = full.parent().context("Invalid path")?; + if !writable(parent) { + anyhow::bail!("Parent directory is not writable (EACCES)"); + } Ok(()) } -/// Verify file deletion preconditions (stub — always succeeds) -pub fn verify_delete_file(_root: &str, _path: &str) -> Result<()> { +/// Check Lean `CreateFilePrecondition` (`proofs/lean4/FileOperations.lean`): +/// `notExists`, `parentExists`, `parentIsDir`, `parentWritable`. +pub fn verify_create_file(root: &str, path: &str) -> Result<()> { + verify_new_node(&resolve(root, path)) +} + +/// Check Lean `DeleteFilePrecondition` (`proofs/lean4/FileOperations.lean`): +/// `isFile`, `parentWritable`. +/// +/// A symlink is accepted as a file node, as in the Lean model; FIFOs, +/// sockets and devices are rejected. +pub fn verify_delete_file(root: &str, path: &str) -> Result<()> { + let full = resolve(root, path); + if !node_exists(&full) { + anyhow::bail!("Path does not exist (ENOENT)"); + } + if node_is_dir(&full) { + anyhow::bail!("Path is a directory - use rmdir (EISDIR)"); + } + if !node_is_file(&full) { + anyhow::bail!("Path is not a regular file"); + } + let parent = full.parent().context("Invalid path")?; + if !writable(parent) { + anyhow::bail!("Parent directory is not writable (EACCES)"); + } Ok(()) } -/// Verify file copy preconditions (stub — always succeeds) -pub fn verify_copy_file(_root: &str, _src: &str, _dst: &str) -> Result<()> { +/// Check Lean `copyFilePrecondition` (`proofs/lean4/CopyMoveOperations.lean`): +/// `isFile src`, `¬pathExists dst`, `parentExists dst`, +/// `isDirectory (parentPath dst)`, `hasReadPermission src`, +/// `hasWritePermission (parentPath dst)`. +/// +/// `src` is checked through any symlink, because the copy reads the target. +pub fn verify_copy_file(root: &str, src: &str, dst: &str) -> Result<()> { + let src_path = resolve(root, src); + let dst_path = resolve(root, dst); + if !src_path.exists() { + anyhow::bail!("Source does not exist (ENOENT): {}", src); + } + if src_path.is_dir() { + anyhow::bail!("Source is a directory - recursive copy not yet supported (EISDIR)"); + } + if node_exists(&dst_path) { + anyhow::bail!("Destination already exists (EEXIST): {}", dst); + } + let dst_parent = dst_path.parent().context("Invalid destination path")?; + if !dst_parent.exists() { + anyhow::bail!("Parent of destination does not exist (ENOENT)"); + } + if !src_path.is_file() { + anyhow::bail!("Source is not a regular file: {}", src); + } + if !dst_parent.is_dir() { + anyhow::bail!("Parent of destination is not a directory (ENOTDIR)"); + } + if !readable(&src_path) { + anyhow::bail!("Source is not readable (EACCES): {}", src); + } + if !writable(dst_parent) { + anyhow::bail!("Parent of destination is not writable (EACCES)"); + } Ok(()) } -/// Verify move/rename preconditions (stub — always succeeds) -pub fn verify_move(_root: &str, _src: &str, _dst: &str) -> Result<()> { +/// Check Lean `movePrecondition` (`proofs/lean4/CopyMoveOperations.lean`): +/// `pathExists src`, `¬pathExists dst`, `parentExists dst`, `src ≠ dst`, +/// `¬(isDirectory src ∧ isPrefix src dst)`, +/// `hasWritePermission (parentPath src)`, `hasWritePermission (parentPath dst)`. +/// +/// Like the Lean definition, this does not require the destination's parent +/// to be a directory (copy does); the rename itself fails if it is not. +pub fn verify_move(root: &str, src: &str, dst: &str) -> Result<()> { + let src_path = resolve(root, src); + let dst_path = resolve(root, dst); + if !node_exists(&src_path) { + anyhow::bail!("Source does not exist (ENOENT): {}", src); + } + if node_exists(&dst_path) { + anyhow::bail!("Destination already exists (EEXIST): {}", dst); + } + if src_path == dst_path { + anyhow::bail!("Source and destination are the same"); + } + // `is_dir` follows a symlinked source, matching the command's check; + // this is at least as strict as the Lean `isDirectory` on the node. + if src_path.is_dir() && dst_path.starts_with(&src_path) { + anyhow::bail!("Cannot move directory into itself"); + } + let dst_parent = dst_path.parent().context("Invalid destination path")?; + if !dst_parent.exists() { + anyhow::bail!("Parent of destination does not exist (ENOENT)"); + } + let src_parent = src_path.parent().context("Invalid source path")?; + if !writable(src_parent) { + anyhow::bail!("Parent of source is not writable (EACCES)"); + } + if !writable(dst_parent) { + anyhow::bail!("Parent of destination is not writable (EACCES)"); + } Ok(()) } -/// Verify symlink creation preconditions (stub — always succeeds) -pub fn verify_symlink(_root: &str, _target: &str, _link: &str) -> Result<()> { +/// Check Lean `SymlinkPrecondition` (`proofs/lean4/SymlinkOperations.lean`): +/// `notExists`, `parentExists`, `parentIsDir`, `parentWritable`, all on the +/// link path. +/// +/// The Lean model places no condition on the link target, so `_target` is +/// not inspected: a dangling symlink is allowed. +pub fn verify_symlink(root: &str, _target: &str, link: &str) -> Result<()> { + let link_path = resolve(root, link); + if node_exists(&link_path) { + anyhow::bail!("Link path already exists (EEXIST): {}", link); + } + let link_parent = link_path.parent().context("Invalid link path")?; + if !link_parent.exists() { + anyhow::bail!("Parent of link does not exist (ENOENT)"); + } + if !link_parent.is_dir() { + anyhow::bail!("Parent of link is not a directory (ENOTDIR)"); + } + if !writable(link_parent) { + anyhow::bail!("Parent of link is not writable (EACCES)"); + } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + /// A fresh sandbox root and its path as `&str`-able `String`. + fn sandbox() -> (TempDir, String) { + let dir = TempDir::new().expect("tempdir"); + let root = dir.path().to_str().expect("utf-8 tempdir").to_string(); + (dir, root) + } + + /// The error text of a failed check. + fn err(r: Result<()>) -> String { + r.expect_err("expected precondition failure").to_string() + } + + /// Whether permission-denial tests are meaningful (root bypasses `access`). + #[cfg(unix)] + fn not_root() -> bool { + // SAFETY: geteuid has no preconditions. + unsafe { libc::geteuid() != 0 } + } + + /// Make `p` read-only (r-x) so creating entries in it is denied. + #[cfg(unix)] + fn make_readonly(p: &Path) { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(p, fs::Permissions::from_mode(0o555)).unwrap(); + } + + /// Restore `p` to rwx so `TempDir` can clean it up. + #[cfg(unix)] + fn make_writable(p: &Path) { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(p, fs::Permissions::from_mode(0o755)).unwrap(); + } + + // ---- mkdir ---- + + /// `verify_mkdir`: ok when absent with dir parent. + #[test] + fn mkdir_ok_when_absent_with_dir_parent() { + let (_d, root) = sandbox(); + assert!(verify_mkdir(&root, "new").is_ok()); + } + + /// `verify_mkdir`: rejects existing path. + #[test] + fn mkdir_rejects_existing_path() { + let (d, root) = sandbox(); + fs::create_dir(d.path().join("a")).unwrap(); + assert!(err(verify_mkdir(&root, "a")).contains("already exists")); + } + + /// `verify_mkdir`: rejects missing parent. + #[test] + fn mkdir_rejects_missing_parent() { + let (_d, root) = sandbox(); + assert!(err(verify_mkdir(&root, "no/such")).contains("Parent directory does not exist")); + } + + /// `verify_mkdir`: rejects file parent. + #[test] + fn mkdir_rejects_file_parent() { + let (d, root) = sandbox(); + fs::write(d.path().join("f"), "").unwrap(); + assert!(err(verify_mkdir(&root, "f/sub")).contains("ENOTDIR")); + } + + /// `verify_mkdir`: rejects dangling symlink at target. + #[cfg(unix)] + #[test] + fn mkdir_rejects_dangling_symlink_at_target() { + let (d, root) = sandbox(); + std::os::unix::fs::symlink("nowhere", d.path().join("dl")).unwrap(); + assert!(err(verify_mkdir(&root, "dl")).contains("already exists")); + } + + /// `verify_mkdir`: rejects unwritable parent. + #[cfg(unix)] + #[test] + fn mkdir_rejects_unwritable_parent() { + if !not_root() { + return; + } + let (d, root) = sandbox(); + let ro = d.path().join("ro"); + fs::create_dir(&ro).unwrap(); + make_readonly(&ro); + let r = verify_mkdir(&root, "ro/x"); + make_writable(&ro); + assert!(err(r).contains("not writable")); + } + + // ---- rmdir ---- + + /// `verify_rmdir`: ok on empty dir. + #[test] + fn rmdir_ok_on_empty_dir() { + let (d, root) = sandbox(); + fs::create_dir(d.path().join("e")).unwrap(); + assert!(verify_rmdir(&root, "e").is_ok()); + } + + /// `verify_rmdir`: rejects missing. + #[test] + fn rmdir_rejects_missing() { + let (_d, root) = sandbox(); + assert!(err(verify_rmdir(&root, "nope")).contains("does not exist")); + } + + /// `verify_rmdir`: rejects file. + #[test] + fn rmdir_rejects_file() { + let (d, root) = sandbox(); + fs::write(d.path().join("f"), "").unwrap(); + assert!(err(verify_rmdir(&root, "f")).contains("ENOTDIR")); + } + + /// `verify_rmdir`: rejects non empty. + #[test] + fn rmdir_rejects_non_empty() { + let (d, root) = sandbox(); + fs::create_dir(d.path().join("n")).unwrap(); + fs::write(d.path().join("n/child"), "").unwrap(); + assert!(err(verify_rmdir(&root, "n")).contains("not empty")); + } + + /// `verify_rmdir`: rejects sandbox root. + #[test] + fn rmdir_rejects_sandbox_root() { + let (_d, root) = sandbox(); + assert!(err(verify_rmdir(&root, "/")).contains("sandbox root")); + assert!(err(verify_rmdir(&root, "..")).contains("sandbox root")); + } + + /// `verify_rmdir`: rejects symlink to dir. + #[cfg(unix)] + #[test] + fn rmdir_rejects_symlink_to_dir() { + let (d, root) = sandbox(); + fs::create_dir(d.path().join("real")).unwrap(); + std::os::unix::fs::symlink("real", d.path().join("ln")).unwrap(); + assert!(err(verify_rmdir(&root, "ln")).contains("ENOTDIR")); + } + + // ---- create file ---- + + /// `verify_create_file`: ok when absent. + #[test] + fn create_file_ok_when_absent() { + let (_d, root) = sandbox(); + assert!(verify_create_file(&root, "f.txt").is_ok()); + } + + /// `verify_create_file`: rejects existing. + #[test] + fn create_file_rejects_existing() { + let (d, root) = sandbox(); + fs::write(d.path().join("f"), "").unwrap(); + assert!(err(verify_create_file(&root, "f")).contains("already exists")); + } + + /// `verify_create_file`: rejects missing parent. + #[test] + fn create_file_rejects_missing_parent() { + let (_d, root) = sandbox(); + assert!(err(verify_create_file(&root, "x/y")).contains("does not exist")); + } + + /// `verify_create_file`: rejects file parent. + #[test] + fn create_file_rejects_file_parent() { + let (d, root) = sandbox(); + fs::write(d.path().join("f"), "").unwrap(); + assert!(err(verify_create_file(&root, "f/g")).contains("ENOTDIR")); + } + + // ---- delete file ---- + + /// `verify_delete_file`: ok on file. + #[test] + fn delete_file_ok_on_file() { + let (d, root) = sandbox(); + fs::write(d.path().join("f"), "x").unwrap(); + assert!(verify_delete_file(&root, "f").is_ok()); + } + + /// `verify_delete_file`: rejects missing. + #[test] + fn delete_file_rejects_missing() { + let (_d, root) = sandbox(); + assert!(err(verify_delete_file(&root, "f")).contains("does not exist")); + } + + /// `verify_delete_file`: rejects dir. + #[test] + fn delete_file_rejects_dir() { + let (d, root) = sandbox(); + fs::create_dir(d.path().join("d")).unwrap(); + assert!(err(verify_delete_file(&root, "d")).contains("EISDIR")); + } + + /// `verify_delete_file`: rejects fifo. + #[cfg(unix)] + #[test] + fn delete_file_rejects_fifo() { + let (d, root) = sandbox(); + let fifo = d.path().join("p"); + let c = std::ffi::CString::new(fifo.to_str().unwrap()).unwrap(); + // SAFETY: valid NUL-terminated path. + assert_eq!(unsafe { libc::mkfifo(c.as_ptr(), 0o644) }, 0); + assert!(err(verify_delete_file(&root, "p")).contains("not a regular file")); + } + + /// `verify_delete_file`: accepts symlink as file node. + #[cfg(unix)] + #[test] + fn delete_file_accepts_symlink_as_file_node() { + let (d, root) = sandbox(); + std::os::unix::fs::symlink("nowhere", d.path().join("dl")).unwrap(); + assert!(verify_delete_file(&root, "dl").is_ok()); + } + + /// `verify_delete_file`: rejects unwritable parent. + #[cfg(unix)] + #[test] + fn delete_file_rejects_unwritable_parent() { + if !not_root() { + return; + } + let (d, root) = sandbox(); + let ro = d.path().join("ro"); + fs::create_dir(&ro).unwrap(); + fs::write(ro.join("f"), "").unwrap(); + make_readonly(&ro); + let r = verify_delete_file(&root, "ro/f"); + make_writable(&ro); + assert!(err(r).contains("not writable")); + } + + // ---- copy ---- + + /// `verify_copy_file`: ok on file to absent dst. + #[test] + fn copy_ok_on_file_to_absent_dst() { + let (d, root) = sandbox(); + fs::write(d.path().join("s"), "x").unwrap(); + assert!(verify_copy_file(&root, "s", "t").is_ok()); + } + + /// `verify_copy_file`: rejects missing src. + #[test] + fn copy_rejects_missing_src() { + let (_d, root) = sandbox(); + assert!(err(verify_copy_file(&root, "s", "t")).contains("Source does not exist")); + } + + /// `verify_copy_file`: rejects dir src. + #[test] + fn copy_rejects_dir_src() { + let (d, root) = sandbox(); + fs::create_dir(d.path().join("s")).unwrap(); + assert!(err(verify_copy_file(&root, "s", "t")).contains("EISDIR")); + } + + /// `verify_copy_file`: rejects existing dst. + #[test] + fn copy_rejects_existing_dst() { + let (d, root) = sandbox(); + fs::write(d.path().join("s"), "").unwrap(); + fs::write(d.path().join("t"), "").unwrap(); + assert!(err(verify_copy_file(&root, "s", "t")).contains("Destination already exists")); + } + + /// `verify_copy_file`: rejects missing dst parent. + #[test] + fn copy_rejects_missing_dst_parent() { + let (d, root) = sandbox(); + fs::write(d.path().join("s"), "").unwrap(); + assert!(err(verify_copy_file(&root, "s", "no/t")).contains("Parent of destination")); + } + + /// `verify_copy_file`: rejects file dst parent. + #[test] + fn copy_rejects_file_dst_parent() { + let (d, root) = sandbox(); + fs::write(d.path().join("s"), "").unwrap(); + fs::write(d.path().join("f"), "").unwrap(); + assert!(err(verify_copy_file(&root, "s", "f/t")).contains("ENOTDIR")); + } + + /// `verify_copy_file`: rejects unreadable src. + #[cfg(unix)] + #[test] + fn copy_rejects_unreadable_src() { + if !not_root() { + return; + } + use std::os::unix::fs::PermissionsExt; + let (d, root) = sandbox(); + let s = d.path().join("s"); + fs::write(&s, "x").unwrap(); + fs::set_permissions(&s, fs::Permissions::from_mode(0o000)).unwrap(); + assert!(err(verify_copy_file(&root, "s", "t")).contains("not readable")); + } + + // ---- move ---- + + /// `verify_move`: ok on file. + #[test] + fn move_ok_on_file() { + let (d, root) = sandbox(); + fs::write(d.path().join("s"), "").unwrap(); + assert!(verify_move(&root, "s", "t").is_ok()); + } + + /// `verify_move`: ok on dir to sibling. + #[test] + fn move_ok_on_dir_to_sibling() { + let (d, root) = sandbox(); + fs::create_dir(d.path().join("s")).unwrap(); + assert!(verify_move(&root, "s", "t").is_ok()); + } + + /// `verify_move`: rejects missing src. + #[test] + fn move_rejects_missing_src() { + let (_d, root) = sandbox(); + assert!(err(verify_move(&root, "s", "t")).contains("Source does not exist")); + } + + /// `verify_move`: rejects existing dst. + #[test] + fn move_rejects_existing_dst() { + let (d, root) = sandbox(); + fs::write(d.path().join("s"), "").unwrap(); + fs::write(d.path().join("t"), "").unwrap(); + assert!(err(verify_move(&root, "s", "t")).contains("Destination already exists")); + } + + /// `verify_move`: rejects dir into itself. + #[test] + fn move_rejects_dir_into_itself() { + let (d, root) = sandbox(); + fs::create_dir(d.path().join("s")).unwrap(); + assert!(err(verify_move(&root, "s", "s/inner")).contains("into itself")); + } + + /// `verify_move`: rejects missing dst parent. + #[test] + fn move_rejects_missing_dst_parent() { + let (d, root) = sandbox(); + fs::write(d.path().join("s"), "").unwrap(); + assert!(err(verify_move(&root, "s", "no/t")).contains("Parent of destination")); + } + + /// `verify_move`: rejects unwritable src parent. + #[cfg(unix)] + #[test] + fn move_rejects_unwritable_src_parent() { + if !not_root() { + return; + } + let (d, root) = sandbox(); + let ro = d.path().join("ro"); + fs::create_dir(&ro).unwrap(); + fs::write(ro.join("s"), "").unwrap(); + make_readonly(&ro); + let r = verify_move(&root, "ro/s", "t"); + make_writable(&ro); + assert!(err(r).contains("Parent of source is not writable")); + } + + // ---- symlink ---- + + /// `verify_symlink`: ok with dangling target. + #[test] + fn symlink_ok_with_dangling_target() { + let (_d, root) = sandbox(); + assert!(verify_symlink(&root, "does/not/exist", "ln").is_ok()); + } + + /// `verify_symlink`: rejects existing link path. + #[test] + fn symlink_rejects_existing_link_path() { + let (d, root) = sandbox(); + fs::write(d.path().join("ln"), "").unwrap(); + assert!(err(verify_symlink(&root, "x", "ln")).contains("Link path already exists")); + } + + /// `verify_symlink`: rejects dangling symlink at link path. + #[cfg(unix)] + #[test] + fn symlink_rejects_dangling_symlink_at_link_path() { + let (d, root) = sandbox(); + std::os::unix::fs::symlink("nowhere", d.path().join("ln")).unwrap(); + assert!(err(verify_symlink(&root, "x", "ln")).contains("Link path already exists")); + } + + /// `verify_symlink`: rejects missing parent. + #[test] + fn symlink_rejects_missing_parent() { + let (_d, root) = sandbox(); + assert!(err(verify_symlink(&root, "x", "no/ln")).contains("Parent of link does not exist")); + } + + /// `verify_symlink`: rejects file parent. + #[test] + fn symlink_rejects_file_parent() { + let (d, root) = sandbox(); + fs::write(d.path().join("f"), "").unwrap(); + assert!(err(verify_symlink(&root, "x", "f/ln")).contains("ENOTDIR")); + } +}