feat(mcp): add render.rs content policy (text paging, pattern filter, blob spill)
Single content-policy module for MCP resource and tool content, per plans/mcp-resources-prompts-design.md §4.5 (T3): - render_text: UTF-8-boundary-safe paging with clamped max_bytes and grep-style fancy-regex line filtering (2 lines of context, 1-based line-number prefixes, merged hunks); offsets walk the filtered stream. - render_blob/render_blob_at: streaming base64 decode with a 50 MiB ceiling, UTF-8 sniff, sha256-named 0600 spill files under a sanitized server dir with a fixed mime->ext allowlist, and best-effort oldest-first eviction bounding the spill tree at 512 MiB. Not yet wired to call sites; module carries #![allow(dead_code)] until the read/prompt surfaces land.
This commit is contained in:
@@ -0,0 +1,765 @@
|
||||
//! Content policy for MCP resource and tool content: UTF-8-boundary-safe text
|
||||
//! paging, grep-style pattern filtering, and spill-to-disk for binary blobs.
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use crate::config::paths;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use base64::read::DecoderReader;
|
||||
use fancy_regex::Regex;
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fmt;
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::{ErrorKind, Read, Write};
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::SystemTime;
|
||||
|
||||
/// Default page size when the caller does not specify `max_bytes`.
|
||||
pub const DEFAULT_TEXT_MAX_BYTES: usize = 51_200;
|
||||
/// Hard upper bound on a single text slice regardless of requested `max_bytes`.
|
||||
pub const TEXT_MAX_BYTES_CLAMP: usize = 204_800;
|
||||
/// Maximum decoded size of a base64 blob before rendering is refused.
|
||||
pub const BLOB_DECODE_CEILING_BYTES: usize = 50 * 1024 * 1024;
|
||||
/// Total size bound for the spill tree; oldest files are evicted beyond it.
|
||||
pub const SPILL_DIR_MAX_BYTES: u64 = 512 * 1024 * 1024;
|
||||
|
||||
const PATTERN_CONTEXT_LINES: usize = 2;
|
||||
const HUNK_SEPARATOR: &str = "--";
|
||||
|
||||
const MIME_EXTENSIONS: &[(&str, &str)] = &[
|
||||
("application/gzip", "gz"),
|
||||
("application/json", "json"),
|
||||
("application/pdf", "pdf"),
|
||||
("application/zip", "zip"),
|
||||
("audio/mpeg", "mp3"),
|
||||
("image/gif", "gif"),
|
||||
("image/jpeg", "jpg"),
|
||||
("image/png", "png"),
|
||||
("image/webp", "webp"),
|
||||
("text/csv", "csv"),
|
||||
("video/mp4", "mp4"),
|
||||
];
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum RenderError {
|
||||
InvalidPattern { pattern: String, error: String },
|
||||
DecodedSizeExceeded,
|
||||
InvalidBase64(String),
|
||||
Io(std::io::Error),
|
||||
}
|
||||
|
||||
impl fmt::Display for RenderError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::InvalidPattern { pattern, error } => write!(
|
||||
f,
|
||||
"Invalid filter pattern '{pattern}': {error}. Provide a valid regex; \
|
||||
lines matching it are returned with {PATTERN_CONTEXT_LINES} lines of context."
|
||||
),
|
||||
Self::DecodedSizeExceeded => write!(
|
||||
f,
|
||||
"Decoded blob exceeds BLOB_DECODE_CEILING_BYTES ({} MiB); refusing to render it",
|
||||
BLOB_DECODE_CEILING_BYTES / (1024 * 1024)
|
||||
),
|
||||
Self::InvalidBase64(error) => write!(f, "Invalid base64 in blob content: {error}"),
|
||||
Self::Io(error) => write!(f, "Failed to spill blob to disk: {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for RenderError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::Io(error) => Some(error),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for RenderError {
|
||||
fn from(error: std::io::Error) -> Self {
|
||||
Self::Io(error)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RenderedText {
|
||||
pub text: String,
|
||||
pub truncated: bool,
|
||||
pub total_bytes: usize,
|
||||
pub next_offset: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum RenderedBlob {
|
||||
Text(String),
|
||||
Spilled(SpillMetadata),
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SpillMetadata {
|
||||
pub spilled: bool,
|
||||
pub path: PathBuf,
|
||||
pub mime_type: Option<String>,
|
||||
pub sniffed: bool,
|
||||
pub size_bytes: u64,
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
/// Pages `text` with UTF-8-boundary-safe slicing. When `pattern` is set, the
|
||||
/// text is first reduced to matching lines plus context (grep-style, with
|
||||
/// 1-based line-number prefixes), and all offset/size math operates on that
|
||||
/// filtered stream.
|
||||
pub fn render_text(
|
||||
text: &str,
|
||||
pattern: Option<&str>,
|
||||
offset: usize,
|
||||
max_bytes: Option<usize>,
|
||||
) -> Result<RenderedText, RenderError> {
|
||||
let filtered = match pattern {
|
||||
Some(pattern) => Some(filter_lines(text, pattern)?),
|
||||
None => None,
|
||||
};
|
||||
let stream = filtered.as_deref().unwrap_or(text);
|
||||
let max_bytes = max_bytes
|
||||
.unwrap_or(DEFAULT_TEXT_MAX_BYTES)
|
||||
.min(TEXT_MAX_BYTES_CLAMP);
|
||||
let total_bytes = stream.len();
|
||||
let mut start = offset.min(total_bytes);
|
||||
while !stream.is_char_boundary(start) {
|
||||
start += 1;
|
||||
}
|
||||
let mut end = start.saturating_add(max_bytes).min(total_bytes);
|
||||
while !stream.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
let truncated = end < total_bytes;
|
||||
Ok(RenderedText {
|
||||
text: stream[start..end].to_string(),
|
||||
truncated,
|
||||
total_bytes,
|
||||
next_offset: truncated.then_some(end),
|
||||
})
|
||||
}
|
||||
|
||||
/// Decodes a base64 blob, returning it as text when it is valid UTF-8 and
|
||||
/// spilling it under `cache_dir()/mcp-resources/<server>/` otherwise.
|
||||
pub fn render_blob(
|
||||
b64: &str,
|
||||
claimed_mime: Option<&str>,
|
||||
server: &str,
|
||||
) -> Result<RenderedBlob, RenderError> {
|
||||
let spill_base = paths::cache_dir().join("mcp-resources");
|
||||
render_blob_at(b64, claimed_mime, server, &spill_base)
|
||||
}
|
||||
|
||||
pub fn render_blob_at(
|
||||
b64: &str,
|
||||
claimed_mime: Option<&str>,
|
||||
server: &str,
|
||||
spill_base: &Path,
|
||||
) -> Result<RenderedBlob, RenderError> {
|
||||
let decoded = decode_base64_bounded(b64)?;
|
||||
let decoded = match String::from_utf8(decoded) {
|
||||
Ok(text) => return Ok(RenderedBlob::Text(text)),
|
||||
Err(error) => error.into_bytes(),
|
||||
};
|
||||
let sha256 = format!("{:x}", Sha256::digest(&decoded));
|
||||
let dir = spill_base.join(sanitize_server(server));
|
||||
fs::create_dir_all(&dir)?;
|
||||
let path = dir.join(format!("{sha256}.{}", extension_for_mime(claimed_mime)));
|
||||
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
options.mode(0o600);
|
||||
match options.open(&path) {
|
||||
Ok(mut file) => file.write_all(&decoded)?,
|
||||
// Same sha, same content: an existing spill file is already correct.
|
||||
Err(error) if error.kind() == ErrorKind::AlreadyExists => {}
|
||||
Err(error) => return Err(RenderError::Io(error)),
|
||||
}
|
||||
enforce_spill_bound(spill_base, SPILL_DIR_MAX_BYTES, &path);
|
||||
|
||||
Ok(RenderedBlob::Spilled(SpillMetadata {
|
||||
spilled: true,
|
||||
path,
|
||||
mime_type: claimed_mime.map(str::to_string),
|
||||
sniffed: false,
|
||||
size_bytes: decoded.len() as u64,
|
||||
sha256,
|
||||
}))
|
||||
}
|
||||
|
||||
fn filter_lines(text: &str, pattern: &str) -> Result<String, RenderError> {
|
||||
let regex = Regex::new(pattern).map_err(|error| RenderError::InvalidPattern {
|
||||
pattern: pattern.to_string(),
|
||||
error: error.to_string(),
|
||||
})?;
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
// fancy_regex can also fail at match time (backtracking limits); treat
|
||||
// that as a non-match rather than failing the whole render.
|
||||
let is_match: Vec<bool> = lines
|
||||
.iter()
|
||||
.map(|line| regex.is_match(line).unwrap_or(false))
|
||||
.collect();
|
||||
|
||||
let mut keep = vec![false; lines.len()];
|
||||
for (i, _) in is_match.iter().enumerate().filter(|&(_, matched)| *matched) {
|
||||
let start = i.saturating_sub(PATTERN_CONTEXT_LINES);
|
||||
let end = (i + PATTERN_CONTEXT_LINES).min(lines.len() - 1);
|
||||
keep[start..=end].fill(true);
|
||||
}
|
||||
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut prev_kept: Option<usize> = None;
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
if !keep[i] {
|
||||
continue;
|
||||
}
|
||||
if prev_kept.is_some_and(|prev| i > prev + 1) {
|
||||
out.push(HUNK_SEPARATOR.to_string());
|
||||
}
|
||||
let marker = if is_match[i] { ':' } else { '-' };
|
||||
out.push(format!("{}{marker}{line}", i + 1));
|
||||
prev_kept = Some(i);
|
||||
}
|
||||
Ok(out.join("\n"))
|
||||
}
|
||||
|
||||
fn decode_base64_bounded(b64: &str) -> Result<Vec<u8>, RenderError> {
|
||||
// The encoded length puts a lower bound on the decoded size; reject
|
||||
// inputs that bound already proves oversized before decoding anything.
|
||||
let min_decoded = (b64.len() / 4).saturating_mul(3).saturating_sub(2);
|
||||
if min_decoded > BLOB_DECODE_CEILING_BYTES {
|
||||
return Err(RenderError::DecodedSizeExceeded);
|
||||
}
|
||||
|
||||
let mut reader = DecoderReader::new(b64.as_bytes(), &STANDARD);
|
||||
let mut decoded = Vec::new();
|
||||
let mut chunk = [0u8; 8192];
|
||||
loop {
|
||||
match reader.read(&mut chunk) {
|
||||
Ok(0) => return Ok(decoded),
|
||||
Ok(n) => {
|
||||
if decoded.len() + n > BLOB_DECODE_CEILING_BYTES {
|
||||
return Err(RenderError::DecodedSizeExceeded);
|
||||
}
|
||||
decoded.extend_from_slice(&chunk[..n]);
|
||||
}
|
||||
Err(error) => return Err(RenderError::InvalidBase64(error.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a server-controlled mime type to a spill-file extension via an exact
|
||||
/// allowlist lookup; anything unrecognized falls back to `bin`.
|
||||
fn extension_for_mime(mime: Option<&str>) -> &'static str {
|
||||
let Some(mime) = mime else {
|
||||
return "bin";
|
||||
};
|
||||
let bare = mime
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
let ext = MIME_EXTENSIONS
|
||||
.iter()
|
||||
.find(|(known, _)| *known == bare)
|
||||
.map(|(_, ext)| *ext)
|
||||
.unwrap_or("bin");
|
||||
let safe = !ext.is_empty()
|
||||
&& ext.len() <= 8
|
||||
&& ext
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit());
|
||||
if safe { ext } else { "bin" }
|
||||
}
|
||||
|
||||
fn sanitize_server(server: &str) -> String {
|
||||
let sanitized: String = server
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if sanitized.is_empty() || sanitized == "." || sanitized == ".." {
|
||||
"_".to_string()
|
||||
} else {
|
||||
sanitized
|
||||
}
|
||||
}
|
||||
|
||||
struct SpillEntry {
|
||||
path: PathBuf,
|
||||
size: u64,
|
||||
modified: SystemTime,
|
||||
}
|
||||
|
||||
fn enforce_spill_bound(base: &Path, max_total: u64, protect: &Path) {
|
||||
let mut entries = Vec::new();
|
||||
collect_spill_files(base, &mut entries);
|
||||
evict_oldest(entries, max_total, protect);
|
||||
}
|
||||
|
||||
/// Best-effort eviction: the spill dir is shared across processes, so a file
|
||||
/// vanishing underneath us (`NotFound`) is expected and never fails the spill.
|
||||
fn evict_oldest(mut entries: Vec<SpillEntry>, max_total: u64, protect: &Path) {
|
||||
let mut total: u64 = entries.iter().map(|entry| entry.size).sum();
|
||||
if total <= max_total {
|
||||
return;
|
||||
}
|
||||
entries.sort_by_key(|entry| entry.modified);
|
||||
for entry in &entries {
|
||||
if total <= max_total {
|
||||
break;
|
||||
}
|
||||
if entry.path == *protect {
|
||||
continue;
|
||||
}
|
||||
match fs::remove_file(&entry.path) {
|
||||
Ok(()) => total -= entry.size,
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => total -= entry.size,
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_spill_files(dir: &Path, out: &mut Vec<SpillEntry>) {
|
||||
let Ok(entries) = fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let Ok(metadata) = entry.metadata() else {
|
||||
continue;
|
||||
};
|
||||
if metadata.is_dir() {
|
||||
collect_spill_files(&path, out);
|
||||
} else if metadata.is_file() {
|
||||
out.push(SpillEntry {
|
||||
path,
|
||||
size: metadata.len(),
|
||||
modified: metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use base64::Engine;
|
||||
use std::env;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::process;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
fn with_spill_base<F: FnOnce(&Path)>(f: F) {
|
||||
static COUNTER: AtomicUsize = AtomicUsize::new(0);
|
||||
let unique = format!(
|
||||
"{}-{}",
|
||||
process::id(),
|
||||
COUNTER.fetch_add(1, Ordering::Relaxed)
|
||||
);
|
||||
let base = env::temp_dir().join(format!("coyote-render-test-{unique}"));
|
||||
fs::create_dir_all(&base).unwrap();
|
||||
f(&base);
|
||||
let _ = fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
fn set_mtime(path: &Path, secs_after_epoch: u64) {
|
||||
let file = OpenOptions::new().write(true).open(path).unwrap();
|
||||
file.set_modified(SystemTime::UNIX_EPOCH + Duration::from_secs(secs_after_epoch))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn write_spill_file(dir: &Path, name: &str, len: usize, mtime_secs: u64) -> PathBuf {
|
||||
let path = dir.join(name);
|
||||
fs::write(&path, vec![0u8; len]).unwrap();
|
||||
set_mtime(&path, mtime_secs);
|
||||
path
|
||||
}
|
||||
|
||||
const TEN_LINES: &str = "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten";
|
||||
|
||||
#[test]
|
||||
fn slices_basic_ascii_page() {
|
||||
let rendered = render_text("hello world", None, 0, Some(5)).unwrap();
|
||||
|
||||
assert_eq!(rendered.text, "hello");
|
||||
assert!(rendered.truncated);
|
||||
assert_eq!(rendered.total_bytes, 11);
|
||||
assert_eq!(rendered.next_offset, Some(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_mid_codepoint_rounds_forward() {
|
||||
// 'é' occupies bytes 1..3; offset 2 lands inside it.
|
||||
let rendered = render_text("héllo", None, 2, None).unwrap();
|
||||
|
||||
assert_eq!(rendered.text, "llo");
|
||||
assert!(!rendered.truncated);
|
||||
assert_eq!(rendered.next_offset, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_mid_codepoint_rounds_backward() {
|
||||
// 'é' occupies bytes 1..3; offset 0 + max_bytes 2 lands inside it.
|
||||
let rendered = render_text("aé", None, 0, Some(2)).unwrap();
|
||||
|
||||
assert_eq!(rendered.text, "a");
|
||||
assert!(rendered.truncated);
|
||||
assert_eq!(rendered.total_bytes, 3);
|
||||
assert_eq!(rendered.next_offset, Some(1));
|
||||
|
||||
let rest = render_text("aé", None, 1, Some(2)).unwrap();
|
||||
|
||||
assert_eq!(rest.text, "é");
|
||||
assert!(!rest.truncated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_past_eof_returns_empty() {
|
||||
let rendered = render_text("short", None, 100, None).unwrap();
|
||||
|
||||
assert_eq!(rendered.text, "");
|
||||
assert!(!rendered.truncated);
|
||||
assert_eq!(rendered.total_bytes, 5);
|
||||
assert_eq!(rendered.next_offset, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_fit_is_not_truncated() {
|
||||
let rendered = render_text("exact", None, 0, Some(5)).unwrap();
|
||||
|
||||
assert_eq!(rendered.text, "exact");
|
||||
assert!(!rendered.truncated);
|
||||
assert_eq!(rendered.next_offset, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_max_bytes_is_default_text_max_bytes() {
|
||||
let text = "a".repeat(DEFAULT_TEXT_MAX_BYTES + 1);
|
||||
|
||||
let rendered = render_text(&text, None, 0, None).unwrap();
|
||||
|
||||
assert_eq!(rendered.text.len(), DEFAULT_TEXT_MAX_BYTES);
|
||||
assert!(rendered.truncated);
|
||||
assert_eq!(rendered.next_offset, Some(DEFAULT_TEXT_MAX_BYTES));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_bytes_above_clamp_is_clamped() {
|
||||
let text = "a".repeat(TEXT_MAX_BYTES_CLAMP + 1);
|
||||
|
||||
let rendered = render_text(&text, None, 0, Some(usize::MAX)).unwrap();
|
||||
|
||||
assert_eq!(rendered.text.len(), TEXT_MAX_BYTES_CLAMP);
|
||||
assert!(rendered.truncated);
|
||||
assert_eq!(rendered.next_offset, Some(TEXT_MAX_BYTES_CLAMP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_emits_matches_with_context_and_line_numbers() {
|
||||
let rendered = render_text(TEN_LINES, Some("^five$"), 0, None).unwrap();
|
||||
|
||||
assert_eq!(rendered.text, "3-three\n4-four\n5:five\n6-six\n7-seven");
|
||||
assert!(!rendered.truncated);
|
||||
assert_eq!(rendered.total_bytes, rendered.text.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_separates_disjoint_hunks() {
|
||||
let rendered = render_text(TEN_LINES, Some("^(two|nine)$"), 0, None).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
rendered.text,
|
||||
"1-one\n2:two\n3-three\n4-four\n--\n7-seven\n8-eight\n9:nine\n10-ten"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_merges_adjacent_hunks_without_duplicates() {
|
||||
let rendered = render_text(TEN_LINES, Some("^(two|six)$"), 0, None).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
rendered.text,
|
||||
"1-one\n2:two\n3-three\n4-four\n5-five\n6:six\n7-seven\n8-eight"
|
||||
);
|
||||
assert!(!rendered.text.contains(HUNK_SEPARATOR));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_paging_walks_the_filtered_stream() {
|
||||
let full = render_text(TEN_LINES, Some("^t"), 0, None).unwrap();
|
||||
assert!(!full.truncated);
|
||||
|
||||
let mut assembled = String::new();
|
||||
let mut offset = 0;
|
||||
loop {
|
||||
let page = render_text(TEN_LINES, Some("^t"), offset, Some(7)).unwrap();
|
||||
assert_eq!(page.total_bytes, full.text.len());
|
||||
assembled.push_str(&page.text);
|
||||
match page.next_offset {
|
||||
Some(next) => offset = next,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(assembled, full.text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_with_no_matches_returns_empty() {
|
||||
let rendered = render_text(TEN_LINES, Some("^zebra$"), 0, None).unwrap();
|
||||
|
||||
assert_eq!(rendered.text, "");
|
||||
assert_eq!(rendered.total_bytes, 0);
|
||||
assert!(!rendered.truncated);
|
||||
assert_eq!(rendered.next_offset, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_pattern_is_a_teaching_error() {
|
||||
let parse_error = Regex::new("(").unwrap_err().to_string();
|
||||
|
||||
let err = render_text("text", Some("("), 0, None).unwrap_err();
|
||||
|
||||
assert!(matches!(err, RenderError::InvalidPattern { .. }));
|
||||
let message = err.to_string();
|
||||
assert!(message.contains("'('"));
|
||||
assert!(message.contains(&parse_error));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf8_blob_decodes_to_text_without_spilling() {
|
||||
with_spill_base(|base| {
|
||||
let b64 = STANDARD.encode("hello ✓ world");
|
||||
|
||||
let rendered = render_blob_at(&b64, Some("text/plain"), "srv", base).unwrap();
|
||||
|
||||
let RenderedBlob::Text(text) = rendered else {
|
||||
panic!("expected text variant");
|
||||
};
|
||||
assert_eq!(text, "hello ✓ world");
|
||||
assert_eq!(fs::read_dir(base).unwrap().count(), 0);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_blob_spills_with_metadata_and_0600_perms() {
|
||||
with_spill_base(|base| {
|
||||
let data: &[u8] = &[0xff, 0xfe, 0x00, 0x88, 0x01];
|
||||
let b64 = STANDARD.encode(data);
|
||||
|
||||
let rendered = render_blob_at(&b64, Some("application/pdf"), "docs", base).unwrap();
|
||||
|
||||
let RenderedBlob::Spilled(meta) = rendered else {
|
||||
panic!("expected spilled variant");
|
||||
};
|
||||
let expected_sha = format!("{:x}", Sha256::digest(data));
|
||||
assert_eq!(meta.sha256, expected_sha);
|
||||
assert_eq!(
|
||||
meta.path,
|
||||
base.join("docs").join(format!("{expected_sha}.pdf"))
|
||||
);
|
||||
assert_eq!(meta.size_bytes, data.len() as u64);
|
||||
assert_eq!(meta.mime_type.as_deref(), Some("application/pdf"));
|
||||
assert!(!meta.sniffed);
|
||||
assert!(meta.spilled);
|
||||
assert_eq!(fs::read(&meta.path).unwrap(), data);
|
||||
let mode = fs::metadata(&meta.path).unwrap().permissions().mode();
|
||||
assert_eq!(mode & 0o777, 0o600);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_ceiling_rejects_oversized_blob() {
|
||||
with_spill_base(|base| {
|
||||
// base64 of 51 MiB of zero bytes is just a repeated-'A' string.
|
||||
let encoded = "A".repeat(51 * 1024 * 1024 / 3 * 4);
|
||||
|
||||
let err = render_blob_at(&encoded, None, "srv", base).unwrap_err();
|
||||
|
||||
assert!(matches!(err, RenderError::DecodedSizeExceeded));
|
||||
assert!(err.to_string().contains("BLOB_DECODE_CEILING_BYTES"));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_base64_is_rejected() {
|
||||
with_spill_base(|base| {
|
||||
let err = render_blob_at("!!!not base64!!!", None, "srv", base).unwrap_err();
|
||||
|
||||
assert!(matches!(err, RenderError::InvalidBase64(_)));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spill_dedup_returns_same_path_without_rewriting() {
|
||||
with_spill_base(|base| {
|
||||
let data: &[u8] = &[0xff, 0x01, 0x02];
|
||||
let b64 = STANDARD.encode(data);
|
||||
|
||||
let RenderedBlob::Spilled(first) = render_blob_at(&b64, None, "srv", base).unwrap()
|
||||
else {
|
||||
panic!("expected spilled variant");
|
||||
};
|
||||
fs::write(&first.path, b"sentinel").unwrap();
|
||||
|
||||
let RenderedBlob::Spilled(second) = render_blob_at(&b64, None, "srv", base).unwrap()
|
||||
else {
|
||||
panic!("expected spilled variant");
|
||||
};
|
||||
|
||||
assert_eq!(second.path, first.path);
|
||||
assert_eq!(second.sha256, first.sha256);
|
||||
assert_eq!(fs::read(&second.path).unwrap(), b"sentinel");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spill_metadata_serializes_spilled_true() {
|
||||
with_spill_base(|base| {
|
||||
let b64 = STANDARD.encode([0xffu8, 0x00]);
|
||||
|
||||
let RenderedBlob::Spilled(meta) =
|
||||
render_blob_at(&b64, Some("image/png"), "srv", base).unwrap()
|
||||
else {
|
||||
panic!("expected spilled variant");
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(&meta).unwrap();
|
||||
assert_eq!(value["spilled"], serde_json::Value::Bool(true));
|
||||
assert_eq!(value["sniffed"], serde_json::Value::Bool(false));
|
||||
assert_eq!(value["sha256"].as_str(), Some(meta.sha256.as_str()));
|
||||
assert_eq!(value["mime_type"].as_str(), Some("image/png"));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_allowlist_normalizes_and_defaults_to_bin() {
|
||||
assert_eq!(extension_for_mime(Some("application/pdf")), "pdf");
|
||||
assert_eq!(extension_for_mime(Some("image/png")), "png");
|
||||
assert_eq!(extension_for_mime(Some(" TEXT/CSV ; charset=utf-8")), "csv");
|
||||
assert_eq!(extension_for_mime(Some("../../evil")), "bin");
|
||||
assert_eq!(extension_for_mime(Some("image/png/../../x")), "bin");
|
||||
assert_eq!(extension_for_mime(Some("application/x-∞")), "bin");
|
||||
assert_eq!(extension_for_mime(Some("text/plain")), "bin");
|
||||
assert_eq!(extension_for_mime(None), "bin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_server_strips_path_separators() {
|
||||
assert_eq!(sanitize_server("../evil/srv"), ".._evil_srv");
|
||||
assert_eq!(sanitize_server("srv name!"), "srv_name_");
|
||||
assert_eq!(sanitize_server(""), "_");
|
||||
assert_eq!(sanitize_server("."), "_");
|
||||
assert_eq!(sanitize_server(".."), "_");
|
||||
assert_eq!(sanitize_server("good-server_1.0"), "good-server_1.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spill_path_confines_crafted_server_and_mime() {
|
||||
with_spill_base(|base| {
|
||||
let b64 = STANDARD.encode([0xffu8, 0x00, 0x11]);
|
||||
|
||||
let RenderedBlob::Spilled(meta) =
|
||||
render_blob_at(&b64, Some("../../evil"), "../evil/srv", base).unwrap()
|
||||
else {
|
||||
panic!("expected spilled variant");
|
||||
};
|
||||
|
||||
assert!(meta.path.starts_with(base));
|
||||
let dir_name = meta.path.parent().unwrap().file_name().unwrap();
|
||||
assert_eq!(dir_name, ".._evil_srv");
|
||||
assert_eq!(meta.path.extension().unwrap(), "bin");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eviction_removes_oldest_files_first_across_server_dirs() {
|
||||
with_spill_base(|base| {
|
||||
let srv_a = base.join("srv-a");
|
||||
let srv_b = base.join("srv-b");
|
||||
fs::create_dir_all(&srv_a).unwrap();
|
||||
fs::create_dir_all(&srv_b).unwrap();
|
||||
let oldest = write_spill_file(&srv_a, "a.bin", 100, 100);
|
||||
let middle = write_spill_file(&srv_b, "b.bin", 100, 200);
|
||||
let newest = write_spill_file(&srv_b, "c.bin", 100, 300);
|
||||
|
||||
enforce_spill_bound(base, 150, &newest);
|
||||
|
||||
assert!(!oldest.exists());
|
||||
assert!(!middle.exists());
|
||||
assert!(newest.exists());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eviction_skips_protected_file() {
|
||||
with_spill_base(|base| {
|
||||
let srv = base.join("srv");
|
||||
fs::create_dir_all(&srv).unwrap();
|
||||
let oldest = write_spill_file(&srv, "a.bin", 100, 100);
|
||||
let middle = write_spill_file(&srv, "b.bin", 100, 200);
|
||||
let newest = write_spill_file(&srv, "c.bin", 100, 300);
|
||||
|
||||
enforce_spill_bound(base, 250, &oldest);
|
||||
|
||||
assert!(oldest.exists());
|
||||
assert!(!middle.exists());
|
||||
assert!(newest.exists());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eviction_under_bound_is_noop() {
|
||||
with_spill_base(|base| {
|
||||
let srv = base.join("srv");
|
||||
fs::create_dir_all(&srv).unwrap();
|
||||
let first = write_spill_file(&srv, "a.bin", 100, 100);
|
||||
let second = write_spill_file(&srv, "b.bin", 100, 200);
|
||||
|
||||
enforce_spill_bound(base, 1000, &second);
|
||||
|
||||
assert!(first.exists());
|
||||
assert!(second.exists());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eviction_tolerates_already_removed_entries() {
|
||||
with_spill_base(|base| {
|
||||
let srv = base.join("srv");
|
||||
fs::create_dir_all(&srv).unwrap();
|
||||
let real = write_spill_file(&srv, "real.bin", 100, 200);
|
||||
let entries = vec![
|
||||
SpillEntry {
|
||||
path: srv.join("ghost.bin"),
|
||||
size: 100,
|
||||
modified: SystemTime::UNIX_EPOCH + Duration::from_secs(100),
|
||||
},
|
||||
SpillEntry {
|
||||
path: real.clone(),
|
||||
size: 100,
|
||||
modified: SystemTime::UNIX_EPOCH + Duration::from_secs(200),
|
||||
},
|
||||
];
|
||||
|
||||
evict_oldest(entries, 50, &base.join("untouched"));
|
||||
|
||||
assert!(!real.exists());
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user