From 91ddcb43d1e24d24e430389753533df4292c704a Mon Sep 17 00:00:00 2001 From: Joseph Malone Date: Sun, 14 Jun 2026 08:36:29 -0700 Subject: [PATCH 1/7] fix(providers): detect image paths containing spaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit detect_image_path split on whitespace, so an absolute path containing spaces (a macOS screenshot like /…/Screen Shot 2026.png) was never matched. Anchor on each image-extension occurrence instead and walk back over '/'-rooted starts, returning the longest candidate that is an existing image file; the backward scan is bounded to avoid quadratic work on extension-heavy text. Extension matching is now case-insensitive. Existing behavior (relative/fake/nonexistent) is preserved; adds spaces + flood regression tests. Signed-off-by: Joseph Malone --- crates/goose-providers/src/images.rs | 84 +++++++++++++++++++++++----- 1 file changed, 70 insertions(+), 14 deletions(-) diff --git a/crates/goose-providers/src/images.rs b/crates/goose-providers/src/images.rs index ebbfbd210ee9..196afcdf17dc 100644 --- a/crates/goose-providers/src/images.rs +++ b/crates/goose-providers/src/images.rs @@ -33,30 +33,58 @@ pub fn convert_image(image: &ImageContent, image_format: &ImageFormat) -> Value } } -/// Detect if a string contains a path to an image file +/// Detect if a string contains a path to an image file. +/// +/// Absolute paths can contain spaces (e.g. macOS screenshots like +/// `/…/Screen Shot 2026.png`), so rather than splitting on whitespace we anchor +/// on each image-extension occurrence and walk back over `/`-rooted starts, +/// returning the longest candidate that is an existing image file. The backward +/// scan is bounded so extension-heavy text can't cause quadratic work. pub fn detect_image_path(text: &str) -> Option<&str> { - // Basic image file extension check - let extensions = [".png", ".jpg", ".jpeg"]; + const EXTENSIONS: [&str; 3] = [".png", ".jpg", ".jpeg"]; + const MAX_PATH_LEN: usize = 4096; - // Find any word that ends with an image extension - for word in text.split_whitespace() { - if extensions + let mut from = 0; + while from < text.len() { + let Some(end) = EXTENSIONS .iter() - .any(|ext| word.to_lowercase().ends_with(ext)) - { - let path = Path::new(word); - // Check if it's an absolute path and file exists - if path.is_absolute() && path.is_file() { - // Verify it's actually an image file - if is_image_file(path) { - return Some(word); + .filter_map(|ext| find_ascii_ci(text, ext, from).map(|i| i + ext.len())) + .min() + else { + break; + }; + + let mut floor = end.saturating_sub(MAX_PATH_LEN); + while floor < end && !text.is_char_boundary(floor) { + floor += 1; + } + if let Some(window) = text.get(floor..end) { + for (rel, _) in window.match_indices('/') { + let Some(candidate) = text.get(floor + rel..end) else { + continue; + }; + let path = Path::new(candidate); + if path.is_absolute() && path.is_file() && is_image_file(path) { + return Some(candidate); } } } + from = end; } None } +/// Case-insensitive ASCII substring search returning a byte index into +/// `haystack` (no allocation, so the index stays valid for slicing). +fn find_ascii_ci(haystack: &str, needle: &str, from: usize) -> Option { + let (hb, nb) = (haystack.as_bytes(), needle.as_bytes()); + if nb.is_empty() || hb.len() < nb.len() || from > hb.len() - nb.len() { + return None; + } + (from..=hb.len() - nb.len()) + .find(|&i| hb[i..i + nb.len()].iter().zip(nb).all(|(a, b)| a.eq_ignore_ascii_case(b))) +} + /// Check if a file is actually an image by examining its magic bytes fn is_image_file(path: &Path) -> bool { if let Ok(mut file) = std::fs::File::open(path) { @@ -164,6 +192,34 @@ mod tests { assert_eq!(detect_image_path(text), None); } + #[test] + fn test_detect_image_path_with_spaces() { + // Absolute path containing spaces (macOS screenshot style). + let temp_dir = tempfile::tempdir().unwrap(); + let png_path = temp_dir.path().join("Screen Shot 2026.png"); + let png_data = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; + std::fs::write(&png_path, png_data).unwrap(); + let png_path_str = png_path.to_str().unwrap(); + + let text = format!("please describe {} for me", png_path_str); + assert_eq!(detect_image_path(&text), Some(png_path_str)); + + // Case-insensitive extension also matches. + let upper = temp_dir.path().join("Another Shot.PNG"); + std::fs::write(&upper, png_data).unwrap(); + let upper_str = upper.to_str().unwrap(); + let text = format!("see {}", upper_str); + assert_eq!(detect_image_path(&text), Some(upper_str)); + } + + #[test] + fn test_detect_image_path_ignores_extension_flood() { + // Many extension-like tokens but no real absolute path: must scan + // cheaply (bounded) and find nothing. + let text = "see foo.png and bar.jpg and baz.jpeg ".repeat(500); + assert_eq!(detect_image_path(&text), None); + } + #[test] fn test_load_image_file() { // Create a temporary PNG file with valid PNG magic numbers From d7912b812c8e9b7c54a9701f40832c8720920696 Mon Sep 17 00:00:00 2001 From: Douwe M Osinga Date: Mon, 15 Jun 2026 10:56:10 -0400 Subject: [PATCH 2/7] fix(providers): guard image-path boundaries against URLs and longer extensions The simplified backward-scan reintroduced two boundary regressions: a path that is a suffix of a URL (https://host/x.png) could be extracted via the '://' separator, and a backup file (/tmp/x.png.backup) could be truncated to the bare image path. Require the extension to terminate the candidate and the leading '/' to follow a whitespace/quote boundary, and add regression tests. Signed-off-by: Douwe M Osinga --- crates/goose-providers/src/images.rs | 73 ++++++++++++++++++++++------ 1 file changed, 59 insertions(+), 14 deletions(-) diff --git a/crates/goose-providers/src/images.rs b/crates/goose-providers/src/images.rs index 196afcdf17dc..9ab34e1b430f 100644 --- a/crates/goose-providers/src/images.rs +++ b/crates/goose-providers/src/images.rs @@ -40,6 +40,11 @@ pub fn convert_image(image: &ImageContent, image_format: &ImageFormat) -> Value /// on each image-extension occurrence and walk back over `/`-rooted starts, /// returning the longest candidate that is an existing image file. The backward /// scan is bounded so extension-heavy text can't cause quadratic work. +/// +/// The extension must terminate the candidate (so `/tmp/foo.png.backup` is not +/// truncated to `/tmp/foo.png`) and the leading `/` must follow a whitespace or +/// quote boundary (so a `://` in a URL like `https://host/x.png` is not mistaken +/// for an absolute path). pub fn detect_image_path(text: &str) -> Option<&str> { const EXTENSIONS: [&str; 3] = [".png", ".jpg", ".jpeg"]; const MAX_PATH_LEN: usize = 4096; @@ -54,18 +59,33 @@ pub fn detect_image_path(text: &str) -> Option<&str> { break; }; - let mut floor = end.saturating_sub(MAX_PATH_LEN); - while floor < end && !text.is_char_boundary(floor) { - floor += 1; - } - if let Some(window) = text.get(floor..end) { - for (rel, _) in window.match_indices('/') { - let Some(candidate) = text.get(floor + rel..end) else { - continue; - }; - let path = Path::new(candidate); - if path.is_absolute() && path.is_file() && is_image_file(path) { - return Some(candidate); + let terminated = text + .get(end..) + .and_then(|rest| rest.chars().next()) + .is_none_or(|c| c == '/' || c.is_whitespace()); + + if terminated { + let mut floor = end.saturating_sub(MAX_PATH_LEN); + while floor < end && !text.is_char_boundary(floor) { + floor += 1; + } + if let Some(window) = text.get(floor..end) { + for (rel, _) in window.match_indices('/') { + let start = floor + rel; + let preceded_by_boundary = text + .get(..start) + .and_then(|prefix| prefix.chars().next_back()) + .is_none_or(|c| c.is_whitespace() || c == '"' || c == '\''); + if !preceded_by_boundary { + continue; + } + let Some(candidate) = text.get(start..end) else { + continue; + }; + let path = Path::new(candidate); + if path.is_absolute() && path.is_file() && is_image_file(path) { + return Some(candidate); + } } } } @@ -81,8 +101,12 @@ fn find_ascii_ci(haystack: &str, needle: &str, from: usize) -> Option { if nb.is_empty() || hb.len() < nb.len() || from > hb.len() - nb.len() { return None; } - (from..=hb.len() - nb.len()) - .find(|&i| hb[i..i + nb.len()].iter().zip(nb).all(|(a, b)| a.eq_ignore_ascii_case(b))) + (from..=hb.len() - nb.len()).find(|&i| { + hb[i..i + nb.len()] + .iter() + .zip(nb) + .all(|(a, b)| a.eq_ignore_ascii_case(b)) + }) } /// Check if a file is actually an image by examining its magic bytes @@ -212,6 +236,27 @@ mod tests { assert_eq!(detect_image_path(&text), Some(upper_str)); } + #[test] + fn test_detect_image_path_ignores_urls_and_longer_extensions() { + let temp_dir = tempfile::tempdir().unwrap(); + let png_data = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; + + // A real image whose path is a suffix of a URL must not be extracted + // from that URL via the `://` separator. + let dir = temp_dir.path().to_str().unwrap().trim_start_matches('/'); + let png_path = temp_dir.path().join("photo.png"); + std::fs::write(&png_path, png_data).unwrap(); + let url = format!("https:/{}/photo.png", dir); + assert_eq!(detect_image_path(&url), None); + + // A backup file sharing the image extension prefix must not be + // truncated to the bare image path. + let real = temp_dir.path().join("shot.png"); + std::fs::write(&real, png_data).unwrap(); + let backup = format!("{}.backup", real.to_str().unwrap()); + assert_eq!(detect_image_path(&backup), None); + } + #[test] fn test_detect_image_path_ignores_extension_flood() { // Many extension-like tokens but no real absolute path: must scan From a8aea934b2cd899ce1f1116809a006971ac44686 Mon Sep 17 00:00:00 2001 From: Douwe M Osinga Date: Mon, 15 Jun 2026 11:23:11 -0400 Subject: [PATCH 3/7] fix(providers): accept matching quotes as image-path terminators A quoted path like "/tmp/Screen Shot.png" was silently ignored because the closing quote was not a valid terminator after the extension, defeating the whole point of handling space-containing paths. Treat quote characters as terminators alongside whitespace and '/', and add regression tests for quoted paths with spaces. Signed-off-by: Douwe M Osinga --- crates/goose-providers/src/images.rs | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/crates/goose-providers/src/images.rs b/crates/goose-providers/src/images.rs index 9ab34e1b430f..d43483cea242 100644 --- a/crates/goose-providers/src/images.rs +++ b/crates/goose-providers/src/images.rs @@ -44,7 +44,8 @@ pub fn convert_image(image: &ImageContent, image_format: &ImageFormat) -> Value /// The extension must terminate the candidate (so `/tmp/foo.png.backup` is not /// truncated to `/tmp/foo.png`) and the leading `/` must follow a whitespace or /// quote boundary (so a `://` in a URL like `https://host/x.png` is not mistaken -/// for an absolute path). +/// for an absolute path). A path may be wrapped in matching quotes +/// (`"/tmp/Screen Shot.png"`), in which case the closing quote terminates it. pub fn detect_image_path(text: &str) -> Option<&str> { const EXTENSIONS: [&str; 3] = [".png", ".jpg", ".jpeg"]; const MAX_PATH_LEN: usize = 4096; @@ -59,10 +60,9 @@ pub fn detect_image_path(text: &str) -> Option<&str> { break; }; - let terminated = text - .get(end..) - .and_then(|rest| rest.chars().next()) - .is_none_or(|c| c == '/' || c.is_whitespace()); + let terminator = text.get(end..).and_then(|rest| rest.chars().next()); + let terminated = + terminator.is_none_or(|c| c == '/' || c.is_whitespace() || c == '"' || c == '\''); if terminated { let mut floor = end.saturating_sub(MAX_PATH_LEN); @@ -234,6 +234,17 @@ mod tests { let upper_str = upper.to_str().unwrap(); let text = format!("see {}", upper_str); assert_eq!(detect_image_path(&text), Some(upper_str)); + + // Quoted path with spaces: the closing quote terminates the candidate. + let text = format!("describe \"{}\" please", png_path_str); + assert_eq!(detect_image_path(&text), Some(png_path_str)); + let text = format!("describe '{}'", png_path_str); + assert_eq!(detect_image_path(&text), Some(png_path_str)); + + // A stray closing quote in prose must not act as a terminator for an + // unquoted path. + let text = format!("here {}\" trailing", png_path_str); + assert_eq!(detect_image_path(&text), Some(png_path_str)); } #[test] From 1bda01f70cc440c65b5665a136037da7c73ade81 Mon Sep 17 00:00:00 2001 From: Douwe M Osinga Date: Mon, 15 Jun 2026 11:36:24 -0400 Subject: [PATCH 4/7] docs(providers): condense detect_image_path doc comment Signed-off-by: Douwe M Osinga --- crates/goose-providers/src/images.rs | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/crates/goose-providers/src/images.rs b/crates/goose-providers/src/images.rs index d43483cea242..fdb1c3a63dba 100644 --- a/crates/goose-providers/src/images.rs +++ b/crates/goose-providers/src/images.rs @@ -33,19 +33,11 @@ pub fn convert_image(image: &ImageContent, image_format: &ImageFormat) -> Value } } -/// Detect if a string contains a path to an image file. +/// Detect an absolute path to an existing image file within `text`. /// -/// Absolute paths can contain spaces (e.g. macOS screenshots like -/// `/…/Screen Shot 2026.png`), so rather than splitting on whitespace we anchor -/// on each image-extension occurrence and walk back over `/`-rooted starts, -/// returning the longest candidate that is an existing image file. The backward -/// scan is bounded so extension-heavy text can't cause quadratic work. -/// -/// The extension must terminate the candidate (so `/tmp/foo.png.backup` is not -/// truncated to `/tmp/foo.png`) and the leading `/` must follow a whitespace or -/// quote boundary (so a `://` in a URL like `https://host/x.png` is not mistaken -/// for an absolute path). A path may be wrapped in matching quotes -/// (`"/tmp/Screen Shot.png"`), in which case the closing quote terminates it. +/// Anchors on each image extension and walks back to a `/`-rooted, boundary- +/// delimited start so paths containing spaces (e.g. macOS screenshots) are +/// detected without being confused by URLs or longer extensions. pub fn detect_image_path(text: &str) -> Option<&str> { const EXTENSIONS: [&str; 3] = [".png", ".jpg", ".jpeg"]; const MAX_PATH_LEN: usize = 4096; From 27d2278252240dab6c7f064c4e27c5cd2388e2c4 Mon Sep 17 00:00:00 2001 From: Douwe M Osinga Date: Mon, 15 Jun 2026 14:47:04 -0400 Subject: [PATCH 5/7] docs(providers): drop detect_image_path doc comment Signed-off-by: Douwe M Osinga --- crates/goose-providers/src/images.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/crates/goose-providers/src/images.rs b/crates/goose-providers/src/images.rs index fdb1c3a63dba..716365030c5c 100644 --- a/crates/goose-providers/src/images.rs +++ b/crates/goose-providers/src/images.rs @@ -33,11 +33,6 @@ pub fn convert_image(image: &ImageContent, image_format: &ImageFormat) -> Value } } -/// Detect an absolute path to an existing image file within `text`. -/// -/// Anchors on each image extension and walks back to a `/`-rooted, boundary- -/// delimited start so paths containing spaces (e.g. macOS screenshots) are -/// detected without being confused by URLs or longer extensions. pub fn detect_image_path(text: &str) -> Option<&str> { const EXTENSIONS: [&str; 3] = [".png", ".jpg", ".jpeg"]; const MAX_PATH_LEN: usize = 4096; From 066dc83abaf15fd9e5648ef88a7235e0c2533a66 Mon Sep 17 00:00:00 2001 From: Douwe M Osinga Date: Mon, 15 Jun 2026 15:47:22 -0400 Subject: [PATCH 6/7] fix(providers): prefer longest existing image path on whitespace boundary A whitespace-terminated extension is ambiguous because the filename may continue to a later extension (e.g. "Screen Shot.png edited.jpg"). Instead of returning the first existing prefix, keep scanning and return the longest existing candidate. Signed-off-by: Douwe M Osinga --- crates/goose-providers/src/images.rs | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/crates/goose-providers/src/images.rs b/crates/goose-providers/src/images.rs index 716365030c5c..4496dcf90e8d 100644 --- a/crates/goose-providers/src/images.rs +++ b/crates/goose-providers/src/images.rs @@ -37,6 +37,7 @@ pub fn detect_image_path(text: &str) -> Option<&str> { const EXTENSIONS: [&str; 3] = [".png", ".jpg", ".jpeg"]; const MAX_PATH_LEN: usize = 4096; + let mut best: Option<&str> = None; let mut from = 0; while from < text.len() { let Some(end) = EXTENSIONS @@ -71,14 +72,20 @@ pub fn detect_image_path(text: &str) -> Option<&str> { }; let path = Path::new(candidate); if path.is_absolute() && path.is_file() && is_image_file(path) { - return Some(candidate); + // A whitespace-terminated extension is ambiguous: the + // filename may continue to a later extension, so keep + // the longest existing match rather than the first. + if best.is_none_or(|b| candidate.len() > b.len()) { + best = Some(candidate); + } + break; } } } } from = end; } - None + best } /// Case-insensitive ASCII substring search returning a byte index into @@ -232,6 +239,16 @@ mod tests { // unquoted path. let text = format!("here {}\" trailing", png_path_str); assert_eq!(detect_image_path(&text), Some(png_path_str)); + + // When a spaced filename contains an earlier image extension, prefer + // the longer existing candidate over the embedded prefix. + let edited = temp_dir.path().join("Screen Shot.png edited.jpg"); + std::fs::write(&edited, png_data).unwrap(); + let edited_str = edited.to_str().unwrap(); + let prefix = temp_dir.path().join("Screen Shot.png"); + std::fs::write(&prefix, png_data).unwrap(); + let text = format!("look at {}", edited_str); + assert_eq!(detect_image_path(&text), Some(edited_str)); } #[test] From 9db6e0fab40a80711bf9a7fd51d90c1a216389e1 Mon Sep 17 00:00:00 2001 From: Douwe M Osinga Date: Mon, 15 Jun 2026 16:14:24 -0400 Subject: [PATCH 7/7] fix(providers): keep first referenced image path across distinct candidates The longest-wins rule must only apply when a longer match extends the same start (a spaced filename whose earlier extension is a prefix). Across distinct paths, preserve the first referenced one to match the prior scan-order semantics rather than attaching a later, longer path. Signed-off-by: Douwe M Osinga --- crates/goose-providers/src/images.rs | 32 ++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/crates/goose-providers/src/images.rs b/crates/goose-providers/src/images.rs index 4496dcf90e8d..1f194862fcc3 100644 --- a/crates/goose-providers/src/images.rs +++ b/crates/goose-providers/src/images.rs @@ -37,7 +37,7 @@ pub fn detect_image_path(text: &str) -> Option<&str> { const EXTENSIONS: [&str; 3] = [".png", ".jpg", ".jpeg"]; const MAX_PATH_LEN: usize = 4096; - let mut best: Option<&str> = None; + let mut best: Option<(usize, &str)> = None; let mut from = 0; while from < text.len() { let Some(end) = EXTENSIONS @@ -72,11 +72,16 @@ pub fn detect_image_path(text: &str) -> Option<&str> { }; let path = Path::new(candidate); if path.is_absolute() && path.is_file() && is_image_file(path) { - // A whitespace-terminated extension is ambiguous: the - // filename may continue to a later extension, so keep - // the longest existing match rather than the first. - if best.is_none_or(|b| candidate.len() > b.len()) { - best = Some(candidate); + // Keep the first referenced path, but allow a longer + // match anchored at the same start to extend it (a + // whitespace-terminated extension may be a prefix of a + // spaced filename ending in a later extension). + match best { + Some((best_start, _)) if start == best_start => { + best = Some((start, candidate)); + } + None => best = Some((start, candidate)), + Some(_) => {} } break; } @@ -85,7 +90,7 @@ pub fn detect_image_path(text: &str) -> Option<&str> { } from = end; } - best + best.map(|(_, candidate)| candidate) } /// Case-insensitive ASCII substring search returning a byte index into @@ -249,6 +254,19 @@ mod tests { std::fs::write(&prefix, png_data).unwrap(); let text = format!("look at {}", edited_str); assert_eq!(detect_image_path(&text), Some(edited_str)); + + // With multiple distinct images, the first referenced one wins even if + // a later one has a longer path. + let a = temp_dir.path().join("a.png"); + std::fs::write(&a, png_data).unwrap(); + let longer = temp_dir.path().join("much-longer.png"); + std::fs::write(&longer, png_data).unwrap(); + let text = format!( + "compare {} with {}", + a.to_str().unwrap(), + longer.to_str().unwrap() + ); + assert_eq!(detect_image_path(&text), Some(a.to_str().unwrap())); } #[test]