use std::path::Path;
use serde::Serialize;
pub const MAX_FILE_SIZE: u64 = 100 * 1024 * 1024;
pub const MAX_INLINE_SIZE: usize = 50 * 1024 * 1024;
pub fn read_file_bytes(file_path: &str) -> Result<Vec<u8>, ToolErrorInfo> {
let path = Path::new(file_path);
check_file_size(path)?;
std::fs::read(path).map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => ToolErrorInfo::new(
"FILE_NOT_FOUND",
format!("File not found: {file_path}"),
"Check the file path and try again.",
),
_ => ToolErrorInfo::new(
"READ_ERROR",
format!("Failed to read file: {e}"),
"Check file permissions.",
),
})
}
pub fn read_file_string(file_path: &str) -> Result<String, ToolErrorInfo> {
let bytes = read_file_bytes(file_path)?;
String::from_utf8(bytes).map_err(|e| {
ToolErrorInfo::new(
"READ_ERROR",
format!("File is not valid UTF-8: {e}"),
"Ensure the file is UTF-8 encoded.",
)
})
}
fn home_dir() -> Option<std::path::PathBuf> {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(std::path::PathBuf::from)
.filter(|p| !p.as_os_str().is_empty())
}
fn is_sensitive_output_path(target: &Path, home: &Path) -> bool {
const SENSITIVE_SUBDIRS: &[&str] =
&[".ssh", ".gnupg", ".aws", ".config", ".claude", ".codex", ".gemini"];
if SENSITIVE_SUBDIRS.iter().any(|sub| target.starts_with(home.join(sub))) {
return true;
}
if target.parent() == Some(home) {
if let Some(name) = target.file_name().and_then(|n| n.to_str()) {
return name.starts_with('.');
}
}
false
}
pub fn write_output_file(output_path: &str, data: &[u8]) -> Result<(), ToolErrorInfo> {
let out = Path::new(output_path);
if out.components().any(|c| matches!(c, std::path::Component::ParentDir)) {
return Err(ToolErrorInfo::new(
"WRITE_ERROR",
format!("Unsafe output path contains '..': {output_path}"),
"Provide an output path without any '..' parent-directory segments.",
));
}
if out.is_absolute() {
if let Some(home) = home_dir() {
if is_sensitive_output_path(out, &home) {
return Err(ToolErrorInfo::new(
"WRITE_ERROR",
format!("Refusing to write to a sensitive location: {output_path}"),
"Choose an output path outside SSH/credential/config directories.",
));
}
}
}
if out.symlink_metadata().map(|m| m.file_type().is_symlink()).unwrap_or(false) {
return Err(ToolErrorInfo::new(
"WRITE_ERROR",
format!("Refusing to write through a symlink: {output_path}"),
"Provide a real (non-symlink) output path.",
));
}
if let Some(parent) = out.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent).map_err(|e| {
ToolErrorInfo::new(
"WRITE_ERROR",
format!("Cannot create output directory: {e}"),
"Check write permissions.",
)
})?;
}
}
std::fs::write(out, data).map_err(|e| {
ToolErrorInfo::new(
"WRITE_ERROR",
format!("Failed to write file: {e}"),
"Check disk space and permissions.",
)
})
}
fn check_file_size(path: &Path) -> Result<(), ToolErrorInfo> {
match std::fs::metadata(path) {
Ok(m) if m.len() > MAX_FILE_SIZE => Err(ToolErrorInfo::new(
"INPUT_TOO_LARGE",
format!(
"File '{}' is {} MB, exceeds {} MB limit",
path.display(),
m.len() / 1024 / 1024,
MAX_FILE_SIZE / 1024 / 1024,
),
"Use a smaller file or split the document into sections.",
)),
Ok(_) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(ToolErrorInfo::new(
"FILE_NOT_FOUND",
format!("File not found: '{}'", path.display()),
"Check the file path and try again.",
)),
Err(e) => Err(ToolErrorInfo::new(
"METADATA_ERROR",
format!("Cannot read file metadata for '{}': {e}", path.display()),
"Check file permissions.",
)),
}
}
#[derive(Debug, Serialize)]
pub struct ToolOutput<T: Serialize> {
pub data: T,
pub summary: String,
pub next: Vec<String>,
}
impl<T: Serialize> ToolOutput<T> {
pub fn new(data: T, summary: impl Into<String>, next: Vec<&str>) -> Self {
Self { data, summary: summary.into(), next: next.into_iter().map(String::from).collect() }
}
pub fn to_json_string(&self) -> String {
serde_json::to_string_pretty(self)
.unwrap_or_else(|e| format!(r#"{{"error": "serialization failed: {e}"}}"#))
}
}
#[derive(Debug, Serialize)]
pub struct ToolErrorInfo {
pub code: String,
pub message: String,
pub hint: String,
}
impl ToolErrorInfo {
pub fn new(
code: impl Into<String>,
message: impl Into<String>,
hint: impl Into<String>,
) -> Self {
Self { code: code.into(), message: message.into(), hint: hint.into() }
}
pub fn to_json_string(&self) -> String {
serde_json::to_string_pretty(self).unwrap_or_else(|_| format!("Error: {}", self.message))
}
}
#[derive(Debug, Serialize, Clone, PartialEq, Eq)]
pub struct ToolWarningInfo {
pub code: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub hint: Option<String>,
}
impl ToolWarningInfo {
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self { code: code.into(), message: message.into(), hint: None }
}
pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
self.hint = Some(hint.into());
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn read_file_bytes_missing_file() {
let err = read_file_bytes("/nonexistent/path.hwpx").unwrap_err();
assert_eq!(err.code, "FILE_NOT_FOUND");
}
#[test]
fn read_file_string_missing_file() {
let err = read_file_string("/nonexistent/path.md").unwrap_err();
assert_eq!(err.code, "FILE_NOT_FOUND");
}
#[test]
fn read_file_string_non_utf8() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("binary.dat");
std::fs::write(&path, [0xFF, 0xFE, 0x00, 0x80]).unwrap();
let err = read_file_string(path.to_str().unwrap()).unwrap_err();
assert_eq!(err.code, "READ_ERROR");
assert!(err.message.contains("UTF-8"));
}
#[test]
fn read_file_bytes_valid_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.txt");
std::fs::write(&path, b"hello").unwrap();
let bytes = read_file_bytes(path.to_str().unwrap()).unwrap();
assert_eq!(bytes, b"hello");
}
#[test]
fn read_file_string_valid_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.txt");
std::fs::write(&path, "한글 텍스트").unwrap();
let content = read_file_string(path.to_str().unwrap()).unwrap();
assert_eq!(content, "한글 텍스트");
}
#[test]
fn write_output_file_creates_dirs() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("a/b/c/output.hwpx");
write_output_file(path.to_str().unwrap(), b"data").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"data");
}
#[test]
fn write_output_file_overwrites() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("out.hwpx");
std::fs::write(&path, b"old").unwrap();
write_output_file(path.to_str().unwrap(), b"new").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"new");
}
#[test]
fn write_output_file_rejects_parent_dir_traversal() {
let dir = tempfile::tempdir().unwrap();
let escape = dir.path().join("sub").join("..").join("escape.txt");
let escape_str = escape.to_str().unwrap();
let err = write_output_file(escape_str, b"x").unwrap_err();
assert_eq!(err.code, "WRITE_ERROR");
assert!(err.message.contains(".."), "error must mention the '..' rejection");
let normalized = dir.path().join("escape.txt");
assert!(!normalized.exists(), "traversal target must not be written");
}
#[test]
fn write_output_file_rejects_relative_parent_dir() {
let err = write_output_file("../escape-e1-test.txt", b"x").unwrap_err();
assert_eq!(err.code, "WRITE_ERROR");
assert!(!Path::new("../escape-e1-test.txt").exists(), "must not write outside cwd");
}
#[test]
fn write_output_file_allows_absolute_path() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("abs-out.hwpx");
assert!(path.is_absolute());
write_output_file(path.to_str().unwrap(), b"data").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"data");
}
#[test]
fn is_sensitive_output_path_flags_credential_and_dotfile_targets() {
let home = Path::new("/home/u");
assert!(is_sensitive_output_path(Path::new("/home/u/.ssh/authorized_keys"), home));
assert!(is_sensitive_output_path(Path::new("/home/u/.claude/settings.json"), home));
assert!(is_sensitive_output_path(Path::new("/home/u/.config/x/y.toml"), home));
assert!(is_sensitive_output_path(Path::new("/home/u/.zshrc"), home));
assert!(!is_sensitive_output_path(Path::new("/home/u/Documents/out.hwpx"), home));
assert!(!is_sensitive_output_path(Path::new("/home/u/proposals/p.hwpx"), home));
assert!(!is_sensitive_output_path(Path::new("/tmp/out.hwpx"), home));
assert!(!is_sensitive_output_path(Path::new("/home/u/.sshfoo/x"), home));
}
#[test]
fn write_output_file_refuses_sensitive_home_target() {
let Some(home) = home_dir() else { return };
let target = home.join(".ssh").join("hwpforge-e1-guard-probe.tmp");
let err = write_output_file(target.to_str().unwrap(), b"x").unwrap_err();
assert_eq!(err.code, "WRITE_ERROR");
assert!(!target.exists(), "sensitive target must not be written");
}
#[cfg(unix)]
#[test]
fn write_output_file_refuses_symlink_final_component() {
let dir = tempfile::tempdir().unwrap();
let real_target = dir.path().join("real.txt");
let link = dir.path().join("link.hwpx");
std::os::unix::fs::symlink(&real_target, &link).unwrap();
let err = write_output_file(link.to_str().unwrap(), b"data").unwrap_err();
assert_eq!(err.code, "WRITE_ERROR");
assert!(!real_target.exists(), "symlink target must not be written through");
}
}