1526 lines
65 KiB
Rust
1526 lines
65 KiB
Rust
|
|
//! 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(¤t).unwrap();
|
|||
|
|
}
|
|||
|
|
let png = write_png(¤t, "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()),
|
|||
|
|
]
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|