//! File I/O service — public API for filesystem and shell operations. //! //! Exposes functions for reading, writing, and listing files scoped to the //! active project root, plus utilities for absolute-path and shell operations. //! HTTP handlers call these functions instead of touching `io::fs` directly. //! //! Conventions: `docs/architecture/service-modules.md` pub(super) mod io; use crate::state::SessionState; /// Re-export the canonical filesystem entry type so HTTP handlers don't need /// to import from `io::fs` directly. pub use crate::io::fs::FileEntry; /// Re-export the search result type. pub use crate::io::search::SearchResult; /// Re-export the shell output type. pub use crate::io::shell::CommandOutput; // ── Error type ──────────────────────────────────────────────────────────────── /// Typed errors returned by `service::file_io` functions. /// /// HTTP handlers map these to status codes: /// - [`Error::Validation`] → 400 Bad Request /// - [`Error::Filesystem`] → 400 Bad Request (or 404 when appropriate) #[derive(Debug)] pub enum Error { /// The request was invalid (e.g. path traversal attempt, command not allowlisted). Validation(String), /// A filesystem or shell operation failed (file not found, permission denied, etc.). Filesystem(String), } impl std::fmt::Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Validation(msg) => write!(f, "Validation error: {msg}"), Self::Filesystem(msg) => write!(f, "Filesystem error: {msg}"), } } } // ── Path validation ─────────────────────────────────────────────────────────── /// Validate a relative path, rejecting directory traversal attempts. /// /// Returns [`Error::Validation`] when the path contains `..`. pub fn validate_path(path: &str) -> Result<(), Error> { if path.contains("..") { return Err(Error::Validation( "Security Violation: Directory traversal ('..') is not allowed.".to_string(), )); } Ok(()) } // ── Public API ──────────────────────────────────────────────────────────────── /// Read a file from the project root. pub async fn read_file(path: String, state: &SessionState) -> Result { validate_path(&path)?; io::read_file(path, state).await } /// Write a file to the project root, creating parent directories as needed. pub async fn write_file(path: String, content: String, state: &SessionState) -> Result<(), Error> { validate_path(&path)?; io::write_file(path, content, state).await } /// List directory entries at a project-relative path. pub async fn list_directory(path: String, state: &SessionState) -> Result, Error> { io::list_directory(path, state).await } /// List directory entries at an absolute path (not scoped to the project root). pub async fn list_directory_absolute(path: String) -> Result, Error> { io::list_directory_absolute(path).await } /// Create a directory (and all parents) at an absolute path. pub async fn create_directory_absolute(path: String) -> Result<(), Error> { io::create_directory_absolute(path).await } /// Return the current user's home directory path. pub fn get_home_directory() -> Result { io::get_home_directory() } /// List all files in the project recursively, respecting `.gitignore`. pub async fn list_project_files(state: &SessionState) -> Result, Error> { io::list_project_files(state).await } /// Search the project for files whose contents contain `query`. pub async fn search_files(query: String, state: &SessionState) -> Result, Error> { io::search_files(query, state).await } /// Execute an allowlisted shell command in the project root directory. pub async fn exec_shell( command: String, args: Vec, state: &SessionState, ) -> Result { io::exec_shell(command, args, state).await } // ── Tests ───────────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; // Pure unit tests for path validation and sanitisation — no tempdir, no network. #[test] fn validate_path_accepts_simple_relative_path() { assert!(validate_path("src/main.rs").is_ok()); } #[test] fn validate_path_accepts_dot_path() { assert!(validate_path(".").is_ok()); } #[test] fn validate_path_accepts_root_relative() { assert!(validate_path("subdir/file.txt").is_ok()); } #[test] fn validate_path_rejects_parent_traversal() { let result = validate_path("../etc/passwd"); assert!(matches!(result, Err(Error::Validation(_)))); } #[test] fn validate_path_rejects_embedded_traversal() { let result = validate_path("src/../../../etc/passwd"); assert!(matches!(result, Err(Error::Validation(_)))); } #[test] fn validate_path_rejects_double_dot_only() { let result = validate_path(".."); assert!(matches!(result, Err(Error::Validation(_)))); } #[test] fn validate_path_accepts_file_with_single_dots_in_name() { // Filenames like "config.dev.toml" have single dots — must be accepted. assert!(validate_path("config.dev.toml").is_ok()); } #[test] fn validate_path_rejects_traversal_with_url_encoding_lookalike() { // A literal ".." sequence anywhere in the string is rejected. let result = validate_path("valid/..hidden"); assert!(matches!(result, Err(Error::Validation(_)))); } #[test] fn error_display_validation() { let e = Error::Validation("bad path".to_string()); assert!(e.to_string().contains("bad path")); } #[test] fn error_display_filesystem() { let e = Error::Filesystem("file not found".to_string()); assert!(e.to_string().contains("file not found")); } #[test] fn error_display_filesystem_contains_message() { let e = Error::Filesystem("task panic".to_string()); assert!(e.to_string().contains("task panic")); } }