diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index c13215c..64f6e88 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -1,6 +1,7 @@ mod auth_client; pub(crate) mod manage; pub(crate) mod oauth; +pub(crate) mod render; mod sse_transport; use crate::config::AppConfig; diff --git a/src/mcp/render.rs b/src/mcp/render.rs new file mode 100644 index 0000000..771e4fc --- /dev/null +++ b/src/mcp/render.rs @@ -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 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, +} + +#[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, + 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, +) -> Result { + 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//` otherwise. +pub fn render_blob( + b64: &str, + claimed_mime: Option<&str>, + server: &str, +) -> Result { + 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 { + 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 { + 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 = 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 = Vec::new(); + let mut prev_kept: Option = 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, 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, 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) { + 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: 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()); + }); + } +}