From 79a4993fe30d0111376e7fd510b5e1aaff269483 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Fri, 23 Oct 2026 04:43:38 +0530 Subject: [PATCH 1/2] fix(std): refuse non-regular files, full JPEG SOF range, list truncation marker --- .../src/filesystem/edit_file/mod.rs | 18 ++++++---- .../src/filesystem/edit_file/mod_tests.rs | 25 +++++++++++++ .../src/filesystem/file_read/extra_tests.rs | 25 +++++++++++++ .../src/filesystem/file_read/mod.rs | 4 +++ .../src/filesystem/image_info/mod.rs | 23 +++++++----- .../src/filesystem/image_info/mod_tests.rs | 36 +++++++++++++++++-- .../src/filesystem/list_files/extra_tests.rs | 15 ++++++++ .../src/filesystem/list_files/mod.rs | 9 +++++ 8 files changed, 137 insertions(+), 18 deletions(-) diff --git a/crates/tinytools-std/src/filesystem/edit_file/mod.rs b/crates/tinytools-std/src/filesystem/edit_file/mod.rs index b5c38ef..06b047d 100644 --- a/crates/tinytools-std/src/filesystem/edit_file/mod.rs +++ b/crates/tinytools-std/src/filesystem/edit_file/mod.rs @@ -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. diff --git a/crates/tinytools-std/src/filesystem/edit_file/mod_tests.rs b/crates/tinytools-std/src/filesystem/edit_file/mod_tests.rs index 4083dbd..400c7ce 100644 --- a/crates/tinytools-std/src/filesystem/edit_file/mod_tests.rs +++ b/crates/tinytools-std/src/filesystem/edit_file/mod_tests.rs @@ -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")); +} diff --git a/crates/tinytools-std/src/filesystem/file_read/extra_tests.rs b/crates/tinytools-std/src/filesystem/file_read/extra_tests.rs index 1d2e5c2..e384a56 100644 --- a/crates/tinytools-std/src/filesystem/file_read/extra_tests.rs +++ b/crates/tinytools-std/src/filesystem/file_read/extra_tests.rs @@ -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")); +} diff --git a/crates/tinytools-std/src/filesystem/file_read/mod.rs b/crates/tinytools-std/src/filesystem/file_read/mod.rs index 1d52197..bf8efdc 100644 --- a/crates/tinytools-std/src/filesystem/file_read/mod.rs +++ b/crates/tinytools-std/src/filesystem/file_read/mod.rs @@ -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)", diff --git a/crates/tinytools-std/src/filesystem/image_info/mod.rs b/crates/tinytools-std/src/filesystem/image_info/mod.rs index 0d8e0bf..ece1098 100644 --- a/crates/tinytools-std/src/filesystem/image_info/mod.rs +++ b/crates/tinytools-std/src/filesystem/image_info/mod.rs @@ -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]])); @@ -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(); @@ -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); diff --git a/crates/tinytools-std/src/filesystem/image_info/mod_tests.rs b/crates/tinytools-std/src/filesystem/image_info/mod_tests.rs index ff5200b..12556a2 100644 --- a/crates/tinytools-std/src/filesystem/image_info/mod_tests.rs +++ b/crates/tinytools-std/src/filesystem/image_info/mod_tests.rs @@ -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" + ); + } } diff --git a/crates/tinytools-std/src/filesystem/list_files/extra_tests.rs b/crates/tinytools-std/src/filesystem/list_files/extra_tests.rs index 76d3c2f..4bb44c8 100644 --- a/crates/tinytools-std/src/filesystem/list_files/extra_tests.rs +++ b/crates/tinytools-std/src/filesystem/list_files/extra_tests.rs @@ -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 + 1) { + 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]")); +} diff --git a/crates/tinytools-std/src/filesystem/list_files/mod.rs b/crates/tinytools-std/src/filesystem/list_files/mod.rs index 1a2172a..fe639a1 100644 --- a/crates/tinytools-std/src/filesystem/list_files/mod.rs +++ b/crates/tinytools-std/src/filesystem/list_files/mod.rs @@ -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}; @@ -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)) => { @@ -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; } } @@ -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)) } } From 95e531b5b64b3be612d27b3f81f9440b4c6d2ac1 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Fri, 23 Oct 2026 04:57:04 +0530 Subject: [PATCH 2/2] test(list_files): use an inclusive range --- crates/tinytools-std/src/filesystem/list_files/extra_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinytools-std/src/filesystem/list_files/extra_tests.rs b/crates/tinytools-std/src/filesystem/list_files/extra_tests.rs index 4bb44c8..1da5492 100644 --- a/crates/tinytools-std/src/filesystem/list_files/extra_tests.rs +++ b/crates/tinytools-std/src/filesystem/list_files/extra_tests.rs @@ -54,7 +54,7 @@ async fn listing_stops_at_the_entry_cap() { #[tokio::test] async fn a_capped_listing_says_it_was_truncated() { let dir = TempDir::new().unwrap(); - for i in 0..(MAX_ENTRIES + 1) { + 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()));