184 lines
6.5 KiB
Rust
184 lines
6.5 KiB
Rust
|
|
//! 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<String, Error> {
|
||
|
|
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<Vec<FileEntry>, 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<Vec<FileEntry>, 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<String, Error> {
|
||
|
|
io::get_home_directory()
|
||
|
|
}
|
||
|
|
|
||
|
|
/// List all files in the project recursively, respecting `.gitignore`.
|
||
|
|
pub async fn list_project_files(state: &SessionState) -> Result<Vec<String>, 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<Vec<SearchResult>, 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<String>,
|
||
|
|
state: &SessionState,
|
||
|
|
) -> Result<CommandOutput, Error> {
|
||
|
|
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"));
|
||
|
|
}
|
||
|
|
}
|