huskies: merge 608_story_extract_io_and_anthropic_services
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
//! File I/O — the ONLY place in `service/file_io/` that may perform
|
||||
//! filesystem reads, writes, shell execution, or other side effects.
|
||||
//!
|
||||
//! Every function here is a thin adapter that converts lower-level
|
||||
//! `String` errors into the typed [`super::Error`] variants.
|
||||
|
||||
use super::Error;
|
||||
use crate::io::fs::FileEntry;
|
||||
use crate::io::search::SearchResult;
|
||||
use crate::io::shell::CommandOutput;
|
||||
use crate::state::SessionState;
|
||||
|
||||
pub(super) async fn read_file(path: String, state: &SessionState) -> Result<String, Error> {
|
||||
crate::io::fs::read_file(path, state)
|
||||
.await
|
||||
.map_err(Error::Filesystem)
|
||||
}
|
||||
|
||||
pub(super) async fn write_file(
|
||||
path: String,
|
||||
content: String,
|
||||
state: &SessionState,
|
||||
) -> Result<(), Error> {
|
||||
crate::io::fs::write_file(path, content, state)
|
||||
.await
|
||||
.map_err(Error::Filesystem)
|
||||
}
|
||||
|
||||
pub(super) async fn list_directory(
|
||||
path: String,
|
||||
state: &SessionState,
|
||||
) -> Result<Vec<FileEntry>, Error> {
|
||||
crate::io::fs::list_directory(path, state)
|
||||
.await
|
||||
.map_err(Error::Filesystem)
|
||||
}
|
||||
|
||||
pub(super) async fn list_directory_absolute(path: String) -> Result<Vec<FileEntry>, Error> {
|
||||
crate::io::fs::list_directory_absolute(path)
|
||||
.await
|
||||
.map_err(Error::Filesystem)
|
||||
}
|
||||
|
||||
pub(super) async fn create_directory_absolute(path: String) -> Result<(), Error> {
|
||||
crate::io::fs::create_directory_absolute(path)
|
||||
.await
|
||||
.map_err(Error::Filesystem)
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
pub(super) fn get_home_directory() -> Result<String, Error> {
|
||||
crate::io::fs::get_home_directory().map_err(Error::Filesystem)
|
||||
}
|
||||
|
||||
pub(super) async fn list_project_files(state: &SessionState) -> Result<Vec<String>, Error> {
|
||||
crate::io::fs::list_project_files(state)
|
||||
.await
|
||||
.map_err(Error::Filesystem)
|
||||
}
|
||||
|
||||
pub(super) async fn search_files(
|
||||
query: String,
|
||||
state: &SessionState,
|
||||
) -> Result<Vec<SearchResult>, Error> {
|
||||
crate::io::search::search_files(query, state)
|
||||
.await
|
||||
.map_err(Error::Filesystem)
|
||||
}
|
||||
|
||||
pub(super) async fn exec_shell(
|
||||
command: String,
|
||||
args: Vec<String>,
|
||||
state: &SessionState,
|
||||
) -> Result<CommandOutput, Error> {
|
||||
crate::io::shell::exec_shell(command, args, state)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if e.contains("not in the allowlist") {
|
||||
Error::Validation(e)
|
||||
} else {
|
||||
Error::Filesystem(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
//! 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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user