velesdb_memory/context/ingest.rs
1//! Adapter-side I/O pre-pass for `path`-referenced context fragments
2//! (V2b-1, see the crate's `PLAN.md`).
3//!
4//! [`resolve_fragments`] turns every [`ContextFragment::path`] into ordinary
5//! `content` — read from disk under a strict, short-circuiting security
6//! pipeline — BEFORE the request reaches [`super::ContextCompiler`], which
7//! never performs I/O itself (mirrors the [`super::media`] pre-pass: decode
8//! once at the boundary, keep the pipeline core pure). Called from the MCP
9//! adapter (`crate::mcp::context_tools`) ahead of `compile_context` and
10//! `explain_compilation`; a `path` fragment that reaches the compiler
11//! unresolved is rejected with [`MemoryError::IngestDisabled`] (see
12//! `context::validate`) rather than silently compiling empty content.
13//!
14//! # Security model
15//!
16//! Path ingestion is opt-in and allowlisted: nothing is readable unless
17//! [`IngestRoots::parse`] was given at least one root
18//! (`VELESDB_MEMORY_INGEST_ROOTS`, platform `PATH`-list syntax). Every
19//! `path` fragment then runs this ordered, short-circuiting pipeline:
20//!
21//! 1. Shape: `path` is exclusive — it is resolved INTO `content`, so a
22//! fragment setting both is refused — while `content` and `media` MAY
23//! travel together (an image and its caption). A fragment carrying none
24//! of the three is refused too. Checked first, independent of whether
25//! ingestion is even enabled (a malformed request is a malformed
26//! request regardless of server configuration).
27//! 2. At least one configured root, else [`MemoryError::IngestDisabled`].
28//! 3. The number of `path` fragments in the request is bounded
29//! ([`crate::limits::MAX_INGEST_FILES`]).
30//! 4. The path must be absolute — an MCP server's working directory is not
31//! something a caller can rely on.
32//! 5. [`std::fs::canonicalize`] resolves *every* symlink in one call.
33//! 6. The canonical path is checked against the canonical roots
34//! **component-wise** (never a string prefix, which a sibling directory
35//! name like `/root-evil` next to `/root` would defeat). On failure the
36//! error cites the path the caller ASKED for, never the resolved
37//! target — the target itself can be sensitive (e.g. that a symlink
38//! escapes to a specific system path).
39//! 7. `fs::metadata` must report a plain file, within
40//! [`crate::limits::MAX_INGEST_FILE_BYTES`] and the request's running
41//! [`crate::limits::MAX_TOTAL_INGEST_BYTES`] — checked BEFORE any read.
42//! 8. `fs::read`, a re-check of the length actually read, then
43//! `String::from_utf8`; non-UTF-8 content is rejected (never lossily
44//! decoded), with a short hint when the leading bytes look like a known
45//! image format.
46//!
47//! Symlinks are allowed as long as their canonical target lands under a
48//! root — no special-casing needed, step 5 already resolves them. TOCTOU
49//! (a file changing between steps 7 and 8, or after) is an accepted,
50//! documented non-goal: this is a local, single-user server, and the
51//! deterministic contract is on the bytes actually read, not on the file as
52//! it exists at any other instant.
53
54use std::fs;
55use std::path::{Path, PathBuf};
56
57use crate::context::model::ContextFragment;
58use crate::error::MemoryError;
59use crate::limits::{
60 MAX_INGEST_FILES, MAX_INGEST_FILE_BYTES, MAX_TOTAL_INGEST_BYTES, MAX_TRANSCRIPT_BYTES,
61};
62
63/// A parsed, canonicalized allowlist of filesystem roots a `path` fragment
64/// may resolve under. The only way to construct one is [`Self::parse`] —
65/// there is no "allow everything" escape hatch.
66#[derive(Debug, Clone, Default)]
67pub struct IngestRoots {
68 roots: Vec<PathBuf>,
69}
70
71impl IngestRoots {
72 /// Parse `VELESDB_MEMORY_INGEST_ROOTS`'s value: a platform `PATH`-list
73 /// (`:`-separated on Unix, `;`-separated on Windows, via
74 /// [`std::env::split_paths`]) of directories, each canonicalized
75 /// immediately. Fails fast — a root that does not exist or is not a
76 /// directory is a startup configuration error, not something to
77 /// discover later on a caller's first `path` fragment. An empty or
78 /// unset value parses to an empty (disabled) allowlist, not an error.
79 ///
80 /// # Errors
81 /// A human-readable message naming the offending entry.
82 pub fn parse(value: &str) -> Result<Self, String> {
83 let mut roots = Vec::new();
84 for raw in std::env::split_paths(value) {
85 if raw.as_os_str().is_empty() {
86 continue;
87 }
88 let canonical = fs::canonicalize(&raw).map_err(|err| {
89 format!(
90 "VELESDB_MEMORY_INGEST_ROOTS entry '{}' could not be resolved: {err}",
91 raw.display()
92 )
93 })?;
94 if !canonical.is_dir() {
95 return Err(format!(
96 "VELESDB_MEMORY_INGEST_ROOTS entry '{}' is not a directory",
97 raw.display()
98 ));
99 }
100 roots.push(canonical);
101 }
102 Ok(Self { roots })
103 }
104
105 /// Whether path ingestion is enabled at all (at least one root
106 /// configured). `false` short-circuits every `path` fragment with
107 /// [`MemoryError::IngestDisabled`] before any filesystem access.
108 #[must_use]
109 pub fn is_enabled(&self) -> bool {
110 !self.roots.is_empty()
111 }
112
113 /// Whether the already-canonical `candidate` sits under one of the
114 /// roots, checked path-COMPONENT-wise via [`Path::starts_with`] — never
115 /// a string prefix, which would wrongly accept a sibling directory that
116 /// merely shares a prefix (`/root-evil` under a root of `/root`).
117 fn contains(&self, candidate: &Path) -> bool {
118 self.roots.iter().any(|root| candidate.starts_with(root))
119 }
120}
121
122/// The per-fragment shape rules of [`resolve_fragments`]. Two rules, and
123/// they are NOT the same rule read twice:
124///
125/// (a) `path` is EXCLUSIVE. It is resolved into `content` later, so a
126/// fragment carrying both would have its inline text silently
127/// overwritten by the file's.
128/// (b) a fragment must carry SOMETHING. `content` + `media` together is
129/// the supported shape, not a violation — the caption is the only
130/// text lexical relevance can read for an image, and the packer
131/// bills both (`image_tokens` + the caption's estimate). What is
132/// refused is the fragment that carries none of the three: it can
133/// only ever contribute an empty section, and letting it through
134/// turns a caller's typo into a silent no-op.
135fn check_fragment_shape(fragment: &ContextFragment) -> Result<(), MemoryError> {
136 if fragment.path.is_some() && (!fragment.content.is_empty() || fragment.media.is_some()) {
137 return Err(MemoryError::IngestPath(
138 "a fragment may set `path`, or inline `content`/`media` — never both".to_owned(),
139 ));
140 }
141 if fragment.path.is_none() && fragment.content.is_empty() && fragment.media.is_none() {
142 return Err(MemoryError::IngestPath(
143 "a fragment must carry `path`, non-empty `content`, or `media` — this one carries none"
144 .to_owned(),
145 ));
146 }
147 Ok(())
148}
149
150/// Resolve every `path`-carrying fragment of `fragments` into `content`, in
151/// place, in one pre-pass — see the module docs for the full ordered
152/// pipeline. A no-op (and roots-independent) when no fragment carries a
153/// `path`. The first fragment to fail short-circuits the whole request:
154/// never a partial resolution, so a caller never has to guess which
155/// fragments actually got read.
156///
157/// # Errors
158/// [`MemoryError::IngestPath`] for a malformed request shape or an
159/// unreadable/non-UTF-8 path, [`MemoryError::IngestDisabled`] when no root
160/// is configured, [`MemoryError::IngestOutsideRoots`] when a path (after
161/// resolving symlinks) escapes every root, and [`MemoryError::ContextOverLimit`]
162/// for the file-count and byte caps.
163pub fn resolve_fragments(
164 fragments: &mut [ContextFragment],
165 roots: Option<&IngestRoots>,
166) -> Result<(), MemoryError> {
167 // Step 1 (shape), checked for every fragment up front, independent of
168 // whether ingestion is enabled at all.
169 for fragment in fragments.iter() {
170 check_fragment_shape(fragment)?;
171 }
172
173 let indices: Vec<usize> = fragments
174 .iter()
175 .enumerate()
176 .filter(|(_, fragment)| fragment.path.is_some())
177 .map(|(index, _)| index)
178 .collect();
179 if indices.is_empty() {
180 return Ok(());
181 }
182
183 // Step 2: ingestion must be enabled at all.
184 let Some(roots) = roots.filter(|roots| roots.is_enabled()) else {
185 return Err(MemoryError::IngestDisabled);
186 };
187
188 // Step 3: bound the number of files one request may reference.
189 if indices.len() > MAX_INGEST_FILES {
190 return Err(MemoryError::ContextOverLimit(format!(
191 "request references {} files via `path`, exceeding the cap of {MAX_INGEST_FILES}",
192 indices.len()
193 )));
194 }
195
196 let mut total_bytes: usize = 0;
197 for index in indices {
198 // Indices were filtered on `path.is_some()` above; `take` both fetches
199 // the path and clears it without a panicking unwrap. On error the whole
200 // request is rejected, so the cleared field is never observed.
201 let Some(requested) = fragments[index].path.take() else {
202 continue;
203 };
204 let content = resolve_one(&requested, roots, &mut total_bytes, MAX_INGEST_FILE_BYTES)?;
205 fragments[index].content = content;
206 }
207 Ok(())
208}
209
210/// Resolve a `compile_transcript` transcript's `path` field (V2b-2): the same
211/// ordered security pipeline as [`resolve_fragments`]'s `path` handling
212/// (steps 4 through 8 below), but with [`MAX_TRANSCRIPT_BYTES`] in place of
213/// [`MAX_INGEST_FILE_BYTES`] — a transcript is the one caller-facing shape
214/// allowed to read past the ordinary 1 MiB fragment ceiling, because
215/// [`super::segment::segment_transcript`] segments it into sub-1-MiB pieces
216/// immediately after this read, never compiling it as one oversized
217/// fragment. `roots` must already be checked enabled by the caller (the MCP
218/// adapter mirrors [`resolve_fragments`]'s step 2 before calling this).
219///
220/// # Errors
221/// [`MemoryError::IngestPath`] for a relative path, an unreadable path, a
222/// non-plain-file, or non-UTF-8 content; [`MemoryError::IngestOutsideRoots`]
223/// when the canonicalized path escapes every root; [`MemoryError::ContextOverLimit`]
224/// when the file exceeds [`MAX_TRANSCRIPT_BYTES`].
225///
226/// `pub`, not `pub(crate)` — like [`resolve_fragments`], part of the public
227/// `context::ingest` surface (the `mcp` adapter is its first caller, but the
228/// module itself only requires `context`; a build with `context` and no
229/// `mcp` would otherwise flag this crate-internal-only function dead code).
230pub fn resolve_transcript_path(
231 requested: &str,
232 roots: &IngestRoots,
233) -> Result<String, MemoryError> {
234 let mut total_bytes: usize = 0;
235 resolve_one(requested, roots, &mut total_bytes, MAX_TRANSCRIPT_BYTES)
236}
237
238/// Resolve a single `path` fragment's content — steps 4 through 8 of the
239/// module-level pipeline, each short-circuiting on failure. `total_bytes`
240/// accumulates across the whole request (the caller threads the same
241/// counter through every call) so the aggregate cap
242/// ([`MAX_TOTAL_INGEST_BYTES`]) is enforced across files, not just within
243/// one. `file_cap` is the per-file ceiling: [`MAX_INGEST_FILE_BYTES`] for an
244/// ordinary `path` fragment, [`MAX_TRANSCRIPT_BYTES`] for
245/// [`resolve_transcript_path`] (V2b-2) — parameterized rather than a second
246/// copy of this function, so the ordered pipeline (steps 4-8) can never drift
247/// between the two callers.
248fn resolve_one(
249 requested: &str,
250 roots: &IngestRoots,
251 total_bytes: &mut usize,
252 file_cap: usize,
253) -> Result<String, MemoryError> {
254 // Step 4: relative paths are refused outright.
255 let requested_path = Path::new(requested);
256 if requested_path.is_relative() {
257 return Err(MemoryError::IngestPath(format!(
258 "path '{requested}' is relative; only absolute paths are accepted"
259 )));
260 }
261
262 // Step 5: canonicalize resolves every symlink in one call, so the
263 // prefix check in step 6 can never be fooled by an intermediate one.
264 let canonical = fs::canonicalize(requested_path).map_err(|err| {
265 MemoryError::IngestPath(format!("cannot resolve path '{requested}': {err}"))
266 })?;
267
268 // Step 6: prefix check by path components against the canonical roots.
269 // Cites `requested`, never `canonical` — see the module docs.
270 if !roots.contains(&canonical) {
271 return Err(MemoryError::IngestOutsideRoots(requested.to_owned()));
272 }
273
274 // Step 7: metadata checks BEFORE any read.
275 let file_metadata = fs::metadata(&canonical)
276 .map_err(|err| MemoryError::IngestPath(format!("cannot stat path '{requested}': {err}")))?;
277 if !file_metadata.is_file() {
278 return Err(MemoryError::IngestPath(format!(
279 "path '{requested}' is not a regular file"
280 )));
281 }
282 let declared_len = usize::try_from(file_metadata.len()).unwrap_or(usize::MAX);
283 if declared_len > file_cap {
284 return Err(MemoryError::ContextOverLimit(format!(
285 "file '{requested}' is {declared_len} bytes, exceeding the cap of {file_cap} bytes"
286 )));
287 }
288 let running_total = total_bytes.saturating_add(declared_len);
289 if running_total > MAX_TOTAL_INGEST_BYTES {
290 return Err(MemoryError::ContextOverLimit(format!(
291 "ingesting '{requested}' would bring the request total to {running_total} bytes, \
292 exceeding the cap of {MAX_TOTAL_INGEST_BYTES} bytes"
293 )));
294 }
295
296 // Step 8: read, re-check the length actually read (the file may have
297 // changed since `metadata` — documented TOCTOU non-goal, but a race
298 // that would silently blow the byte cap is still caught), then decode.
299 let bytes = fs::read(&canonical)
300 .map_err(|err| MemoryError::IngestPath(format!("cannot read path '{requested}': {err}")))?;
301 if bytes.len() > file_cap {
302 return Err(MemoryError::ContextOverLimit(format!(
303 "file '{requested}' grew to {} bytes while being read, exceeding the cap of \
304 {file_cap} bytes",
305 bytes.len()
306 )));
307 }
308 *total_bytes = total_bytes.saturating_add(bytes.len());
309
310 String::from_utf8(bytes).map_err(|err| {
311 let hint = magic_bytes_hint(err.as_bytes());
312 MemoryError::IngestPath(format!("path '{requested}' is not valid UTF-8{hint}"))
313 })
314}
315
316/// A short, actionable suffix appended to the non-UTF-8 error when the
317/// file's leading bytes match a recognized image format's magic number —
318/// steering a caller who ingested a screenshot by mistake toward
319/// [`super::model::MediaRef`] instead of leaving them to guess why a
320/// `path` fragment failed. Recognizes exactly PNG and JPEG (the two
321/// formats [`super::estimator::ImageTokenEstimator`] special-cases);
322/// unrecognized binary content gets no hint, never a wrong guess.
323fn magic_bytes_hint(bytes: &[u8]) -> &'static str {
324 const PNG_MAGIC: [u8; 8] = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
325 const JPEG_MAGIC: [u8; 3] = [0xFF, 0xD8, 0xFF];
326 if bytes.starts_with(&PNG_MAGIC) || bytes.starts_with(&JPEG_MAGIC) {
327 " (looks like an image — use a media fragment instead of `path`)"
328 } else {
329 ""
330 }
331}
332
333#[cfg(test)]
334#[path = "ingest_tests.rs"]
335mod tests;