Skip to main content

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;