huskies: merge 608_story_extract_io_and_anthropic_services

This commit is contained in:
dave
2026-04-24 15:50:26 +00:00
parent aba3120388
commit 65c896f07f
8 changed files with 617 additions and 291 deletions
+84
View File
@@ -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)
}
})
}
+183
View File
@@ -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"));
}
}