Skip to main content

lean_ctx/core/
rooted_read.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Rooted, admitted file readers shared by the engine and the MCP tools: every
3//! source is opened inside its root (no symlink escape), decoded, and admitted
4//! through the context gateway before its content is returned. Re-exported
5//! from `tools::ctx_read` for the tool layer.
6
7#[derive(Debug)]
8pub struct RootedRead {
9    pub content: String,
10    pub canonical_path: String,
11}
12
13/// A rooted read with the outcome of admitting its ORIGINAL bytes.
14#[derive(Debug)]
15pub struct AdmittedRootedRead {
16    /// The delivered text (project and gateway transformations applied).
17    pub content: String,
18    pub(crate) canonical_path: String,
19    /// Classification of the original source, before any redaction.
20    pub(crate) classification: lean_ctx_protocol::context_gateway::ClassificationV1,
21    /// Whether the original may enter a derived store (complete, not restricted).
22    pub storable: bool,
23    /// `false` when the bytes needed lossy decoding.
24    pub complete: bool,
25}
26
27#[derive(Debug)]
28struct DecodedRead {
29    content: String,
30    decode_lossy: bool,
31}
32
33/// Reads a file as UTF-8 with lossy fallback, enforcing binary detection and max read size limit.
34/// Defense-in-depth: verifies that the canonical path stays within the process's project root
35/// (if determinable) even though callers SHOULD have already jail-checked the path.
36pub fn read_file_lossy(path: &str) -> Result<String, std::io::Error> {
37    read_file_internal(path, "ctx_read", false)
38}
39
40/// Read and admit the original source for the CLI adapter. Unlike the generic
41/// unscoped reader, this explicit agent-facing path always applies the Gateway
42/// and carries the decoder's completeness into the admission decision.
43pub fn read_file_lossy_admitted_for_cli(
44    path: &str,
45) -> Result<crate::core::context_admission::SourceAdmission, std::io::Error> {
46    let decoded = read_file_decoded(path)?;
47    // The shared Gateway admission composes active project-policy transforms,
48    // but it does not emit the existing content-policy audit. Keep that
49    // privacy-safe audit on the original bytes; do not feed its transformed
50    // text to Gateway classification.
51    if let Some(policy) = crate::core::policy::runtime::active() {
52        let outcome = crate::core::policy::content::evaluate_text(&decoded.content, &policy);
53        crate::core::policy::audit_filter::audit_filter(
54            "ctx_read",
55            &outcome.audit,
56            outcome.blocked,
57        );
58    }
59    crate::core::context_admission::admit_project_file_with_storage(
60        &decoded.content,
61        path,
62        false,
63        crate::core::context_admission::OutputCompleteness {
64            decode_lossy: decoded.decode_lossy,
65            ..Default::default()
66        },
67    )
68}
69
70/// Acquire an original source under its project authority through a rooted descriptor.
71/// Parsing and derived metadata must use the returned, admitted content.
72pub fn read_file_for_tool_rooted(
73    path: &str,
74    root: &str,
75    tool: &str,
76) -> Result<String, std::io::Error> {
77    let mut remaining = crate::core::limits::max_read_bytes();
78    read_file_for_tool_rooted_budgeted(path, root, tool, &mut remaining)
79}
80
81/// Charge original bytes, including rejected sources, before filtering can shrink them.
82pub fn read_file_for_tool_rooted_budgeted(
83    path: &str,
84    root: &str,
85    tool: &str,
86    remaining_bytes: &mut usize,
87) -> Result<String, std::io::Error> {
88    read_file_for_tool_rooted_with_path(path, root, tool, remaining_bytes).map(|read| read.content)
89}
90
91pub fn read_file_for_tool_rooted_with_path(
92    path: &str,
93    root: &str,
94    tool: &str,
95    remaining_bytes: &mut usize,
96) -> Result<RootedRead, std::io::Error> {
97    read_file_for_tool_rooted_admitted(path, root, tool, remaining_bytes).map(|read| RootedRead {
98        content: read.content,
99        canonical_path: read.canonical_path,
100    })
101}
102
103/// Put the source a tool was asked for into the running call's lineage when
104/// the answer may not have read it (served from a cache or as a stub).
105///
106/// Under protection and inside a source capture, the file is read once
107/// through the admitted rooted reader — which publishes it, bound to what is
108/// on disk now. A source that can no longer be read that way (gone, outside
109/// the root, withheld, binary) makes the call's source set unknown. Without a
110/// capture or without protection this does nothing and reads nothing.
111///
112/// The read is not part of the call's delivery: it is kept out of the
113/// Decision Receipt so a cached answer is not counted as two admissions.
114pub fn publish_requested_source(path: &str, root: &str, tool: &str) {
115    if crate::core::archive::authority::current_capture().is_none()
116        || !crate::core::context_admission::stored::source_authority_required()
117    {
118        return;
119    }
120    let published = crate::core::context_admission::capture::ADMISSIONS.sync_scope(None, || {
121        let mut remaining = crate::core::limits::max_read_bytes();
122        read_file_for_tool_rooted_admitted(path, root, tool, &mut remaining).is_ok()
123    });
124    if !published {
125        crate::core::archive::authority::mark_unbound();
126    }
127}
128
129/// The common rooted read: descriptor-based path jail, budget charged on the
130/// original bytes, and one admission decided on the ORIGINAL decoded source.
131/// Returns the delivered text together with the outcome of that original
132/// admission, so a derived or stored item inherits the source's
133/// classification, storability and completeness instead of re-inferring them
134/// from text a project rule or the gateway has already rewritten.
135pub fn read_file_for_tool_rooted_admitted(
136    path: &str,
137    root: &str,
138    tool: &str,
139    remaining_bytes: &mut usize,
140) -> Result<AdmittedRootedRead, std::io::Error> {
141    read_file_for_tool_rooted_admitted_with(path, root, tool, remaining_bytes, None)
142}
143
144/// [`read_file_for_tool_rooted_admitted`] — the same read in every respect —
145/// with the gateway rules of a snapshot the caller captured (`Some`) instead
146/// of the configuration as it is at this moment (`None`): a caller that
147/// judges several things under one snapshot has its sources judged under
148/// that snapshot too.
149pub fn read_file_for_tool_rooted_admitted_with(
150    path: &str,
151    root: &str,
152    tool: &str,
153    remaining_bytes: &mut usize,
154    gateway: Option<&crate::core::context_admission::stores::StoreAdmission>,
155) -> Result<AdmittedRootedRead, std::io::Error> {
156    if crate::core::binary_detect::has_binary_extension(path) {
157        return Err(std::io::Error::other(
158            "source format is not inspectable as text",
159        ));
160    }
161    let authority = crate::core::policy::runtime::REQUEST_PROJECT
162        .try_with(|slot| slot.borrow().clone())
163        .ok()
164        .flatten()
165        .unwrap_or_else(|| std::path::PathBuf::from(root));
166    crate::core::policy::runtime::REQUEST_PROJECT.sync_scope(
167        std::cell::RefCell::new(Some(authority)),
168        || {
169            crate::core::io_boundary::jail_and_check_path(
170                tool,
171                std::path::Path::new(path),
172                std::path::Path::new(root),
173            )
174            .map_err(|_| {
175                std::io::Error::new(
176                    std::io::ErrorKind::PermissionDenied,
177                    "source path withheld by policy",
178                )
179            })?;
180            let (file, canonical_path) =
181                open_rooted_nofollow(std::path::Path::new(path), std::path::Path::new(root))?;
182            let decoded = read_open_file_lossy_bounded_decoded(
183                file,
184                path,
185                *remaining_bytes,
186                Some(remaining_bytes),
187            )?;
188            let complete = !decoded.decode_lossy;
189            let admission = admit_read_with(decoded, path, false, tool, gateway)?;
190            let canonical_path = canonical_path.to_string_lossy().into_owned();
191            // Source publication (G04): the call that is running learns that
192            // this source contributed. A no-op without a capture or without
193            // protection; see `archive::authority::publish_read`.
194            crate::core::archive::authority::publish_read(
195                std::path::Path::new(root),
196                &canonical_path,
197                tool,
198                &admission.text,
199            );
200            Ok(AdmittedRootedRead {
201                content: admission.text,
202                canonical_path,
203                classification: admission.classification,
204                storable: admission.storable && complete,
205                complete,
206            })
207        },
208    )
209}
210
211/// A rooted source opened ONCE for a read in admitted units: the bounded
212/// import read of a history transcript above the read cap (`core::import`).
213///
214/// The path decision and the open are those of the rooted admitted reader:
215/// path jail and secret-path rule first, then one descriptor-relative
216/// no-follow open under the root. Everything after that — the size, every
217/// byte, every admission — is taken from THIS descriptor; the path is never
218/// opened a second time. The raw bytes stay with the caller only until
219/// `Self::admit_unit` has decided on them.
220///
221/// ONE rule set judges every unit and seam of the source and is the one the
222/// unit-safety inspection reads: the pack through the source view the caller
223/// must hold open (`policy::runtime::with_project_source_view` — inside it
224/// `policy::runtime::active()` is the pinned snapshot, and the view fails if
225/// pack or role changed by its end), the gateway rules through `gateway`,
226/// captured once here and passed into every admission.
227#[derive(Debug)]
228pub struct RootedUnitSource {
229    file: std::fs::File,
230    path: String,
231    tool: String,
232    authority: std::path::PathBuf,
233    byte_len: u64,
234    gateway: crate::core::context_admission::stores::StoreAdmission,
235}
236
237// The gateway rules of this moment as one snapshot — the function the
238// database importers use too.
239use crate::core::context_admission::carrier::gateway_snapshot as gateway_rules_now;
240
241/// One unit of a [`RootedUnitSource`] after the admission of its ORIGINAL bytes.
242#[derive(Debug)]
243pub struct AdmittedUnit {
244    /// The delivered text (project and gateway transformations applied).
245    pub(crate) content: String,
246    /// Whether the original may enter a derived store.
247    pub(crate) storable: bool,
248}
249
250/// Open `path` under `root` for `tool` for a read in admitted units. Same
251/// refusals as [`read_file_for_tool_rooted_admitted`] before its first byte:
252/// `PermissionDenied` for a path rule, any other error for a source that is
253/// not a readable regular file inside the root — or when the caller holds no
254/// source view: without a pinned pack nothing is admitted in units. `gateway`
255/// is the caller's ONE snapshot of the gateway rules for this source.
256pub fn open_rooted_unit_source(
257    path: &str,
258    root: &str,
259    tool: &str,
260    gateway: crate::core::context_admission::stores::StoreAdmission,
261) -> Result<RootedUnitSource, std::io::Error> {
262    if crate::core::binary_detect::has_binary_extension(path) {
263        return Err(std::io::Error::other(
264            "source format is not inspectable as text",
265        ));
266    }
267    if crate::core::policy::runtime::inherited_source_view().is_none() {
268        return Err(std::io::Error::other(
269            "unit admission requires a pinned policy view",
270        ));
271    }
272    let authority = crate::core::policy::runtime::REQUEST_PROJECT
273        .try_with(|slot| slot.borrow().clone())
274        .ok()
275        .flatten()
276        .unwrap_or_else(|| std::path::PathBuf::from(root));
277    let scope = std::cell::RefCell::new(Some(authority.clone()));
278    crate::core::policy::runtime::REQUEST_PROJECT.sync_scope(scope, || {
279        crate::core::io_boundary::jail_and_check_path(
280            tool,
281            std::path::Path::new(path),
282            std::path::Path::new(root),
283        )
284        .map_err(|_| {
285            std::io::Error::new(
286                std::io::ErrorKind::PermissionDenied,
287                "source path withheld by policy",
288            )
289        })?;
290        let (file, _) =
291            open_rooted_nofollow(std::path::Path::new(path), std::path::Path::new(root))?;
292        let byte_len = file.metadata()?.len();
293        Ok(RootedUnitSource {
294            file,
295            path: path.to_owned(),
296            tool: tool.to_owned(),
297            authority,
298            byte_len,
299            gateway,
300        })
301    })
302}
303
304impl RootedUnitSource {
305    /// Size of the opened file, read from its descriptor.
306    pub(crate) fn byte_len(&self) -> u64 {
307        self.byte_len
308    }
309
310    /// Whether the gateway rules configured NOW are still the ones every
311    /// unit of this source was admitted under (the digest of the admission
312    /// fingerprint, `StoreAdmission::digest`). Asked immediately before what
313    /// was read is taken over; the pack side is verified by the caller's
314    /// source view when it ends.
315    pub(crate) fn gateway_rules_unchanged(&self) -> bool {
316        gateway_rules_now().digest() == self.gateway.digest()
317    }
318
319    /// The next raw bytes of the descriptor; `0` at its end. RAW: nothing read
320    /// here may be parsed, stored or delivered before [`Self::admit_unit`] has
321    /// admitted the unit it belongs to.
322    pub(crate) fn read_raw(&mut self, buffer: &mut [u8]) -> Result<usize, std::io::Error> {
323        use std::io::Read;
324        loop {
325            match self.file.read(buffer) {
326                Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
327                read => return read,
328            }
329        }
330    }
331
332    /// Admit one unit of this source: the admission the rooted admitted reader
333    /// gives a whole file ([`admit_read`]: project policy and gateway on the
334    /// ORIGINAL text), for these bytes. The caller cuts units at line ends, so
335    /// a unit is complete lines and never divides a character.
336    ///
337    /// A unit is at most the read cap and must be complete UTF-8: a source
338    /// in another encoding cannot be decoded piece by piece and is refused,
339    /// not guessed. `PermissionDenied` when the unit is withheld.
340    ///
341    /// Lineage (G04): a source read in units has no single admitted text to
342    /// bind, so under protection the running call's source set becomes
343    /// unknown instead of silently smaller.
344    ///
345    /// Not weaker than the whole-file admission: `ErrorKind::Unsupported`
346    /// when a rule that is active now could match across lines without a
347    /// bound ([`active_rules_are_unit_safe`]), or when this unit ends inside
348    /// a built-in rule whose match would continue in the next unit
349    /// ([`unit_ends_inside_a_rule`]). The source is then not admitted in
350    /// units at all.
351    pub(crate) fn admit_unit(&self, bytes: Vec<u8>) -> Result<AdmittedUnit, std::io::Error> {
352        self.admit_bytes(bytes, true)
353    }
354
355    /// Admit a SEAM: the end of one unit followed by the start of the next,
356    /// as one text, so that a bounded match across the unit boundary is seen
357    /// by an admission. Checked only — the caller discards the result's text
358    /// and never imports it; a refused seam refuses the source like a
359    /// refused unit. A seam ends where the caller cut it, so the
360    /// "ends inside a rule" check of a unit does not apply to it.
361    pub(crate) fn admit_seam(&self, bytes: Vec<u8>) -> Result<AdmittedUnit, std::io::Error> {
362        self.admit_bytes(bytes, false)
363    }
364
365    fn admit_bytes(
366        &self,
367        bytes: Vec<u8>,
368        whole_unit: bool,
369    ) -> Result<AdmittedUnit, std::io::Error> {
370        use crate::core::text_decode::Encoding;
371        if bytes.len() > crate::core::limits::max_read_bytes() {
372            return Err(std::io::Error::other(
373                "source unit exceeded the bounded read limit",
374            ));
375        }
376        if crate::core::text_decode::looks_binary(&bytes) {
377            return Err(std::io::Error::other(
378                "source format is not inspectable as text",
379            ));
380        }
381        let decoded = crate::core::text_decode::decode_checked(bytes);
382        if !decoded.complete() || !matches!(decoded.encoding, Encoding::Utf8 | Encoding::Utf8Bom) {
383            return Err(std::io::Error::other(
384                "source is not UTF-8 text and cannot be read in units",
385            ));
386        }
387        if whole_unit && unit_ends_inside_a_rule(&decoded.text) {
388            return Err(unit_admission_unsupported());
389        }
390        let scope = std::cell::RefCell::new(Some(self.authority.clone()));
391        let admission = crate::core::policy::runtime::REQUEST_PROJECT.sync_scope(scope, || {
392            // The inspection and the admission read the SAME two objects:
393            // the pack pinned by the caller's source view and `self.gateway`.
394            if !active_rules_are_unit_safe(&self.gateway) {
395                return Err(unit_admission_unsupported());
396            }
397            admit_read_with(
398                DecodedRead {
399                    content: decoded.text,
400                    decode_lossy: false,
401                },
402                &self.path,
403                false,
404                &self.tool,
405                Some(&self.gateway),
406            )
407        })?;
408        if crate::core::archive::authority::current_capture().is_some()
409            && crate::core::context_admission::stored::source_authority_required()
410        {
411            crate::core::archive::authority::mark_unbound();
412        }
413        Ok(AdmittedUnit {
414            content: admission.text,
415            storable: admission.storable,
416        })
417    }
418}
419
420fn unit_admission_unsupported() -> std::io::Error {
421    std::io::Error::new(
422        std::io::ErrorKind::Unsupported,
423        "source cannot be admitted in units under the active rules",
424    )
425}
426
427/// Whether every rule that is active NOW and that lean-ctx does not ship
428/// itself is proven unable to match across a line end — the condition under
429/// which admitting complete lines unit by unit finds what admitting the whole
430/// text finds.
431///
432/// - Policy pack `[redaction]` entries and `[filters.blocked_patterns]`, and
433///   the configured `secret_detection.custom_patterns`: free regular
434///   expressions. The pack parser bounds their number and their LENGTH
435///   (`MAX_RULE_PATTERN_BYTES`), not what they match: any flag is allowed and
436///   no match length is bounded. Each must pass [`pattern_is_single_line`].
437/// - A semantic detector stage judges a document, not lines: when one is
438///   configured, nothing is admitted in units.
439///
440/// The built-in rules are not covered here: they are line-local, or bounded
441/// and covered by the seam, or caught by [`unit_ends_inside_a_rule`].
442fn active_rules_are_unit_safe(
443    gateway: &crate::core::context_admission::stores::StoreAdmission,
444) -> bool {
445    let pack = crate::core::policy::runtime::active().is_none_or(|active| {
446        active
447            .redaction
448            .iter()
449            .chain(&active.blocked_patterns)
450            .all(|(_, pattern)| pattern_is_single_line(pattern.as_str()))
451    });
452    let rules = gateway.admission_policy();
453    pack && rules.semantic.is_none()
454        && rules
455            .secret_patterns
456            .custom_patterns
457            .iter()
458            .all(|pattern| pattern_is_single_line(pattern))
459}
460
461// The one statement of what a rule can match across a line end, shared with
462// the gateway's chunked scan.
463use crate::core::context_admission::cross_line::{
464    ends_inside_classification_field, pattern_is_single_line,
465};
466
467/// Whether a unit ends INSIDE a built-in rule whose match the whole-file
468/// admission would continue in the following text — the built-in rules whose
469/// reach across lines no window bounds:
470///
471/// - the private-key block mask (`core::redaction`, "Private key block"):
472///   from a PEM `BEGIN … PRIVATE KEY` header line to the first END marker
473///   anywhere after it, or over the key-material lines that follow. Open when
474///   the unit ends inside a header, or holds a header with no END marker
475///   after it. Checked on the text as that rule sees it
476///   (`normalize_secret_text`).
477/// - the classification field (`input_filters::classification::field_re`):
478///   `classification` / `sensitivity` / `data class`, `:` or `=`, and the
479///   value, separated by any amount of white space including line ends. Open
480///   when the unit ends after the key, or after the separator.
481///
482/// Decided on the unit alone; never on a seam.
483pub fn unit_ends_inside_a_rule(text: &str) -> bool {
484    use std::sync::OnceLock;
485    const KINDS: &str = r"(?:RSA\s+|EC\s+|DSA\s+|OPENSSH\s+)?PRIVATE\s+KEY-----";
486    static OPEN_HEADER: OnceLock<regex::Regex> = OnceLock::new();
487    static HEADER: OnceLock<regex::Regex> = OnceLock::new();
488    static END: OnceLock<regex::Regex> = OnceLock::new();
489    let built = |cell: &'static OnceLock<regex::Regex>, pattern: &str| {
490        cell.get_or_init(|| regex::Regex::new(pattern).expect("valid built-in rule guard"))
491    };
492
493    // The same statement of the open field the gateway's chunked scan uses.
494    if ends_inside_classification_field(text) {
495        return true;
496    }
497    let normalized = crate::core::redaction::normalize_secret_text(text);
498    if !normalized.contains("-----BEGIN") {
499        return false;
500    }
501    let open_header = built(
502        &OPEN_HEADER,
503        r"-----BEGIN(?:\s+(?:RSA|EC|DSA|OPENSSH))?(?:\s+PRIVATE)?\s*\z",
504    );
505    if open_header.is_match(&normalized) {
506        return true;
507    }
508    let last_header = built(&HEADER, &format!(r"-----BEGIN\s+{KINDS}"))
509        .find_iter(&normalized)
510        .last()
511        .map(|found| found.end());
512    let last_end = built(&END, &format!(r"-----END\s+{KINDS}"))
513        .find_iter(&normalized)
514        .last()
515        .map(|found| found.start());
516    // Every header needs an END marker after it, with key material between.
517    match (last_header, last_end) {
518        (Some(header), Some(end)) => header >= end,
519        (Some(_), None) => true,
520        (None, _) => false,
521    }
522}
523
524/// The rooted no-follow open on its own, for a source that is not read as
525/// text (a history database, `core::import::database`): one open, relative to
526/// the root's descriptor, no link followed in any component on Unix, a regular
527/// file. The caller has made the path decision and runs every further check
528/// on the returned descriptor.
529pub fn open_rooted_source(
530    path: &std::path::Path,
531    root: &std::path::Path,
532) -> Result<std::fs::File, std::io::Error> {
533    open_rooted_nofollow(path, root).map(|(file, _)| file)
534}
535
536fn read_file_internal(
537    path: &str,
538    tool: &str,
539    enforce_policy: bool,
540) -> Result<String, std::io::Error> {
541    let decoded = read_file_decoded(path)?;
542    apply_read_policies(decoded, path, false, tool, enforce_policy)
543}
544
545fn read_file_decoded(path: &str) -> Result<DecodedRead, std::io::Error> {
546    if crate::core::binary_detect::has_binary_extension(path) {
547        let msg = crate::core::binary_detect::binary_file_message(path);
548        return Err(std::io::Error::other(msg));
549    }
550
551    {
552        let canonical =
553            crate::core::pathutil::safe_canonicalize_bounded(std::path::Path::new(path), 2000);
554        if let Some(cwd) = crate::core::policy::diagnostics::project() {
555            let root = crate::core::pathutil::safe_canonicalize_bounded(&cwd, 2000);
556            if !canonical.starts_with(&root) {
557                let allow = crate::core::pathjail::allow_paths_from_env_and_config();
558                let data_dir_ok = crate::core::data_dir::lean_ctx_data_dir()
559                    .is_ok_and(|d| canonical.starts_with(d));
560                let tmp_ok = canonical.starts_with(std::env::temp_dir());
561                if !allow.iter().any(|a| canonical.starts_with(a)) && !data_dir_ok && !tmp_ok {
562                    // Internal file-read workers may lack the MCP request scope.
563                    // Never put an untrusted path into this fallback diagnostic.
564                    tracing::warn!("defense-in-depth: read target may escape current project root");
565                }
566            }
567        }
568    }
569
570    let file = open_with_retry(path)?;
571    read_open_file_lossy_bounded_decoded(file, path, crate::core::limits::max_read_bytes(), None)
572}
573
574pub fn read_file_lossy_rooted(path: &str, root: &str) -> Result<RootedRead, std::io::Error> {
575    if crate::core::binary_detect::has_binary_extension(path) {
576        return Err(std::io::Error::other(
577            crate::core::binary_detect::binary_file_message(path),
578        ));
579    }
580    let (file, canonical_path) =
581        open_rooted_nofollow(std::path::Path::new(path), std::path::Path::new(root))?;
582    crate::core::policy::runtime::REQUEST_PROJECT.sync_scope(
583        std::cell::RefCell::new(Some(std::path::PathBuf::from(root))),
584        || {
585            Ok(RootedRead {
586                // A signed source snapshot must retain its original digest. If
587                // protection requires rewriting it, withhold this legacy path.
588                content: read_open_file_lossy(file, path, true, "ctx_read", true)?,
589                canonical_path: canonical_path.to_string_lossy().into_owned(),
590            })
591        },
592    )
593}
594
595fn read_open_file_lossy(
596    file: std::fs::File,
597    path: &str,
598    preserve_bytes: bool,
599    tool: &str,
600    enforce_policy: bool,
601) -> Result<String, std::io::Error> {
602    read_open_file_lossy_bounded(
603        file,
604        path,
605        preserve_bytes,
606        tool,
607        enforce_policy,
608        crate::core::limits::max_read_bytes(),
609        None,
610    )
611}
612
613fn read_open_file_lossy_bounded(
614    file: std::fs::File,
615    path: &str,
616    preserve_bytes: bool,
617    tool: &str,
618    enforce_policy: bool,
619    maximum_bytes: usize,
620    remaining_bytes: Option<&mut usize>,
621) -> Result<String, std::io::Error> {
622    let decoded = read_open_file_lossy_bounded_decoded(file, path, maximum_bytes, remaining_bytes)?;
623    apply_read_policies(decoded, path, preserve_bytes, tool, enforce_policy)
624}
625
626fn read_open_file_lossy_bounded_decoded(
627    file: std::fs::File,
628    path: &str,
629    maximum_bytes: usize,
630    remaining_bytes: Option<&mut usize>,
631) -> Result<DecodedRead, std::io::Error> {
632    let bytes = read_open_file_bounded_bytes(file, path, maximum_bytes, remaining_bytes)?;
633    // Same decoding as the index readers, so every file `ctx_compose` or
634    // `ctx_search` can return is readable here too (Windows ANSI, UTF-16).
635    // The decoder says what it could not represent: corrupt UTF-8, a dangling
636    // UTF-16 byte, an unpaired surrogate. Windows-1252 and well-formed UTF-16
637    // decode faithfully, so policy scanning sees every byte of them.
638    let decoded = crate::core::text_decode::decode_checked(bytes);
639    let decode_lossy = !decoded.complete();
640    Ok(DecodedRead {
641        content: decoded.text,
642        decode_lossy,
643    })
644}
645
646/// The bytes of an opened text source, bounded by `maximum_bytes` and the
647/// read cap and charged to the call's budget. RAW: nothing of them is parsed,
648/// stored or delivered before an admission has decided on the source.
649fn read_open_file_bounded_bytes(
650    file: std::fs::File,
651    path: &str,
652    maximum_bytes: usize,
653    remaining_bytes: Option<&mut usize>,
654) -> Result<Vec<u8>, std::io::Error> {
655    let cap = maximum_bytes.min(crate::core::limits::max_read_bytes());
656    if cap == 0 {
657        return Err(std::io::Error::other("source read budget exhausted"));
658    }
659    let meta = file
660        .metadata()
661        .map_err(|e| std::io::Error::other(format!("cannot stat open file descriptor: {e}")))?;
662    if meta.len() > cap as u64 {
663        return Err(std::io::Error::other(format!(
664            "file too large ({} bytes, limit {} bytes via LCTX_MAX_READ_BYTES). \
665             Increase the limit or use a line-range read: mode=\"lines:1-100\"",
666            meta.len(),
667            cap
668        )));
669    }
670
671    use std::io::{BufRead, Read};
672    let mut bytes = Vec::with_capacity(meta.len() as usize);
673    let mut reader = std::io::BufReader::with_capacity(cap.saturating_add(1).min(8192), file);
674    let prefix = reader.fill_buf()?;
675    if crate::core::text_decode::looks_binary(prefix) {
676        if let Some(remaining) = remaining_bytes {
677            *remaining = remaining.saturating_sub(prefix.len());
678        }
679        return Err(std::io::Error::other(
680            crate::core::binary_detect::binary_file_message(path),
681        ));
682    }
683    let read_result = reader.take(cap as u64 + 1).read_to_end(&mut bytes);
684    if let Some(remaining) = remaining_bytes {
685        *remaining = remaining.saturating_sub(bytes.len());
686    }
687    read_result?;
688    if bytes.len() > cap {
689        return Err(std::io::Error::other(
690            "file exceeded the bounded read limit",
691        ));
692    }
693    Ok(bytes)
694}
695
696fn apply_read_policies(
697    decoded: DecodedRead,
698    path: &str,
699    preserve_bytes: bool,
700    tool: &str,
701    enforce_policy: bool,
702) -> Result<String, std::io::Error> {
703    let s = decoded.content;
704    // Constrain agent acquisitions, not unrelated human/local filesystem reads.
705    // Detached read workers inherit this scope from TaskSpine::spawn_thread.
706    let agent_acquisition = enforce_policy
707        || crate::core::policy::runtime::REQUEST_PROJECT
708            .try_with(|slot| slot.borrow().is_some())
709            .unwrap_or(false);
710    if !agent_acquisition {
711        return Ok(s);
712    }
713    let admission = admit_read(
714        DecodedRead {
715            content: s,
716            decode_lossy: decoded.decode_lossy,
717        },
718        path,
719        preserve_bytes,
720        tool,
721    )?;
722    // Source publication for the readers that are not handed a root (the main
723    // `ctx_read` path): the request project is the authority the read ran
724    // under. Without one the source cannot be bound, which — under protection
725    // and inside a capture — makes the call's source set unknown.
726    if crate::core::archive::authority::current_capture().is_some() {
727        let project = crate::core::policy::runtime::REQUEST_PROJECT
728            .try_with(|slot| slot.borrow().clone())
729            .ok()
730            .flatten()
731            .unwrap_or_default();
732        let canonical =
733            crate::core::pathutil::safe_canonicalize_or_self(std::path::Path::new(path));
734        crate::core::archive::authority::publish_read(
735            &project,
736            &canonical.to_string_lossy(),
737            tool,
738            &admission.text,
739        );
740    }
741    Ok(admission.text)
742}
743
744/// Admit one decoded source. Order, defined once for every reader:
745///
746/// 1. the project policy audits and judges the ORIGINAL text (a block, or a
747///    lossy decode under a pack, refuses);
748/// 2. the gateway admission also decides on the ORIGINAL text, with the
749///    decoder's completeness — classification, secrets, storability. A project
750///    redaction can therefore never remove a marking before the gateway has
751///    classified the source;
752/// 3. the delivered text is the composition of both transformations, the most
753///    restrictive result winning (`admit_source_with_storage` composes the
754///    project rewrite itself and withholds when the two do not commute).
755///
756/// Clean content is returned byte-identically.
757fn admit_read(
758    decoded: DecodedRead,
759    path: &str,
760    preserve_bytes: bool,
761    tool: &str,
762) -> Result<crate::core::context_admission::SourceAdmission, std::io::Error> {
763    admit_read_with(decoded, path, preserve_bytes, tool, None)
764}
765
766/// [`admit_read`] with the gateway rules of a snapshot the caller captured
767/// once, instead of the configuration as it is at this moment: a source that
768/// is admitted in several pieces is judged by ONE rule set (the pack side is
769/// pinned by the caller's source view, `policy::runtime::with_source_view`).
770fn admit_read_with(
771    decoded: DecodedRead,
772    path: &str,
773    preserve_bytes: bool,
774    tool: &str,
775    gateway: Option<&crate::core::context_admission::stores::StoreAdmission>,
776) -> Result<crate::core::context_admission::SourceAdmission, std::io::Error> {
777    let original = decoded.content;
778    if let Some(policy) = crate::core::policy::runtime::active() {
779        let outcome = crate::core::policy::content::evaluate_text(&original, &policy);
780        crate::core::policy::audit_filter::audit_filter(tool, &outcome.audit, outcome.blocked);
781        if decoded.decode_lossy || outcome.blocked || (preserve_bytes && outcome.text != original) {
782            return Err(std::io::Error::new(
783                std::io::ErrorKind::PermissionDenied,
784                "source content withheld by policy",
785            ));
786        }
787    }
788    // Context Gateway admission runs before any cache, compression or
789    // delivery sees the source, for every read mode.
790    let completeness = crate::core::context_admission::OutputCompleteness {
791        decode_lossy: decoded.decode_lossy,
792        ..Default::default()
793    };
794    match gateway {
795        Some(snapshot) => crate::core::context_admission::admit_source_with_snapshot(
796            &original,
797            path,
798            preserve_bytes,
799            completeness,
800            snapshot,
801        ),
802        None => crate::core::context_admission::admit_project_file_with_storage(
803            &original,
804            path,
805            preserve_bytes,
806            completeness,
807        ),
808    }
809}
810
811#[cfg(unix)]
812fn open_rooted_nofollow(
813    path: &std::path::Path,
814    root: &std::path::Path,
815) -> Result<(std::fs::File, std::path::PathBuf), std::io::Error> {
816    use std::ffi::CString;
817    use std::os::unix::ffi::OsStrExt;
818    use std::os::unix::io::{AsRawFd, FromRawFd};
819
820    let root = crate::core::pathutil::canonicalize_secure(root)?;
821    let target = crate::core::pathjail::jail_path(path, &root)
822        .map_err(|_| std::io::Error::other("file is outside the rooted read boundary"))?;
823    let relative = target
824        .strip_prefix(&root)
825        .map_err(|_| std::io::Error::other("file is outside the rooted read boundary"))?;
826    let components: Vec<_> = relative.components().collect();
827    if components.is_empty() {
828        return Err(std::io::Error::other(
829            "rooted read target must be a regular file",
830        ));
831    }
832    let root_name = CString::new(root.as_os_str().as_bytes())
833        .map_err(|_| std::io::Error::other("rooted read path contains a NUL byte"))?;
834    // SAFETY: root_name is NUL-terminated; File takes ownership immediately.
835    let root_fd = unsafe {
836        libc::open(
837            root_name.as_ptr(),
838            libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
839        )
840    };
841    if root_fd < 0 {
842        return Err(std::io::Error::last_os_error());
843    }
844    // SAFETY: root_fd is a new successful descriptor owned by this scope.
845    let mut directory = unsafe { std::fs::File::from_raw_fd(root_fd) };
846    for (index, component) in components.iter().enumerate() {
847        let std::path::Component::Normal(name) = component else {
848            return Err(std::io::Error::other(
849                "rooted read path contains an invalid component",
850            ));
851        };
852        let name = CString::new(name.as_bytes())
853            .map_err(|_| std::io::Error::other("rooted read path contains a NUL byte"))?;
854        let last = index + 1 == components.len();
855        let flags = if last {
856            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK
857        } else {
858            libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW
859        };
860        // SAFETY: directory is live and name is NUL-terminated. File owns success.
861        let fd = unsafe { libc::openat(directory.as_raw_fd(), name.as_ptr(), flags) };
862        if fd < 0 {
863            return Err(std::io::Error::last_os_error());
864        }
865        // SAFETY: fd is a new successful descriptor owned by this scope.
866        let opened = unsafe { std::fs::File::from_raw_fd(fd) };
867        if last {
868            if !opened.metadata()?.is_file() {
869                return Err(std::io::Error::other(
870                    "rooted read target must be a regular file",
871                ));
872            }
873            return Ok((opened, target));
874        }
875        directory = opened;
876    }
877    unreachable!("non-empty rooted path must return from its final component")
878}
879
880#[cfg(windows)]
881fn open_rooted_nofollow(
882    path: &std::path::Path,
883    root: &std::path::Path,
884) -> Result<(std::fs::File, std::path::PathBuf), std::io::Error> {
885    use std::ffi::OsString;
886    use std::os::windows::ffi::OsStringExt;
887    use std::os::windows::io::AsRawHandle;
888    use windows_sys::Win32::Storage::FileSystem::{
889        FILE_NAME_NORMALIZED, GetFinalPathNameByHandleW, VOLUME_NAME_DOS,
890    };
891
892    let root = crate::core::pathutil::canonicalize_secure(root)?;
893    // Open the path the jail checked: a relative path resolves against the
894    // root there, not against the process CWD (the daemon's is not the project).
895    let target = crate::core::pathjail::jail_path(path, &root)
896        .map_err(|_| std::io::Error::other("file is outside the rooted read boundary"))?;
897    let file = std::fs::OpenOptions::new().read(true).open(&target)?;
898    let mut buffer = vec![0u16; 32_768];
899    // SAFETY: the handle is live and buffer is writable for its full length.
900    let length = unsafe {
901        GetFinalPathNameByHandleW(
902            file.as_raw_handle().cast(),
903            buffer.as_mut_ptr(),
904            buffer.len() as u32,
905            FILE_NAME_NORMALIZED | VOLUME_NAME_DOS,
906        )
907    };
908    if length == 0 || length as usize >= buffer.len() {
909        return Err(std::io::Error::last_os_error());
910    }
911    buffer.truncate(length as usize);
912    let final_path = crate::core::pathutil::strip_verbatim(std::path::PathBuf::from(
913        OsString::from_wide(&buffer),
914    ));
915    if !final_path.starts_with(&root) || !file.metadata()?.is_file() {
916        return Err(std::io::Error::other(
917            "opened file escaped the rooted read boundary",
918        ));
919    }
920    Ok((file, final_path))
921}
922
923#[cfg(not(any(unix, windows)))]
924fn open_rooted_nofollow(
925    path: &std::path::Path,
926    root: &std::path::Path,
927) -> Result<(std::fs::File, std::path::PathBuf), std::io::Error> {
928    let root = crate::core::pathutil::canonicalize_secure(root)?;
929    let target = crate::core::pathjail::jail_path(path, &root)
930        .map_err(|_| std::io::Error::other("file is outside the rooted read boundary"))?;
931    let file = std::fs::File::open(&target)?;
932    if !file.metadata()?.is_file() {
933        return Err(std::io::Error::other(
934            "rooted read target must be a regular file",
935        ));
936    }
937    Ok((file, target))
938}
939
940/// A streamed line-window read (#811): only the requested span's raw lines,
941/// plus the file's true total line count for the header — the rest of the
942/// file is never buffered.
943pub struct LineWindow {
944    /// Raw lines within `[start, end]`, joined with `\n`.
945    pub body: String,
946    /// True total line count of the file.
947    pub total_lines: usize,
948    /// Clamped, 1-based inclusive bounds actually served.
949    pub start: usize,
950    pub end: usize,
951}
952
953/// Parses an `anchored:` window payload for the disk-streaming short-circuit
954/// below. Only the dash form (`"N-M"`) is fast-pathed — `anchored_lines_mode`
955/// (the registered handler) always emits it, using the `999999` EOF sentinel
956/// rather than a bare `"N"`. A hand-typed bare payload (meaning "N to EOF")
957/// returns `None` and falls through to the normal full-read path instead of
958/// guessing a total line count up front.
959pub fn parse_disk_anchor_range(payload: &str) -> Option<(usize, usize)> {
960    let (s, e) = payload.split_once('-')?;
961    let start = s.trim().parse::<usize>().ok()?.max(1);
962    let end = e.trim().parse::<usize>().ok()?;
963    Some((start, end))
964}
965
966/// Streams `path` line-by-line and extracts only `[start, end]` (1-based,
967/// inclusive) without ever holding the whole file in memory — the
968/// anchored-window counterpart to [`read_file_lossy`] (#811). Every line is
969/// still counted (one cheap UTF-8 pass, no per-line allocation outside the
970/// requested window) so the caller can report the true total. Returns `None`
971/// on anything that isn't a clean streamed text read (I/O error, a binary
972/// file, invalid UTF-8 anywhere in the file) so the caller can fall back to
973/// the existing, more permissive `read_file_lossy` path — behaviour never
974/// regresses, it just doesn't always get the fast path.
975pub fn read_line_window(path: &str, start: usize, end: usize) -> Option<LineWindow> {
976    if crate::core::binary_detect::has_binary_extension(path) {
977        return None;
978    }
979    use std::io::BufRead;
980    let file = open_with_retry(path).ok()?;
981    let mut reader = std::io::BufReader::new(file);
982    if reader
983        .fill_buf()
984        .ok()?
985        .iter()
986        .take(8192)
987        .any(|byte| *byte == 0)
988    {
989        return None;
990    }
991    let mut total = 0usize;
992    let mut collected = Vec::new();
993    for line in reader.lines() {
994        let line = line.ok()?;
995        total += 1;
996        if total >= start && total <= end {
997            collected.push(line);
998        }
999    }
1000    Some(LineWindow {
1001        body: collected.join("\n"),
1002        total_lines: total,
1003        start: start.min(total.max(1)),
1004        end: end.min(total),
1005    })
1006}
1007
1008/// Opens a file, retrying once after a brief pause on NotFound.
1009/// Works around overlay/FUSE stat-cache races in container runtimes (Docker, Codex).
1010/// Uses O_NOFOLLOW on Unix for TOCTOU symlink protection.
1011fn open_with_retry(path: &str) -> Result<std::fs::File, std::io::Error> {
1012    match open_nofollow(path) {
1013        Ok(f) => Ok(f),
1014        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1015            std::thread::sleep(std::time::Duration::from_millis(50));
1016            open_nofollow(path).map_err(|e| {
1017                if e.kind() == std::io::ErrorKind::NotFound {
1018                    let hint = crate::core::path_suggest::hint(std::path::Path::new(path));
1019                    if hint.is_empty() {
1020                        std::io::Error::other(format!(
1021                            "file not found: {path} — verify the path with ctx_tree or ctx_search"
1022                        ))
1023                    } else {
1024                        std::io::Error::other(format!("file not found: {path}{hint}"))
1025                    }
1026                } else {
1027                    e
1028                }
1029            })
1030        }
1031        Err(e) => Err(e),
1032    }
1033}
1034
1035#[cfg(unix)]
1036fn open_nofollow(path: &str) -> Result<std::fs::File, std::io::Error> {
1037    use std::os::unix::fs::OpenOptionsExt;
1038    use std::path::Path;
1039
1040    let p = Path::new(path);
1041    // Canonicalize the parent directory (resolving symlinks in the directory path)
1042    // but apply O_NOFOLLOW only to the final file component. This prevents
1043    // symlink-following attacks on the target file while allowing legitimate
1044    // directory symlinks (e.g., /tmp → /private/tmp on macOS).
1045    if let (Some(parent), Some(filename)) = (p.parent(), p.file_name())
1046        && parent.exists()
1047    {
1048        let canonical_parent = crate::core::pathutil::safe_canonicalize_bounded(parent, 2000);
1049        let canonical_path = canonical_parent.join(filename);
1050        return std::fs::OpenOptions::new()
1051            .read(true)
1052            .custom_flags(libc::O_NOFOLLOW)
1053            .open(&canonical_path);
1054    }
1055
1056    // Fallback: direct open with O_NOFOLLOW
1057    std::fs::OpenOptions::new()
1058        .read(true)
1059        .custom_flags(libc::O_NOFOLLOW)
1060        .open(path)
1061}
1062
1063#[cfg(not(unix))]
1064fn open_nofollow(path: &str) -> Result<std::fs::File, std::io::Error> {
1065    std::fs::File::open(path)
1066}
1067
1068#[cfg(test)]
1069#[path = "rooted_read_admission_tests.rs"]
1070mod admission_tests;
1071
1072#[cfg(test)]
1073mod tests {
1074    use super::read_file_lossy_rooted;
1075
1076    #[test]
1077    fn original_read_budget_counts_masked_and_blocked_content() {
1078        let root = tempfile::tempdir().unwrap();
1079        let policy_path = root.path().join("policy.toml");
1080        std::fs::write(&policy_path,
1081            "name = 'budget'\nversion = '1.0.0'\ndescription = 'fixture'\n[redaction]\nmasked = 'X+'\n[filters]\nclassification = 'block'\n").unwrap();
1082        let pack = crate::core::policy::parse_file(&policy_path).unwrap();
1083        let _policy = crate::core::policy::runtime::TestPolicyOverride::set(Some(
1084            crate::core::policy::resolve(&pack).unwrap(),
1085        ));
1086        let masked = root.path().join("masked.txt");
1087        let blocked = root.path().join("blocked.txt");
1088        std::fs::write(&masked, "X".repeat(512)).unwrap();
1089        let blocked_text = format!("CONFIDENTIAL{}", " ".repeat(500));
1090        std::fs::write(&blocked, &blocked_text).unwrap();
1091        let mut remaining = 512 + blocked_text.len();
1092        let content = super::read_file_for_tool_rooted_budgeted(
1093            masked.to_str().unwrap(),
1094            root.path().to_str().unwrap(),
1095            "ctx_symbol",
1096            &mut remaining,
1097        )
1098        .unwrap();
1099        assert!(content.len() < 512);
1100        assert_eq!(remaining, blocked_text.len());
1101        assert!(
1102            super::read_file_for_tool_rooted_budgeted(
1103                blocked.to_str().unwrap(),
1104                root.path().to_str().unwrap(),
1105                "ctx_symbol",
1106                &mut remaining,
1107            )
1108            .is_err()
1109        );
1110        assert_eq!(remaining, 0);
1111        assert!(
1112            super::read_file_for_tool_rooted_budgeted(
1113                masked.to_str().unwrap(),
1114                root.path().to_str().unwrap(),
1115                "ctx_symbol",
1116                &mut remaining,
1117            )
1118            .is_err()
1119        );
1120    }
1121
1122    #[test]
1123    fn rooted_read_binds_content_to_canonical_in_root_source() {
1124        let root = tempfile::tempdir().unwrap();
1125        let path = root.path().join("source.txt");
1126        std::fs::write(&path, "rooted content").unwrap();
1127
1128        let read = read_file_lossy_rooted(&path.to_string_lossy(), &root.path().to_string_lossy())
1129            .unwrap();
1130
1131        assert_eq!(read.content, "rooted content");
1132        assert_eq!(
1133            std::path::Path::new(&read.canonical_path),
1134            crate::core::pathutil::canonicalize_secure(&path).unwrap()
1135        );
1136    }
1137
1138    #[test]
1139    fn rooted_read_resolves_a_relative_path_against_the_root_not_the_cwd() {
1140        // Config (path_jail) is read from the data dir parallel tests repoint.
1141        let _iso = crate::core::data_dir::isolated_data_dir();
1142        let root = tempfile::tempdir().unwrap();
1143        std::fs::write(root.path().join("relative.txt"), "from the root").unwrap();
1144
1145        let read = read_file_lossy_rooted("relative.txt", &root.path().to_string_lossy()).unwrap();
1146
1147        assert_eq!(read.content, "from the root");
1148    }
1149
1150    #[test]
1151    fn rooted_read_rejects_outside_source_and_binary_content() {
1152        let root = tempfile::tempdir().unwrap();
1153        let outside = tempfile::tempdir().unwrap();
1154        let outside_path = outside.path().join("outside.txt");
1155        std::fs::write(&outside_path, "outside").unwrap();
1156        assert!(
1157            read_file_lossy_rooted(
1158                &outside_path.to_string_lossy(),
1159                &root.path().to_string_lossy(),
1160            )
1161            .is_err()
1162        );
1163
1164        let binary = root.path().join("binary.txt");
1165        std::fs::write(&binary, b"text\0binary").unwrap();
1166        let error =
1167            read_file_lossy_rooted(&binary.to_string_lossy(), &root.path().to_string_lossy())
1168                .unwrap_err();
1169        assert!(error.to_string().contains("Binary file detected"));
1170    }
1171
1172    #[cfg(unix)]
1173    #[test]
1174    fn rooted_read_resolves_safe_alias_and_rejects_escape_symlink() {
1175        use std::os::unix::fs::symlink;
1176
1177        let root = tempfile::tempdir().unwrap();
1178        let inside = root.path().join("inside.txt");
1179        std::fs::write(&inside, "inside").unwrap();
1180        let safe_alias = root.path().join("safe-alias.txt");
1181        symlink(&inside, &safe_alias).unwrap();
1182        assert_eq!(
1183            read_file_lossy_rooted(
1184                &safe_alias.to_string_lossy(),
1185                &root.path().to_string_lossy(),
1186            )
1187            .unwrap()
1188            .content,
1189            "inside"
1190        );
1191
1192        let outside = tempfile::tempdir().unwrap();
1193        let outside_path = outside.path().join("outside.txt");
1194        std::fs::write(&outside_path, "outside").unwrap();
1195        let escape = root.path().join("escape.txt");
1196        symlink(outside_path, &escape).unwrap();
1197        assert!(
1198            read_file_lossy_rooted(&escape.to_string_lossy(), &root.path().to_string_lossy(),)
1199                .is_err()
1200        );
1201    }
1202}