grok-build-upstream-mirror/crates/codegen/xai-grok-shared/src/placeholder_images.rs
grokkybara[bot] c68e39f604 Publish harness and TUI open-source
initial sync from the monorepo
2026-07-16 06:46:02 +01:00

1526 lines
65 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Shared helper for resolving `[Image #N: <path>]` placeholders into
//! image bytes.
//!
//! Both the TUI ([`xai_grok_pager::prompt_images`]) and the server-side
//! ingestion path ([`crate::session::acp_session`]) need to recover image
//! bytes when a placeholder lacks an attached `PastedImage` /
//! `ContentBlock::Image` — e.g. a paste from a previous session's
//! prompt, a session reload, or a synthetic re-render. The two sides
//! share one canonical loader so the validation rules (extension
//! allowlist, prefix allowlist, size cap, image-decoder validation,
//! aggregate-bytes cap) cannot drift.
//!
//! ## Threat model
//!
//! The placeholder path is **user-controlled** but the user does not
//! explicitly opt in to reading any arbitrary file — they paste a chat
//! transcript fragment and the agent may resurrect it across sessions.
//! To stop the placeholder mechanism from becoming a generic file
//! exfiltration sink, the loader is intentionally conservative:
//!
//! * Canonicalises every candidate path (resolves `..` and symlinks).
//! * Asserts the canonical target lives under an explicit prefix
//! allowlist (workspace cwd + a small set of common user-image
//! directories under `$HOME`; never the whole `$HOME`). See
//! [`default_allowed_prefixes`].
//! * Asserts the extension is in [`ALLOWED_IMAGE_EXTENSIONS`].
//! * Routes the bytes through the `image` crate's full header parser
//! ([`image::ImageReader::with_guessed_format`] +
//! `into_dimensions`) so magic-byte forgery — a PNG-prefix file
//! followed by arbitrary content — is rejected. See
//! [`PlaceholderLoadError::NotAnImage`].
//! * Rejects any canonical path containing a known sensitive-bundle
//! subtree (`.photoslibrary/`, `.musiclibrary/`, etc.) even when the
//! parent prefix is in the allowlist.
//! * Enforces a per-image byte cap, a per-prompt placeholder count
//! cap, and a per-prompt aggregate-bytes cap so a single prompt
//! cannot trigger huge sequential syscall chains or memory spikes.
//!
//! Wire format: `[Image #<n>: <absolute_path>]` — the producer is
//! [`xai_grok_pager::prompt_images::display_text`]. The shape of this
//! placeholder is part of the chat-history contract — do NOT change
//! it. The regex requires the literal `": "` separator that the
//! producer always emits; see [`extract_placeholders`].
//!
//! ## `file://` URI convention
//!
//! Both the TUI's `prompt_images::build_content_blocks_with_workspace`
//! and the server-side recovery in [`recover_orphan_placeholders`]
//! emit `file://{canonical.display()}` URIs **without** percent-encoding
//! the path. This deviates from RFC 3986 (a path with spaces should be
//! `%20`-encoded) but it is internally consistent across producer and
//! consumer: [`canonical_from_file_uri`] parses inbound URIs using both
//! the relaxed unencoded form **and** percent-decoded form so dedup
//! works against either convention. Do **not** add percent-encoding on
//! one side without also doing it on the other — past-issue: producer
//! / consumer asymmetry breaks dedup.
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use regex::Regex;
/// Maximum size of a single placeholder-loaded image. Matches the TUI's
/// `MAX_SEND_BYTES` (50 MB) so a path that loads on the TUI side cannot
/// be silently rejected by the server-side fallback.
pub const MAX_PLACEHOLDER_IMAGE_BYTES: usize = 50_000_000;
/// Maximum number of placeholders the loader will process per call.
/// Caps **on-disk loads** at 16 per prompt; the regex scan itself is
/// unbounded but linear in input length and short-circuits via
/// [`Iterator::take`] before `filter_map` runs.
pub const MAX_PLACEHOLDERS_PER_PROMPT: usize = 16;
/// Per-prompt aggregate-bytes cap across recovered placeholder images.
/// Prevents 16 × 50 MB worst-case RSS spikes on memory-constrained
/// runners.
pub const MAX_PLACEHOLDER_AGGREGATE_BYTES: usize = 200 * 1024 * 1024;
/// `_meta` key under which an attached image's `[Image #N]` display number
/// is recorded on its ACP image block, so the server can resolve
/// `[Image #N]` tokens to the right attachment by number rather than list
/// position (the two diverge — see `AttachedImages` in `xai-grok-tools`).
pub const IMAGE_DISPLAY_NUMBER_META_KEY: &str = "xai.dev/imageDisplayNumber";
/// Build an ACP image-block `_meta` value carrying `display_number` under
/// [`IMAGE_DISPLAY_NUMBER_META_KEY`].
pub fn display_number_meta(display_number: usize) -> agent_client_protocol::Meta {
let mut meta = agent_client_protocol::Meta::new();
meta.insert(
IMAGE_DISPLAY_NUMBER_META_KEY.to_owned(),
serde_json::json!(display_number),
);
meta
}
/// Read the `[Image #N]` display number recorded in an image block's
/// `_meta`, if present.
pub fn display_number_from_meta(meta: Option<&agent_client_protocol::Meta>) -> Option<usize> {
meta?
.get(IMAGE_DISPLAY_NUMBER_META_KEY)?
.as_u64()
.and_then(|n| usize::try_from(n).ok())
}
/// Build the per-turn `[Image #N]` → reference registry (see
/// [`AttachedImages`](xai_grok_tools::types::resources::AttachedImages))
/// from the user's inline attached images.
///
/// The display number comes from each block's `_meta` (set by the TUI),
/// falling back to 1-based position for callers that don't record it. The
/// reference is one `image_edit`'s resolver can read directly: the bare
/// durable path (from the `file://` URI) when present, else a
/// `data:<mime>;base64,<data>` URL.
pub fn attached_image_references(
images: &[agent_client_protocol::ImageContent],
) -> Vec<(usize, String)> {
images
.iter()
.enumerate()
.map(|(idx, image)| {
let display_number = display_number_from_meta(image.meta.as_ref()).unwrap_or(idx + 1);
let reference =
if let Some(path) = image.uri.as_deref().and_then(|u| u.strip_prefix("file://")) {
path.to_owned()
} else {
format!("data:{};base64,{}", image.mime_type, image.data)
};
(display_number, reference)
})
.collect()
}
/// File extensions accepted by the placeholder loader.
///
/// SVG is intentionally **not** in this list: SVG is XML text with no
/// reliable magic-byte signature, and adding it would expand the attack
/// surface (script tags, XXE) without a corresponding image-decoder
/// validation pass. Any future SVG support must be gated by a script
/// attack-surface review.
pub const ALLOWED_IMAGE_EXTENSIONS: &[&str] =
&["png", "jpg", "jpeg", "gif", "webp", "bmp", "tiff", "tif"];
/// Substrings that, if present anywhere in a canonical path, deny the
/// load even when the parent prefix is in the allowlist. Covers macOS
/// bundle subtrees the user did not explicitly opt in to sharing
/// (`~/Pictures/X.photoslibrary/originals/...`), Trash, and Keychain
/// bundles.
///
/// **Platform contract.** Each needle uses forward-slash separators
/// and is matched case-sensitively against the canonical path. The
/// enforcement site
/// ([`load_canonical_placeholder_image`]) normalises `\` → `/` before
/// the substring check, so Windows paths are covered. macOS HFS+
/// volumes (case-insensitive by default) and case-sensitive APFS both
/// hit the case-sensitive match — every entry in this list is a
/// system-emitted name and is case-stable in practice. If a future
/// entry depends on user-typed casing, add a `to_ascii_lowercase` step
/// at both sites.
pub const DENY_PATH_CONTAINS: &[&str] = &[
".photoslibrary/",
".musiclibrary/",
".imovielibrary/",
"/.Trash/",
"/Library/Keychains/",
"/Library/Containers/",
"/.ssh/",
"/.aws/",
"/.gnupg/",
];
/// Compiled regex matching the TUI placeholder format
/// `[Image #<digits>: <path>]`.
///
/// * Producer emits exactly `": "` (colon, single space) as the
/// separator — see [`xai_grok_pager::prompt_images::display_text`].
/// The regex requires the same; a path token like `[Image #5:foo]`
/// does **not** match.
/// * The path capture excludes `]`, `\n`, and `\r` so the match
/// terminates cleanly at the placeholder boundary even on
/// Windows-style line endings or path strings containing other
/// bracket forms.
/// * Path captures may contain spaces (typical macOS paths in
/// `~/My Pictures`).
static IMAGE_PLACEHOLDER_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"\[Image #(\d+): ([^\]\r\n]+?)\]").expect("placeholder regex is valid")
});
/// One occurrence of `[Image #N: <path>]` in arbitrary text.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PlaceholderMatch {
/// The `N` in `[Image #N: …]` (1-based per the TUI's convention).
pub display_number: usize,
/// The raw path string as it appeared in the text (no canonicalisation).
pub path: String,
/// Byte offsets into the source text covered by the full
/// `[Image #N: …]` match — `text[start..end]` is the placeholder.
pub span: (usize, usize),
}
/// Scan `text` for every well-formed placeholder.
///
/// Malformed forms (`[Image #3]`, `[Image #5: ]`, truncated, etc.) are
/// skipped without failing the whole scan. The regex iterator is
/// short-circuited via [`Iterator::take`] **before** `filter_map`, so a
/// pathological prompt with 100 000 placeholders does not consume
/// 100 000 captures — at most [`MAX_PLACEHOLDERS_PER_PROMPT`] are
/// inspected. Trade-off: a prompt with one invalid placeholder among
/// 16 valid ones may yield 15 results.
pub fn extract_placeholders(text: &str) -> Vec<PlaceholderMatch> {
IMAGE_PLACEHOLDER_RE
.captures_iter(text)
.take(MAX_PLACEHOLDERS_PER_PROMPT)
.filter_map(|cap| {
let whole = cap.get(0)?;
let n = cap.get(1)?.as_str().parse::<usize>().ok()?;
let path = cap.get(2)?.as_str().trim();
if path.is_empty() {
return None;
}
Some(PlaceholderMatch {
display_number: n,
path: path.to_owned(),
span: (whole.start(), whole.end()),
})
})
.collect()
}
/// Rewrites every `[Image #N: <path>]` placeholder in `text` to the
/// shorter `[Image #N]` form, dropping the path component.
///
/// Run **after** the orphan-recovery pipeline has finished extracting
/// paths it needs to load. Once the image is attached inline the path
/// is redundant *and harmful*: the model treats it as a hint and may
/// call the `Read` tool on the path even though the bytes are already
/// in context. The bracketed anchor `[Image #N]` is preserved so the
/// model can still tell where in the prose the image was referenced.
///
/// Takes `String` by value so the common no-placeholder case returns
/// the input unchanged with zero allocations.
///
/// The scan is bounded by [`MAX_PLACEHOLDERS_PER_PROMPT`]; any extra
/// placeholders past the cap are left in their original form.
pub fn strip_paths_from_image_placeholders(text: String) -> String {
use std::fmt::Write as _;
// Fast path: probe with `is_match` (no `Captures` allocation) and
// return the owned input unchanged when there is nothing to do.
if !IMAGE_PLACEHOLDER_RE.is_match(&text) {
return text;
}
let mut out = String::with_capacity(text.len());
let mut last = 0usize;
for cap in IMAGE_PLACEHOLDER_RE
.captures_iter(&text)
.take(MAX_PLACEHOLDERS_PER_PROMPT)
{
// Group 0 is the full match and group 1 is `(\d+)` — both are
// structurally guaranteed by the regex.
let whole = cap.get(0).expect("regex match always has group 0");
let n = cap.get(1).expect("regex always has group 1").as_str();
out.push_str(&text[last..whole.start()]);
// `write!` to a String is infallible.
let _ = write!(out, "[Image #{n}]");
last = whole.end();
}
out.push_str(&text[last..]);
out
}
/// Loaded image bytes plus a sniffed MIME type.
#[derive(Debug, Clone)]
pub struct LoadedPlaceholderImage {
/// Raw image bytes read from disk. Ownership transferred to the
/// caller so it can be base64-encoded or moved into a
/// `ContentBlock::Image` without an intermediate clone.
pub data: Vec<u8>,
/// MIME type derived from the `image` crate's full header parser
/// ([`image::ImageReader::with_guessed_format`] +
/// `into_dimensions`). Always one of `image/png`, `image/jpeg`,
/// `image/gif`, `image/webp`, `image/bmp`, `image/tiff`.
pub mime_type: String,
}
/// Why a placeholder load was rejected.
///
/// The error variants are deliberately coarse-grained: an attacker with
/// log access should not be able to probe the filesystem by reading
/// distinct error messages. In particular, no variant carries the raw
/// `io::Error` string; an `io::ErrorKind` is retained where useful but
/// renderer-side messages stay generic.
#[derive(Debug, thiserror::Error)]
pub enum PlaceholderLoadError {
/// Resolved path is outside every entry in the prefix allowlist
/// (or matches a [`DENY_PATH_CONTAINS`] entry inside an allowed
/// prefix).
///
/// Returned **before** any further I/O so a path like `/etc/passwd`
/// returns this variant rather than leaking that `/etc/passwd`
/// exists.
#[error("path is outside allowed prefixes")]
OutsideAllowedPrefixes,
/// Path could not be canonicalised (missing, permission denied,
/// etc.). We never include the raw filesystem error to avoid log
/// probing.
#[error("path does not resolve")]
CanonicalizeFailed,
/// Extension is not in [`ALLOWED_IMAGE_EXTENSIONS`].
#[error("unsupported image extension")]
UnsupportedExtension,
/// Resolved path is not a regular file (e.g. directory, FIFO).
#[error("path is not a regular file")]
NotAFile,
/// `std::fs::read` failed after canonicalisation. The variant
/// retains only the `io::ErrorKind`, not the verbose message.
#[error("read failed: {0:?}")]
ReadFailed(std::io::ErrorKind),
/// File exceeds the configured per-image byte cap.
#[error("file is {actual} bytes, exceeds {limit}-byte cap")]
TooLarge { actual: usize, limit: usize },
/// Bytes do not decode as a supported image. Routed through
/// [`image::ImageReader::with_guessed_format`] +
/// `into_dimensions`, so a file with PNG magic bytes followed by
/// arbitrary content is rejected here.
#[error("bytes do not decode as a supported image")]
NotAnImage,
}
/// Build the canonical prefix allowlist with the workspace cwd plus a
/// small, intentional set of common user-image directories under
/// `$HOME`.
///
/// The list is canonicalised up-front so prefix checks against
/// canonical resolved paths work. Non-canonical paths are **never**
/// appended — if `dunce::canonicalize(workspace_cwd)` fails (transient
/// permission, missing dir), the workspace prefix is dropped entirely.
///
/// `$HOME` itself is **not** an allowed prefix: that would let
/// arbitrary placeholder paths under `~/.ssh`, `~/.aws`, `~/.config`,
/// etc. be exfil-able. Instead, only the typical user-paste image
/// directories are added.
pub fn default_allowed_prefixes(workspace_cwd: &Path) -> Vec<PathBuf> {
default_allowed_prefixes_with_home(workspace_cwd, dirs::home_dir())
}
/// Test-injectable variant of [`default_allowed_prefixes`]. Production
/// code should call [`default_allowed_prefixes`]; tests pass an
/// explicit `home` so they don't depend on the ambient `$HOME`.
///
/// The returned `Vec` is canonical and deduplicated; **ordering is
/// alphabetical by OS path, not insertion order**. The prefix check in
/// [`load_placeholder_image`] uses `starts_with`, so order is
/// functionally irrelevant.
pub fn default_allowed_prefixes_with_home(
workspace_cwd: &Path,
home: Option<PathBuf>,
) -> Vec<PathBuf> {
let mut prefixes: Vec<PathBuf> = Vec::new();
match dunce::canonicalize(workspace_cwd) {
Ok(canon) => prefixes.push(canon),
Err(e) => {
tracing::warn!(
workspace_cwd = ?workspace_cwd,
error_kind = ?e.kind(),
"placeholder_images: workspace cwd does not canonicalize; dropping from allowlist",
);
}
}
if let Some(home) = home {
for sub in HOME_IMAGE_SUBDIRS {
if let Ok(canon) = dunce::canonicalize(home.join(sub)) {
prefixes.push(canon);
}
}
}
prefixes.sort();
prefixes.dedup();
prefixes
}
/// Subdirectories under `$HOME` that are part of the default allowlist.
///
/// Chosen to match the directories users actually paste images from in
/// practice. Sensitive subtrees (`~/.ssh`, `~/.aws`, `~/.config`,
/// `~/.gnupg`, `~/Library/Keychains`) are intentionally excluded — they
/// are never added to the prefix list, and any path resolving into
/// [`DENY_PATH_CONTAINS`] is rejected even from inside an allowed
/// prefix.
pub const HOME_IMAGE_SUBDIRS: &[&str] = &[
"Downloads",
"Desktop",
"Pictures",
"Documents",
"Screenshots",
];
/// Resolve and validate `path_str`, then read the file.
///
/// Validation order is **prefix-first** by design: an out-of-allowlist
/// path returns [`PlaceholderLoadError::OutsideAllowedPrefixes`]
/// regardless of whether the file exists, the extension is recognised,
/// or the bytes look like an image. This prevents log-level
/// information disclosure (an attacker with read access to telemetry
/// can no longer distinguish "file exists but is outside scope" from
/// "file does not exist").
///
/// `allowed_prefixes` should already be canonical (see
/// [`default_allowed_prefixes`]). The function does not canonicalise
/// `allowed_prefixes` again — the caller pays that cost once.
///
/// Symlinks: this loader follows symlinks (via `canonicalize`), then
/// checks the **resolved** path against the prefix allowlist. That is
/// strictly stronger than the legacy
/// [`xai_grok_pager::prompt_images::read_image_at_path`], which has no
/// prefix allowlist at all. The new rule applies to both
/// `[Image #N: <path>]` placeholder recovery callers — the
/// server-side `handle_prompt` fallback and the TUI orphan-placeholder
/// fallback. The legacy user-initiated drag/paste path in
/// `read_image_at_path` is intentionally outside this allowlist (the
/// user explicitly chose those files via the OS file picker).
pub fn load_placeholder_image(
path_str: &str,
allowed_prefixes: &[PathBuf],
) -> Result<LoadedPlaceholderImage, PlaceholderLoadError> {
load_placeholder_image_with_cap(path_str, allowed_prefixes, MAX_PLACEHOLDER_IMAGE_BYTES)
}
/// Variant of [`load_placeholder_image`] that takes an explicit byte
/// cap. Used by tests so they can exercise the
/// [`PlaceholderLoadError::TooLarge`] path with a tiny cap and a small
/// file rather than synthesising a 50 MB blob.
pub fn load_placeholder_image_with_cap(
path_str: &str,
allowed_prefixes: &[PathBuf],
max_bytes: usize,
) -> Result<LoadedPlaceholderImage, PlaceholderLoadError> {
let canonical = dunce::canonicalize(Path::new(path_str))
.map_err(|_| PlaceholderLoadError::CanonicalizeFailed)?;
load_canonical_placeholder_image(&canonical, allowed_prefixes, max_bytes)
}
/// Variant of [`load_placeholder_image`] for callers that have already
/// canonicalised the path (e.g. [`recover_orphan_placeholders`], which
/// canonicalises once for dedup). Saves one `canonicalize` syscall per
/// successful load.
pub fn load_canonical_placeholder_image(
canonical: &Path,
allowed_prefixes: &[PathBuf],
max_bytes: usize,
) -> Result<LoadedPlaceholderImage, PlaceholderLoadError> {
// Prefix check first — out-of-scope paths return a single,
// file-system-independent variant.
if !allowed_prefixes.iter().any(|p| canonical.starts_with(p)) {
return Err(PlaceholderLoadError::OutsideAllowedPrefixes);
}
// Deny-list pass: even inside an allowed prefix, certain subtrees
// (macOS bundle internals, `.Trash`, secret stores) are off-limits.
// Normalise `\` → `/` so Windows paths hit the same forward-slash
// needles as Unix paths — see the `DENY_PATH_CONTAINS` doc-comment
// for the platform contract.
let canonical_str = canonical.to_string_lossy().replace('\\', "/");
if DENY_PATH_CONTAINS
.iter()
.any(|needle| canonical_str.contains(needle))
{
return Err(PlaceholderLoadError::OutsideAllowedPrefixes);
}
let ext = canonical
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase())
.ok_or(PlaceholderLoadError::UnsupportedExtension)?;
if !ALLOWED_IMAGE_EXTENSIONS.contains(&ext.as_str()) {
return Err(PlaceholderLoadError::UnsupportedExtension);
}
let metadata = canonical
.metadata()
.map_err(|e| PlaceholderLoadError::ReadFailed(e.kind()))?;
if !metadata.is_file() {
return Err(PlaceholderLoadError::NotAFile);
}
let size = metadata.len() as usize;
if size > max_bytes {
return Err(PlaceholderLoadError::TooLarge {
actual: size,
limit: max_bytes,
});
}
let data = std::fs::read(canonical).map_err(|e| PlaceholderLoadError::ReadFailed(e.kind()))?;
// Re-check after read: a sparse/grown file may exceed the cap
// even when the metadata snapshot was under it.
if data.len() > max_bytes {
return Err(PlaceholderLoadError::TooLarge {
actual: data.len(),
limit: max_bytes,
});
}
// Image-decoder validation: routed through the `image` crate's
// header parser so a file with PNG magic bytes followed by arbitrary
// content (e.g. a private key) is rejected. `into_dimensions` reads
// the header (cheap) but not the pixel payload (expensive), so a
// truncated/garbled image fails fast.
let mime_type = decode_image_mime(&data).ok_or(PlaceholderLoadError::NotAnImage)?;
Ok(LoadedPlaceholderImage {
data,
mime_type: mime_type.to_owned(),
})
}
/// Header-only validation via the shared image_validate helper.
/// Returns the matching MIME type, or `None` if the bytes fail validation.
fn decode_image_mime(data: &[u8]) -> Option<&'static str> {
xai_grok_tools::util::image_validate::validate_image_bytes_with(data, false)
.ok()
.map(|(_, _, mime)| mime)
}
/// Recover orphan `[Image #N: <path>]` placeholders embedded in the
/// user query text by loading the referenced files from disk.
///
/// Production wrapper over [`recover_orphan_placeholders_with_prefixes`]
/// that derives the prefix allowlist from `workspace_cwd` via
/// [`default_allowed_prefixes`].
///
/// **Note for integration tests.** This wrapper reads the ambient
/// process `$HOME` via `dirs::home_dir()` to construct
/// [`HOME_IMAGE_SUBDIRS`] prefixes. An end-to-end test driving
/// `handle_prompt` therefore inherits the test runner's `$HOME` and
/// any subdirectories it materialises (`~/Downloads`, etc.) into the
/// allowlist. For hermetic test isolation, call
/// [`recover_orphan_placeholders_with_prefixes`] directly with an
/// explicit prefix list — see the unit tests of this module for the
/// pattern.
pub fn recover_orphan_placeholders(
query: &str,
raw_images: &mut Vec<agent_client_protocol::ImageContent>,
workspace_cwd: &Path,
) -> usize {
let allowed = default_allowed_prefixes(workspace_cwd);
recover_orphan_placeholders_with_prefixes(query, raw_images, &allowed)
}
/// Inject-friendly variant of [`recover_orphan_placeholders`].
///
/// "Orphan" = a placeholder whose canonical path is **not** already
/// present in `raw_images` (i.e. the TUI did not send a matching
/// `ContentBlock::Image`).
///
/// Dedup is performed against the **canonical** form of each existing
/// `raw_images[i].uri` so a TUI-attached non-canonical `file://`
/// URI (e.g. `file:///tmp/foo.png` when the canonical path is
/// `/private/tmp/foo.png`) still matches the placeholder's canonical
/// form. Percent-encoded forms are also handled (see
/// [`canonical_from_file_uri`]).
///
/// Enforces [`MAX_PLACEHOLDER_AGGREGATE_BYTES`] across the recovered
/// payloads: once the running total would exceed the cap, the
/// remainder of the placeholders are skipped with a `warn` log.
///
/// Returns the number of recovered images so the caller can log a
/// summary.
pub fn recover_orphan_placeholders_with_prefixes(
query: &str,
raw_images: &mut Vec<agent_client_protocol::ImageContent>,
allowed_prefixes: &[PathBuf],
) -> usize {
recover_orphan_placeholders_with_prefixes_and_caps(
query,
raw_images,
allowed_prefixes,
MAX_PLACEHOLDER_IMAGE_BYTES,
MAX_PLACEHOLDER_AGGREGATE_BYTES,
)
}
/// Test-injectable variant of
/// [`recover_orphan_placeholders_with_prefixes`].
///
/// Production code calls the cap-defaulting wrapper; tests use this
/// form to exercise the aggregate cap with small synthetic values
/// (real 200 MB tests would burn disk/CPU per run).
///
/// Aggregate-cap semantics: the loop reads the next image, then checks
/// `aggregate_bytes + image.len() > aggregate_max`. The first image
/// that pushes the running total **strictly above** the cap is
/// dropped and the loop `break`s; earlier images already in
/// `raw_images` stay. The cap is therefore an **inclusive** upper
/// bound on the aggregate — a running total exactly equal to the cap
/// is admitted, the next byte trips the break. See test
/// `recover_orphan_placeholders_aggregate_cap_inclusive_boundary`.
pub fn recover_orphan_placeholders_with_prefixes_and_caps(
query: &str,
raw_images: &mut Vec<agent_client_protocol::ImageContent>,
allowed_prefixes: &[PathBuf],
per_image_max: usize,
aggregate_max: usize,
) -> usize {
use base64::Engine as _;
let placeholders = extract_placeholders(query);
if placeholders.is_empty() {
return 0;
}
// Pre-compute canonical paths of the already-attached images so the
// dedup is robust against non-canonical `file://` URIs from the
// TUI side as well as percent-encoded URIs from non-TUI clients.
let attached_canonical: std::collections::HashSet<PathBuf> = raw_images
.iter()
.filter_map(|img| img.uri.as_deref().and_then(canonical_from_file_uri))
.collect();
let mut recovered: usize = 0;
let mut aggregate_bytes: usize = 0;
for ph in placeholders {
let canonical = match dunce::canonicalize(Path::new(&ph.path)) {
Ok(c) => c,
Err(_) => {
tracing::warn!(
path = ?ph.path,
"placeholder_images: orphan path does not resolve; leaving placeholder text intact",
);
continue;
}
};
if attached_canonical.contains(&canonical) {
continue;
}
match load_canonical_placeholder_image(&canonical, allowed_prefixes, per_image_max) {
Ok(loaded) => {
let next_total = aggregate_bytes.saturating_add(loaded.data.len());
if next_total > aggregate_max {
tracing::warn!(
path = ?ph.path,
aggregate_bytes,
per_image_bytes = loaded.data.len(),
cap = aggregate_max,
"placeholder_images: aggregate-bytes cap reached; skipping remaining orphan placeholders",
);
break;
}
aggregate_bytes = next_total;
let data = base64::engine::general_purpose::STANDARD.encode(&loaded.data);
raw_images.push(
agent_client_protocol::ImageContent::new(data, loaded.mime_type)
.uri(format!("file://{}", canonical.display()))
// Record the real `[Image #N]` number so it resolves by
// number, matching the TUI-attached images (which set it
// too) and avoiding position-based collisions.
.meta(display_number_meta(ph.display_number)),
);
recovered += 1;
}
Err(e) => {
tracing::warn!(
path = ?ph.path,
error = %e,
"placeholder_images: orphan placeholder failed to load",
);
}
}
}
recovered
}
/// Parse a `file://...` URI into a canonical `PathBuf`, accepting both
/// the relaxed unencoded form emitted by the TUI / server (see the
/// `file://` URI convention note in the module header) and the
/// percent-encoded RFC 3986 form. Returns `None` if the URI does not
/// start with `file://`; otherwise returns the canonicalised path
/// (falling back to the raw path when canonicalisation fails so the
/// caller can still compare against attached URIs).
pub fn canonical_from_file_uri(uri: &str) -> Option<PathBuf> {
let raw_path_str = uri.strip_prefix("file://")?;
// Try percent-decoding first; fall back to the literal form. Both
// are valid per the module's `file://` URI convention.
let decoded: std::borrow::Cow<'_, str> = match urlencoding::decode(raw_path_str) {
Ok(c) => c,
Err(_) => std::borrow::Cow::Borrowed(raw_path_str),
};
let raw = Path::new(decoded.as_ref());
Some(dunce::canonicalize(raw).unwrap_or_else(|_| raw.to_path_buf()))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
/// Minimal valid 1x1 PNG (real format, decodable by `image`).
const PNG_BYTES: &[u8] = &[
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44,
0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F,
0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9C, 0x63, 0x00,
0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00, 0x00, 0x00, 0x00, 0x49,
0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
];
fn write_png(dir: &Path, name: &str) -> PathBuf {
let path = dir.join(name);
let mut f = std::fs::File::create(&path).unwrap();
f.write_all(PNG_BYTES).unwrap();
path
}
// ----- strip_paths_from_image_placeholders ---------------------------
#[test]
fn strip_paths_drops_path_keeps_anchor() {
// The whole point: the model should see the bracketed anchor
// `[Image #N]` but not the path that would tempt a `Read`.
assert_eq!(
strip_paths_from_image_placeholders(
"what is that?[Image #1: /Users/me/Desktop/x.png] thanks".to_owned()
),
"what is that?[Image #1] thanks"
);
}
#[test]
fn strip_paths_handles_multiple_placeholders_and_spaces_in_paths() {
assert_eq!(
strip_paths_from_image_placeholders(
"[Image #1: /tmp/a.png] mid [Image #2: /home/u/My Pictures/b.jpg] tail".to_owned()
),
"[Image #1] mid [Image #2] tail"
);
}
#[test]
fn strip_paths_returns_input_unchanged_when_no_placeholders() {
// Fast-path: the helper takes `String` by value and the
// no-match branch returns it verbatim — no allocation. The
// identity here pins the contract (input string ⇔ output
// string) byte-for-byte.
let text = "no placeholder here, just prose";
assert_eq!(strip_paths_from_image_placeholders(text.to_owned()), text);
}
#[test]
fn strip_paths_preserves_surrounding_whitespace_and_unicode() {
assert_eq!(
strip_paths_from_image_placeholders(
"café \u{202f}[Image #4: /Users/me/Desktop/Screenshot 2026-05-22 at 16.01.21.png] ok"
.to_owned()
),
"café \u{202f}[Image #4] ok"
);
}
#[test]
fn strip_paths_ignores_malformed_placeholders() {
// None of these match the regex (the last is unterminated, the
// middle two have empty paths). The leading `[Image #1]` is
// already in the short form, so the output is bit-identical
// to the input.
let text = "[Image #1] [Image #2:] [Image #3: ] [Image #4: /ok.png";
assert_eq!(strip_paths_from_image_placeholders(text.to_owned()), text);
}
// ----- extract_placeholders ------------------------------------------
#[test]
fn extract_placeholders_basic() {
let text = "look at [Image #1: /tmp/a.png] and [Image #2: /home/user/b.jpg]";
let matches = extract_placeholders(text);
assert_eq!(matches.len(), 2);
assert_eq!(matches[0].display_number, 1);
assert_eq!(matches[0].path, "/tmp/a.png");
assert_eq!(matches[1].display_number, 2);
assert_eq!(matches[1].path, "/home/user/b.jpg");
}
#[test]
fn extract_placeholders_with_spaces_in_path() {
let text = "[Image #3: /Users/me/My Pictures/cat.png]";
let matches = extract_placeholders(text);
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].path, "/Users/me/My Pictures/cat.png");
}
#[test]
fn extract_placeholders_rejects_partial_and_invalid_forms() {
assert!(extract_placeholders("[Image #3] only").is_empty());
assert!(extract_placeholders("[Image #4: ]").is_empty());
assert!(extract_placeholders("[Image #5: /tmp/x.png").is_empty());
assert!(extract_placeholders("[image #1: /tmp/x.png]").is_empty());
assert!(extract_placeholders("[Image #: /tmp/x.png]").is_empty());
// Missing space after colon: producer always emits ": " so the
// shorthand form is intentionally rejected. Pinned here.
assert!(extract_placeholders("[Image #5:foo.png]").is_empty());
}
#[test]
fn extract_placeholders_inside_larger_text_returns_spans() {
let text = "before [Image #7: /tmp/a.png] after";
let matches = extract_placeholders(text);
assert_eq!(matches.len(), 1);
let (start, end) = matches[0].span;
assert_eq!(&text[start..end], "[Image #7: /tmp/a.png]");
}
#[test]
fn extract_placeholders_unicode_path() {
let text = "[Image #9: /tmp/café.png]";
let matches = extract_placeholders(text);
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].path, "/tmp/café.png");
}
#[test]
fn extract_placeholders_large_display_number() {
let text = "[Image #99999: /tmp/a.png]";
let matches = extract_placeholders(text);
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].display_number, 99999);
}
#[test]
fn extract_placeholders_display_number_zero_is_kept() {
let text = "[Image #0: /tmp/a.png]";
let matches = extract_placeholders(text);
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].display_number, 0);
}
#[test]
fn extract_placeholders_multiple_on_same_line() {
let text = "[Image #1: /tmp/a.png] [Image #2: /tmp/b.png] [Image #3: /tmp/c.png]";
let matches = extract_placeholders(text);
assert_eq!(matches.len(), 3);
assert_eq!(matches[0].display_number, 1);
assert_eq!(matches[1].display_number, 2);
assert_eq!(matches[2].display_number, 3);
}
#[test]
fn extract_placeholders_at_text_boundaries() {
let matches = extract_placeholders("[Image #1: /tmp/a.png] tail");
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].span.0, 0);
let text2 = "head [Image #2: /tmp/b.png]";
let matches2 = extract_placeholders(text2);
assert_eq!(matches2.len(), 1);
assert_eq!(matches2[0].span.1, text2.len());
}
#[test]
fn extract_placeholders_rejects_carriage_return_in_path() {
let text = "[Image #1: /tmp/a\r.png]";
assert!(extract_placeholders(text).is_empty());
}
#[test]
fn extract_placeholders_caps_at_max_per_prompt() {
let chunk = "[Image #1: /tmp/a.png] ".repeat(MAX_PLACEHOLDERS_PER_PROMPT + 5);
let matches = extract_placeholders(&chunk);
assert_eq!(matches.len(), MAX_PLACEHOLDERS_PER_PROMPT);
}
#[test]
fn extract_placeholders_first_nested_bracket_terminates() {
// Pinning behaviour: `]` inside the path closes the match
// early. Result: the first segment is captured as the path,
// the rest of the text is left alone. Also pin the span so a
// future regex revision that consumes nested brackets is
// caught.
let text = "[Image #1: /tmp/[odd].png]";
let matches = extract_placeholders(text);
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].path, "/tmp/[odd");
let (start, end) = matches[0].span;
assert_eq!(&text[start..end], "[Image #1: /tmp/[odd]");
}
// ----- load_placeholder_image ----------------------------------------
#[test]
fn load_placeholder_image_happy_path() {
let dir = tempfile::tempdir().unwrap();
let path = write_png(dir.path(), "ok.png");
let canon = dunce::canonicalize(dir.path()).unwrap();
let loaded =
load_placeholder_image(path.to_str().unwrap(), std::slice::from_ref(&canon)).unwrap();
assert_eq!(loaded.mime_type, "image/png");
assert_eq!(loaded.data, PNG_BYTES);
}
#[test]
fn load_placeholder_image_rejects_missing_path() {
let dir = tempfile::tempdir().unwrap();
let canon = dunce::canonicalize(dir.path()).unwrap();
let missing = dir.path().join("does-not-exist.png");
let err = load_placeholder_image(missing.to_str().unwrap(), std::slice::from_ref(&canon))
.unwrap_err();
assert!(matches!(err, PlaceholderLoadError::CanonicalizeFailed));
}
#[test]
fn load_placeholder_image_rejects_unsupported_extension() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("notes.txt");
std::fs::write(&path, b"hello").unwrap();
let canon = dunce::canonicalize(dir.path()).unwrap();
let err = load_placeholder_image(path.to_str().unwrap(), std::slice::from_ref(&canon))
.unwrap_err();
assert!(matches!(err, PlaceholderLoadError::UnsupportedExtension));
}
#[test]
fn load_placeholder_image_rejects_outside_allowed_prefixes() {
let real_dir = tempfile::tempdir().unwrap();
let png = write_png(real_dir.path(), "real.png");
let other_dir = tempfile::tempdir().unwrap();
let other_canon = dunce::canonicalize(other_dir.path()).unwrap();
let err = load_placeholder_image(png.to_str().unwrap(), std::slice::from_ref(&other_canon))
.unwrap_err();
assert!(matches!(err, PlaceholderLoadError::OutsideAllowedPrefixes));
}
#[test]
fn load_placeholder_image_rejects_not_an_image_with_image_extension() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("fake.png");
std::fs::write(&path, b"not actually a png").unwrap();
let canon = dunce::canonicalize(dir.path()).unwrap();
let err = load_placeholder_image(path.to_str().unwrap(), std::slice::from_ref(&canon))
.unwrap_err();
assert!(matches!(err, PlaceholderLoadError::NotAnImage));
}
/// Defence-in-depth regression for the exfil chain: a file whose
/// first 8 bytes are PNG magic but whose tail is arbitrary content
/// (e.g. a private key) is **rejected** by the loader. Earlier
/// rounds pinned the weaker `image::guess_format`-only behaviour
/// which accepted such forgeries; the full
/// `with_guessed_format` + `into_dimensions` check closes that
/// gap.
#[test]
fn load_placeholder_image_rejects_png_magic_forgery_with_garbage_tail() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("forged.png");
let mut bytes = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
bytes.extend(b"PRIVATE KEY DATA - not a real PNG");
std::fs::write(&path, &bytes).unwrap();
let canon = dunce::canonicalize(dir.path()).unwrap();
let err = load_placeholder_image(path.to_str().unwrap(), std::slice::from_ref(&canon))
.unwrap_err();
assert!(
matches!(err, PlaceholderLoadError::NotAnImage),
"expected NotAnImage, got: {err:?}"
);
}
#[test]
fn load_placeholder_image_rejects_directory_as_not_a_file() {
let allowed = tempfile::tempdir().unwrap();
let dir_path = allowed.path().join("looks-like.png");
std::fs::create_dir(&dir_path).unwrap();
let canon = dunce::canonicalize(allowed.path()).unwrap();
let err = load_placeholder_image(dir_path.to_str().unwrap(), std::slice::from_ref(&canon))
.unwrap_err();
assert!(matches!(err, PlaceholderLoadError::NotAFile));
}
#[cfg(unix)]
#[test]
fn load_placeholder_image_reports_read_failure_on_unreadable_file() {
// SAFETY: getuid is always safe; we just don't want to import libc.
let euid = unsafe { libc::geteuid() };
if euid == 0 {
return;
}
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("locked.png");
std::fs::write(&path, PNG_BYTES).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap();
let canon = dunce::canonicalize(dir.path()).unwrap();
let err = load_placeholder_image(path.to_str().unwrap(), std::slice::from_ref(&canon))
.unwrap_err();
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
assert!(
matches!(err, PlaceholderLoadError::ReadFailed(_)),
"expected ReadFailed, got: {err:?}"
);
}
#[test]
fn load_placeholder_image_rejects_oversize_via_injectable_cap() {
let dir = tempfile::tempdir().unwrap();
let path = write_png(dir.path(), "small-but-over-cap.png");
let canon = dunce::canonicalize(dir.path()).unwrap();
let err = load_placeholder_image_with_cap(
path.to_str().unwrap(),
std::slice::from_ref(&canon),
32,
)
.unwrap_err();
match err {
PlaceholderLoadError::TooLarge { actual, limit } => {
assert_eq!(actual, PNG_BYTES.len());
assert_eq!(limit, 32);
}
other => panic!("expected TooLarge, got {other:?}"),
}
}
#[cfg(unix)]
#[test]
fn load_placeholder_image_rejects_symlink_escape() {
let allowed_dir = tempfile::tempdir().unwrap();
let outside_dir = tempfile::tempdir().unwrap();
let target = write_png(outside_dir.path(), "secret.png");
let link = allowed_dir.path().join("link.png");
std::os::unix::fs::symlink(&target, &link).unwrap();
let allowed_canon = dunce::canonicalize(allowed_dir.path()).unwrap();
let err =
load_placeholder_image(link.to_str().unwrap(), std::slice::from_ref(&allowed_canon))
.unwrap_err();
assert!(
matches!(err, PlaceholderLoadError::OutsideAllowedPrefixes),
"symlink escape must canonicalize then trip the prefix check, got: {err:?}",
);
}
#[test]
fn load_placeholder_image_rejects_path_traversal_via_dotdot() {
let allowed_dir = tempfile::tempdir().unwrap();
let outside_dir = tempfile::tempdir().unwrap();
let unique = format!("ph-traversal-{}.png", uuid::Uuid::new_v4());
let outside = outside_dir.path().join(&unique);
std::fs::write(&outside, PNG_BYTES).unwrap();
let traversal = allowed_dir
.path()
.join("..")
.join(outside_dir.path().file_name().unwrap())
.join(&unique);
let allowed_canon = dunce::canonicalize(allowed_dir.path()).unwrap();
let err = load_placeholder_image(
traversal.to_str().unwrap(),
std::slice::from_ref(&allowed_canon),
)
.unwrap_err();
assert!(matches!(err, PlaceholderLoadError::OutsideAllowedPrefixes));
}
#[test]
fn load_placeholder_image_rejects_deny_listed_subtree() {
// Construct a workspace whose canonical contains
// `/.photoslibrary/` so the path is inside the allowlist but
// hits the deny-list.
let root = tempfile::tempdir().unwrap();
let bundle = root
.path()
.join("Photos Library.photoslibrary")
.join("originals");
std::fs::create_dir_all(&bundle).unwrap();
let png = write_png(&bundle, "hash.png");
let allowed_canon = dunce::canonicalize(root.path()).unwrap();
let err =
load_placeholder_image(png.to_str().unwrap(), std::slice::from_ref(&allowed_canon))
.unwrap_err();
assert!(
matches!(err, PlaceholderLoadError::OutsideAllowedPrefixes),
"deny-listed subtree must be rejected even inside allowlist, got: {err:?}"
);
}
// ----- default_allowed_prefixes / _with_home --------------------------
#[test]
fn default_allowed_prefixes_with_home_includes_workspace_and_every_subdir() {
let dir = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
// Materialise every entry in HOME_IMAGE_SUBDIRS so the list
// drift is caught at test time, not at runtime.
let mut expected_subdir_canons: Vec<PathBuf> = Vec::new();
for sub in HOME_IMAGE_SUBDIRS {
let p = home.path().join(sub);
std::fs::create_dir(&p).unwrap();
expected_subdir_canons.push(dunce::canonicalize(&p).unwrap());
}
let prefixes =
default_allowed_prefixes_with_home(dir.path(), Some(home.path().to_path_buf()));
let dir_canon = dunce::canonicalize(dir.path()).unwrap();
assert!(prefixes.contains(&dir_canon));
for canon in &expected_subdir_canons {
assert!(
prefixes.contains(canon),
"missing prefix for {canon:?} in {prefixes:?}"
);
}
// $HOME itself is NOT in the list.
assert!(!prefixes.contains(&dunce::canonicalize(home.path()).unwrap()));
// At least workspace + each subdir, sorted+deduped. Uses `>=`
// not `==` so the test stays green if `$TMPDIR` happens to
// resolve inside one of the home subdirs (e.g. CI runners that
// set `TMPDIR=$HOME/Downloads/ci`) — in that case the
// workspace canonical would equal one of the subdir canonicals
// and the dedup pass collapses them.
assert!(
prefixes.len() >= HOME_IMAGE_SUBDIRS.len(),
"expected at least {} prefixes, got {prefixes:?}",
HOME_IMAGE_SUBDIRS.len()
);
}
#[test]
fn default_allowed_prefixes_with_home_unset_returns_workspace_only() {
let dir = tempfile::tempdir().unwrap();
let prefixes = default_allowed_prefixes_with_home(dir.path(), None);
let dir_canon = dunce::canonicalize(dir.path()).unwrap();
assert_eq!(prefixes, vec![dir_canon]);
}
#[test]
fn default_allowed_prefixes_dedups_workspace_equal_home_subdir() {
let home = tempfile::tempdir().unwrap();
let downloads = home.path().join("Downloads");
std::fs::create_dir(&downloads).unwrap();
let prefixes =
default_allowed_prefixes_with_home(&downloads, Some(home.path().to_path_buf()));
let dl_canon = dunce::canonicalize(&downloads).unwrap();
let count = prefixes.iter().filter(|p| **p == dl_canon).count();
assert_eq!(
count, 1,
"duplicate canonical prefix not deduped: {prefixes:?}"
);
}
#[test]
fn default_allowed_prefixes_drops_non_canonical_workspace() {
let prefixes = default_allowed_prefixes_with_home(
Path::new("/nonexistent/abs/path/we/never/created"),
None,
);
assert!(
prefixes.is_empty(),
"non-canonical workspace must not be added to the allowlist: {prefixes:?}"
);
}
// ----- canonical_from_file_uri ----------------------------------------
#[test]
fn canonical_from_file_uri_rejects_non_file_scheme() {
assert!(canonical_from_file_uri("https://example.com/x.png").is_none());
assert!(canonical_from_file_uri("/raw/path").is_none());
}
#[test]
fn canonical_from_file_uri_handles_percent_encoded_path() {
let dir = tempfile::tempdir().unwrap();
let with_space = dir.path().join("My Pictures");
std::fs::create_dir(&with_space).unwrap();
let png = write_png(&with_space, "cat.png");
let canon = dunce::canonicalize(&png).unwrap();
// RFC 3986 form: spaces percent-encoded.
let raw = format!("file://{}", png.display());
let encoded = raw.replace(' ', "%20");
let parsed = canonical_from_file_uri(&encoded).unwrap();
assert_eq!(parsed, canon);
}
// ----- recover_orphan_placeholders (hermetic, no ambient $HOME) -------
/// Build a non-empty ACP `ImageContent` so a future dedup change
/// that short-circuits on `data.is_empty()` cannot silently pass
/// these tests.
fn make_acp_image(uri: &str) -> agent_client_protocol::ImageContent {
agent_client_protocol::ImageContent::new("AAAA", "image/png").uri(Some(uri.to_string()))
}
#[test]
fn recover_orphan_placeholders_loads_orphan() {
let dir = tempfile::tempdir().unwrap();
let path = write_png(dir.path(), "rec.png");
let canon = dunce::canonicalize(&path).unwrap();
let query = format!("look at [Image #1: {}]", canon.display());
let mut raw: Vec<agent_client_protocol::ImageContent> = Vec::new();
let allowed = vec![dunce::canonicalize(dir.path()).unwrap()];
let n = recover_orphan_placeholders_with_prefixes(&query, &mut raw, &allowed);
assert_eq!(n, 1);
assert_eq!(raw.len(), 1);
assert_eq!(raw[0].mime_type, "image/png");
assert!(!raw[0].data.is_empty());
assert!(raw[0].uri.as_deref().unwrap().starts_with("file://"));
// The recovered image carries its real `[Image #N]` number so
// `image_edit` can resolve the token to it by number.
assert_eq!(display_number_from_meta(raw[0].meta.as_ref()), Some(1));
}
#[test]
fn recover_orphan_placeholders_dedupes_against_canonical_uri() {
let dir = tempfile::tempdir().unwrap();
let path = write_png(dir.path(), "dup.png");
let canon = dunce::canonicalize(&path).unwrap();
let attached_uri = format!("file://{}", canon.display());
let mut raw = vec![make_acp_image(&attached_uri)];
let query = format!("see [Image #1: {}]", canon.display());
let allowed = vec![dunce::canonicalize(dir.path()).unwrap()];
let n = recover_orphan_placeholders_with_prefixes(&query, &mut raw, &allowed);
assert_eq!(n, 0, "canonical-canonical dedup must skip the load");
assert_eq!(raw.len(), 1);
// The original entry must be untouched, not silently
// overwritten by a duplicate load.
assert_eq!(raw[0].data, "AAAA");
}
#[cfg(unix)]
#[test]
fn recover_orphan_placeholders_dedupes_against_non_canonical_tui_uri() {
let dir = tempfile::tempdir().unwrap();
let outside_root = tempfile::tempdir().unwrap();
let real_target = write_png(outside_root.path(), "data.png");
let link = dir.path().join("link.png");
std::os::unix::fs::symlink(&real_target, &link).unwrap();
let attached_uri = format!("file://{}", link.display()); // non-canonical
let mut raw = vec![make_acp_image(&attached_uri)];
let canonical_placeholder = dunce::canonicalize(&real_target).unwrap();
let query = format!("[Image #1: {}]", canonical_placeholder.display());
let allowed = vec![dunce::canonicalize(outside_root.path()).unwrap()];
let n = recover_orphan_placeholders_with_prefixes(&query, &mut raw, &allowed);
assert_eq!(
n, 0,
"non-canonical TUI URI must still dedup against canonical placeholder path"
);
assert_eq!(raw.len(), 1);
assert_eq!(raw[0].data, "AAAA");
}
#[test]
fn recover_orphan_placeholders_dedupes_against_percent_encoded_uri() {
let dir = tempfile::tempdir().unwrap();
let space_dir = dir.path().join("My Pictures");
std::fs::create_dir(&space_dir).unwrap();
let png = write_png(&space_dir, "cat.png");
let canon = dunce::canonicalize(&png).unwrap();
// Attached image URI uses RFC 3986 percent-encoded form.
let raw_form = format!("file://{}", canon.display());
let encoded = raw_form.replace(' ', "%20");
let mut raw = vec![make_acp_image(&encoded)];
let query = format!("[Image #1: {}]", canon.display());
let allowed = vec![dunce::canonicalize(dir.path()).unwrap()];
let n = recover_orphan_placeholders_with_prefixes(&query, &mut raw, &allowed);
assert_eq!(
n, 0,
"percent-encoded `file://` URI must dedup against canonical placeholder"
);
assert_eq!(raw.len(), 1);
assert_eq!(raw[0].data, "AAAA");
}
/// Pin the inverse direction. The placeholder text wire
/// format is the unencoded path produced by
/// `xai_grok_pager::prompt_images::display_text`. A
/// percent-encoded path *inside the placeholder text* is **not**
/// supported — `extract_placeholders` captures the raw `%20`
/// substring and `canonicalize` rejects the synthetic name. The
/// orphan loader logs a warn and skips the placeholder. The
/// already-attached canonical URI in `raw_images` stays
/// untouched.
#[test]
fn recover_orphan_placeholders_percent_encoded_path_in_placeholder_is_not_supported() {
let dir = tempfile::tempdir().unwrap();
let space_dir = dir.path().join("My Pictures");
std::fs::create_dir(&space_dir).unwrap();
let png = write_png(&space_dir, "cat.png");
let canon = dunce::canonicalize(&png).unwrap();
let attached_uri = format!("file://{}", canon.display());
let mut raw = vec![make_acp_image(&attached_uri)];
// Percent-encoded path inside the placeholder text — not the
// documented wire format.
let encoded_in_text = format!("{}", canon.display()).replace(' ', "%20");
let query = format!("[Image #1: {}]", encoded_in_text);
let allowed = vec![dunce::canonicalize(dir.path()).unwrap()];
let n = recover_orphan_placeholders_with_prefixes(&query, &mut raw, &allowed);
assert_eq!(n, 0);
assert_eq!(raw.len(), 1, "attached URI must remain intact");
assert_eq!(raw[0].data, "AAAA");
}
#[test]
fn recover_orphan_placeholders_zero_placeholders_short_circuits() {
let dir = tempfile::tempdir().unwrap();
let mut raw: Vec<agent_client_protocol::ImageContent> = Vec::new();
let allowed = vec![dunce::canonicalize(dir.path()).unwrap()];
let n = recover_orphan_placeholders_with_prefixes("just a message", &mut raw, &allowed);
assert_eq!(n, 0);
assert!(raw.is_empty());
}
#[test]
fn recover_orphan_placeholders_failed_load_leaves_raw_images_untouched() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("nope.png");
let mut raw: Vec<agent_client_protocol::ImageContent> = Vec::new();
let query = format!("[Image #1: {}]", missing.display());
let allowed = vec![dunce::canonicalize(dir.path()).unwrap()];
let n = recover_orphan_placeholders_with_prefixes(&query, &mut raw, &allowed);
assert_eq!(n, 0);
assert!(raw.is_empty());
}
#[test]
fn recover_orphan_placeholders_outside_allowlist_rejected() {
let workspace = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let png = write_png(outside.path(), "secret.png");
let canon = dunce::canonicalize(&png).unwrap();
let mut raw: Vec<agent_client_protocol::ImageContent> = Vec::new();
let query = format!("[Image #1: {}]", canon.display());
// Allowlist is workspace only — the placeholder canon is
// outside it.
let allowed = vec![dunce::canonicalize(workspace.path()).unwrap()];
let n = recover_orphan_placeholders_with_prefixes(&query, &mut raw, &allowed);
assert_eq!(n, 0);
assert!(raw.is_empty());
}
// ----- Aggregate cap -------------------------------------------------
/// Two placeholders, aggregate cap below the cumulative byte
/// total of both. The first image fits; the second pushes the
/// running total over and the loop breaks. Pin: `n == 1`, second
/// placeholder was NOT loaded into `raw`.
#[test]
fn recover_orphan_placeholders_aggregate_cap_breaks_loop() {
let dir = tempfile::tempdir().unwrap();
let p1 = write_png(dir.path(), "a.png");
let p2 = write_png(dir.path(), "b.png");
let c1 = dunce::canonicalize(&p1).unwrap();
let c2 = dunce::canonicalize(&p2).unwrap();
let query = format!("[Image #1: {}] [Image #2: {}]", c1.display(), c2.display());
let mut raw: Vec<agent_client_protocol::ImageContent> = Vec::new();
let allowed = vec![dunce::canonicalize(dir.path()).unwrap()];
// Per-image cap permissive; aggregate cap admits exactly one
// image (PNG_BYTES is 67 bytes; cap at 100 lets one through,
// blocks the second).
let n = recover_orphan_placeholders_with_prefixes_and_caps(
&query, &mut raw, &allowed, 1_000, 100,
);
assert_eq!(n, 1, "aggregate cap must allow exactly one image");
assert_eq!(raw.len(), 1);
// Order matters — the first placeholder's canonical URI is
// the one that landed in `raw`.
let attached_uri = raw[0].uri.as_deref().unwrap();
assert!(
attached_uri.contains("a.png"),
"expected the first placeholder to be the one kept, got: {attached_uri}"
);
}
/// Pin the aggregate-cap boundary semantics. The cap is
/// **inclusive** — `aggregate + image.len() > cap` triggers the
/// break. A single image exactly equal to the cap is admitted.
#[test]
fn recover_orphan_placeholders_aggregate_cap_inclusive_boundary() {
let dir = tempfile::tempdir().unwrap();
let p = write_png(dir.path(), "one.png");
let c = dunce::canonicalize(&p).unwrap();
let query = format!("[Image #1: {}]", c.display());
let mut raw: Vec<agent_client_protocol::ImageContent> = Vec::new();
let allowed = vec![dunce::canonicalize(dir.path()).unwrap()];
// Cap == image size: the `>` comparison admits this image.
let n = recover_orphan_placeholders_with_prefixes_and_caps(
&query,
&mut raw,
&allowed,
1_000,
PNG_BYTES.len(),
);
assert_eq!(n, 1);
assert_eq!(raw.len(), 1);
}
/// The reject side of the inclusive-boundary contract.
/// `cap == image_size - 1` is the smallest cap that rejects this
/// image. Two-sided boundary pinning locks down the inclusive vs
/// exclusive contract.
#[test]
fn recover_orphan_placeholders_aggregate_cap_inclusive_boundary_rejects_at_one_below() {
let dir = tempfile::tempdir().unwrap();
let p = write_png(dir.path(), "one.png");
let c = dunce::canonicalize(&p).unwrap();
let query = format!("[Image #1: {}]", c.display());
let mut raw: Vec<agent_client_protocol::ImageContent> = Vec::new();
let allowed = vec![dunce::canonicalize(dir.path()).unwrap()];
let n = recover_orphan_placeholders_with_prefixes_and_caps(
&query,
&mut raw,
&allowed,
1_000,
PNG_BYTES.len() - 1,
);
assert_eq!(n, 0, "cap one below image size must reject");
assert!(raw.is_empty());
}
// ----- DENY_PATH_CONTAINS --------------------------------------------
/// Every entry of `DENY_PATH_CONTAINS` produces an
/// `OutsideAllowedPrefixes` rejection. Loops the constant so a
/// future PR that deletes a security-critical entry (e.g.
/// `/.ssh/`) ships a failing test.
#[test]
fn load_placeholder_image_rejects_every_deny_list_entry() {
// Build a canonical that contains each needle by constructing
// a directory tree under a tempdir. Each needle is wrapped in
// a leading `<allowed>` and a trailing `<file>.png`. Forward
// slashes only — the enforcement site normalises Windows
// backslashes to forward slashes before the substring check.
for needle in DENY_PATH_CONTAINS {
let root = tempfile::tempdir().unwrap();
let trimmed = needle.trim_matches('/');
// Materialise the directory tree implied by the needle so
// canonicalize succeeds.
let mut current = root.path().to_path_buf();
for segment in trimmed.split('/') {
current = current.join(segment);
std::fs::create_dir(&current).unwrap();
}
let png = write_png(&current, "x.png");
let canon = dunce::canonicalize(&png).unwrap();
// Allowlist is the root — without the deny-list, this
// path would be accepted.
let allowed = vec![dunce::canonicalize(root.path()).unwrap()];
let err = load_placeholder_image(canon.to_str().unwrap(), &allowed).unwrap_err();
assert!(
matches!(err, PlaceholderLoadError::OutsideAllowedPrefixes),
"needle {needle:?} did not produce OutsideAllowedPrefixes; got {err:?}"
);
}
// Positive control. A benign path inside an allowed
// prefix containing **none** of the deny needles must still
// load. Without this, a future regression that rejects every
// path would pass the loop above and ship.
let root = tempfile::tempdir().unwrap();
let png = write_png(root.path(), "picture.png");
let canon = dunce::canonicalize(&png).unwrap();
let allowed = vec![dunce::canonicalize(root.path()).unwrap()];
let loaded =
load_placeholder_image(canon.to_str().unwrap(), &allowed).unwrap_or_else(|e| {
panic!("positive control: benign path inside allowed prefix must load, got: {e:?}")
});
// Tighten the positive control — a regression that
// returned an empty `LoadedPlaceholderImage` would pass a
// bare `is_ok()` assertion. Pin the mime type and round-trip
// the bytes against the on-disk PNG.
assert_eq!(loaded.mime_type, "image/png");
assert_eq!(loaded.data, PNG_BYTES);
}
#[test]
fn attached_image_references_prefers_file_path_over_data() {
// `[Image #N]` resolution should hand `image_edit` a bare on-disk
// path (from the durable `file://` URI) so it reads the session
// copy rather than re-decoding a large base64 blob.
let img = agent_client_protocol::ImageContent::new("AAAA", "image/png")
.uri(Some(
"file:///Users/me/.grok/sessions/s/images/image-1.png".into(),
))
.meta(display_number_meta(1));
let refs = attached_image_references(std::slice::from_ref(&img));
assert_eq!(
refs,
vec![(
1,
"/Users/me/.grok/sessions/s/images/image-1.png".to_string()
)]
);
}
#[test]
fn attached_image_references_falls_back_to_data_url() {
// No durable URI (e.g. persistence failed): keep the inline bytes
// as a data URL so the token still resolves to the right image.
let img = agent_client_protocol::ImageContent::new("BBBB", "image/jpeg")
.meta(display_number_meta(2));
let refs = attached_image_references(std::slice::from_ref(&img));
assert_eq!(refs, vec![(2, "data:image/jpeg;base64,BBBB".to_string())]);
}
#[test]
fn attached_image_references_keys_by_meta_number_not_position() {
// Non-contiguous numbers (`#1`, `#3`) survive a mid-compose chip
// removal; the registry must key on the recorded number, not the
// list position.
let mk = |data: &str, n: usize| {
agent_client_protocol::ImageContent::new(data, "image/png").meta(display_number_meta(n))
};
let refs = attached_image_references(&[mk("first", 1), mk("third", 3)]);
assert_eq!(
refs,
vec![
(1, "data:image/png;base64,first".to_string()),
(3, "data:image/png;base64,third".to_string()),
]
);
}
#[test]
fn attached_image_references_falls_back_to_position_without_meta() {
// Older client / non-TUI caller with no recorded number: fall back
// to 1-based position so the common contiguous case still resolves.
let mk = |data: &str| agent_client_protocol::ImageContent::new(data, "image/png");
let refs = attached_image_references(&[mk("first"), mk("second")]);
assert_eq!(
refs,
vec![
(1, "data:image/png;base64,first".to_string()),
(2, "data:image/png;base64,second".to_string()),
]
);
}
}