Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions crates/tinytools-std/src/filesystem/edit_file/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,13 +167,17 @@ impl EditFileTool {
Err(msg) => return Ok(ToolResult::error(msg)),
};

if let Ok(meta) = tokio::fs::metadata(&resolved).await
&& meta.len() > MAX_FILE_BYTES
{
return Ok(ToolResult::error(format!(
"File too large: {} bytes (limit: {MAX_FILE_BYTES} bytes)",
meta.len()
)));
if let Ok(meta) = tokio::fs::metadata(&resolved).await {
// A FIFO or device would block the read (or never end).
if !meta.is_file() {
return Ok(ToolResult::error(format!("{path} is not a regular file")));
}
if meta.len() > MAX_FILE_BYTES {
return Ok(ToolResult::error(format!(
"File too large: {} bytes (limit: {MAX_FILE_BYTES} bytes)",
meta.len()
)));
}
}

// Acquire per-path lock for the read-modify-write section.
Expand Down
25 changes: 25 additions & 0 deletions crates/tinytools-std/src/filesystem/edit_file/mod_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -340,3 +340,28 @@ async fn edit_refuses_a_replacement_that_outgrows_the_cap() {
&result.output()[..result.output().len().min(200)]
);
}

#[cfg(unix)]
#[tokio::test]
async fn edit_refuses_a_fifo() {
let dir = tempfile::tempdir().unwrap();
let fifo = dir.path().join("pipe");
assert!(
std::process::Command::new("mkfifo")
.arg(&fifo)
.status()
.unwrap()
.success()
);
let tool = EditFileTool::new(test_security(dir.path().to_path_buf()));
let result = tokio::time::timeout(
std::time::Duration::from_secs(5),
tool.execute(json!({"path": "pipe", "old_string": "a", "new_string": "b"})),
)
.await;
// Give a stuck reader EOF so a failing run can still shut down.
std::thread::spawn(move || drop(std::fs::OpenOptions::new().write(true).open(fifo)));
let result = result.expect("edit blocked on a FIFO").unwrap();
assert!(result.is_error);
assert!(result.output().contains("not a regular file"));
}
25 changes: 25 additions & 0 deletions crates/tinytools-std/src/filesystem/file_read/extra_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,28 @@ async fn a_paged_read_is_recorded_as_partial() {
"a paged read was recorded as a full read"
);
}

#[cfg(unix)]
#[tokio::test]
async fn a_fifo_is_refused_not_read() {
let dir = TempDir::new().unwrap();
let fifo = dir.path().join("pipe");
assert!(
std::process::Command::new("mkfifo")
.arg(&fifo)
.status()
.unwrap()
.success()
);
let tool = FileReadTool::new(TestGate::supervised(dir.path().to_path_buf()));
let result = tokio::time::timeout(
std::time::Duration::from_secs(5),
tool.execute(json!({"path": "pipe"})),
)
.await;
// Give a stuck reader EOF so a failing run can still shut down.
std::thread::spawn(move || drop(std::fs::OpenOptions::new().write(true).open(fifo)));
let result = result.expect("file_read blocked on a FIFO").unwrap();
assert!(result.is_error);
assert!(result.output().contains("not a regular file"));
}
4 changes: 4 additions & 0 deletions crates/tinytools-std/src/filesystem/file_read/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,10 @@ impl FileReadTool {
// Check file size AFTER canonicalization to prevent TOCTOU symlink bypass
match tokio::fs::metadata(&resolved_path).await {
Ok(meta) => {
// A FIFO or device would block the read (or never end).
if !meta.is_file() {
return Ok(ToolResult::error(format!("{path} is not a regular file")));
}
if meta.len() > MAX_FILE_SIZE_BYTES {
return Ok(ToolResult::error(format!(
"File too large: {} bytes (limit: {MAX_FILE_SIZE_BYTES} bytes)",
Expand Down
23 changes: 15 additions & 8 deletions crates/tinytools-std/src/filesystem/image_info/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,9 @@ impl ImageInfoTool {
let marker = bytes[i + 1];
i += 2;

// SOF0..SOF3 markers contain dimensions
if (0xC0..=0xC3).contains(&marker) {
// SOF0..SOF15 carry the frame size; C4 (DHT), C8 (JPG) and
// CC (DAC) share the range but are ordinary segments.
if (0xC0..=0xCF).contains(&marker) && !matches!(marker, 0xC4 | 0xC8 | 0xCC) {
if i + 7 <= bytes.len() {
let h = u32::from(u16::from_be_bytes([bytes[i + 3], bytes[i + 4]]));
let w = u32::from(u16::from_be_bytes([bytes[i + 5], bytes[i + 6]]));
Expand Down Expand Up @@ -163,9 +164,14 @@ impl Tool for ImageInfoTool {
Err(msg) => return Ok(ToolResult::error(msg)),
};

let metadata = tokio::fs::metadata(&resolved)
.await
.map_err(|e| anyhow::anyhow!("Failed to read file metadata: {e}"))?;
let metadata = match tokio::fs::metadata(&resolved).await {
Ok(m) => m,
Err(e) => {
return Ok(ToolResult::error(format!(
"Failed to read file metadata: {e}"
)));
}
};

let file_size = metadata.len();

Expand All @@ -175,9 +181,10 @@ impl Tool for ImageInfoTool {
)));
}

let bytes = tokio::fs::read(&resolved)
.await
.map_err(|e| anyhow::anyhow!("Failed to read image file: {e}"))?;
let bytes = match tokio::fs::read(&resolved).await {
Ok(b) => b,
Err(e) => return Ok(ToolResult::error(format!("Failed to read image file: {e}"))),
};

let format = Self::detect_format(&bytes);
let dimensions = Self::extract_dimensions(&bytes, format);
Expand Down
36 changes: 33 additions & 3 deletions crates/tinytools-std/src/filesystem/image_info/mod_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -334,9 +334,39 @@ async fn execute_on_a_directory_reports_a_read_failure() {
let dir = std::env::temp_dir().join("tinytools_image_info_dir_test");
tokio::fs::create_dir_all(&dir).await.unwrap();
let tool = ImageInfoTool::new(test_security());
let err = tool
let result = tool
.execute(json!({"path": dir.to_string_lossy()}))
.await
.unwrap_err();
assert!(err.to_string().contains("Failed to read image file"));
.unwrap();
assert!(result.is_error);
assert!(result.output().contains("Failed to read image file"));
}

// Every SOF marker (C0-CF except DHT C4, JPG C8 and DAC CC) carries the
// frame size; the other three are segments to skip.
#[test]
fn jpeg_dimensions_come_from_every_sof_marker_and_skip_c4_c8_cc() {
let sof = |marker: u8| {
vec![
0xFF, 0xD8, 0xFF, marker, 0x00, 0x11, 0x08, 0x01, 0xE0, 0x02, 0x80,
]
};
for marker in [
0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF,
] {
assert_eq!(
ImageInfoTool::extract_dimensions(&sof(marker), "jpeg"),
Some((640, 480)),
"SOF {marker:#X}"
);
}
for marker in [0xC4, 0xC8, 0xCC] {
let mut bytes = vec![0xFF, 0xD8, 0xFF, marker, 0x00, 0x04, 0x01, 0xE0];
bytes.extend_from_slice(&sof(0xC0)[2..]);
assert_eq!(
ImageInfoTool::extract_dimensions(&bytes, "jpeg"),
Some((640, 480)),
"{marker:#X} must be skipped"
);
}
}
15 changes: 15 additions & 0 deletions crates/tinytools-std/src/filesystem/list_files/extra_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,18 @@ async fn listing_stops_at_the_entry_cap() {
.starts_with(&format!("{MAX_ENTRIES} entr(ies) in ."))
);
}

#[tokio::test]
async fn a_capped_listing_says_it_was_truncated() {
let dir = TempDir::new().unwrap();
for i in 0..=MAX_ENTRIES {
std::fs::write(dir.path().join(format!("f{i:05}")), "").unwrap();
}
let tool = ListFilesTool::new(TestGate::supervised(dir.path().to_path_buf()));
let r = tool.execute(json!({})).await.unwrap();
assert!(r.output().ends_with("[truncated]"), "{}", &r.output()[..80]);

std::fs::remove_file(dir.path().join("f00000")).unwrap();
let r = tool.execute(json!({})).await.unwrap();
assert!(!r.output().contains("[truncated]"));
}
9 changes: 9 additions & 0 deletions crates/tinytools-std/src/filesystem/list_files/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use super::gate::{FsGate, gate_for_context};
use async_trait::async_trait;
use serde_json::json;
use std::fmt::Write;
use std::sync::Arc;
use tinytools::ToolRunContext;
use tinytools::{PermissionLevel, Tool, ToolCallOptions, ToolResult};
Expand Down Expand Up @@ -101,6 +102,7 @@ impl ListFilesTool {
};

let mut entries: Vec<(String, String)> = Vec::new();
let mut truncated = false;
loop {
match read.next_entry().await {
Ok(Some(entry)) => {
Expand All @@ -113,6 +115,7 @@ impl ListFilesTool {
};
entries.push((kind.to_string(), name));
if entries.len() >= MAX_ENTRIES {
truncated = matches!(read.next_entry().await, Ok(Some(_)));
break;
}
}
Expand All @@ -134,6 +137,12 @@ impl ListFilesTool {
body.push('\t');
body.push_str(&name);
}
if truncated {
let _ = write!(
body,
"\n... listing stopped at {MAX_ENTRIES} entries [truncated]"
);
}
Ok(ToolResult::success(body))
}
}
Expand Down
Loading