1#[derive(Debug)]
8pub struct RootedRead {
9 pub content: String,
10 pub canonical_path: String,
11}
12
13#[derive(Debug)]
15pub struct AdmittedRootedRead {
16 pub content: String,
18 pub(crate) canonical_path: String,
19 pub(crate) classification: lean_ctx_protocol::context_gateway::ClassificationV1,
21 pub storable: bool,
23 pub complete: bool,
25}
26
27#[derive(Debug)]
28struct DecodedRead {
29 content: String,
30 decode_lossy: bool,
31}
32
33pub fn read_file_lossy(path: &str) -> Result<String, std::io::Error> {
37 read_file_internal(path, "ctx_read", false)
38}
39
40pub 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 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
70pub 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
81pub 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
103pub 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
129pub 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
144pub 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 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#[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
237use crate::core::context_admission::carrier::gateway_snapshot as gateway_rules_now;
240
241#[derive(Debug)]
243pub struct AdmittedUnit {
244 pub(crate) content: String,
246 pub(crate) storable: bool,
248}
249
250pub 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 pub(crate) fn byte_len(&self) -> u64 {
307 self.byte_len
308 }
309
310 pub(crate) fn gateway_rules_unchanged(&self) -> bool {
316 gateway_rules_now().digest() == self.gateway.digest()
317 }
318
319 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 pub(crate) fn admit_unit(&self, bytes: Vec<u8>) -> Result<AdmittedUnit, std::io::Error> {
352 self.admit_bytes(bytes, true)
353 }
354
355 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 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
427fn 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
461use crate::core::context_admission::cross_line::{
464 ends_inside_classification_field, pattern_is_single_line,
465};
466
467pub 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 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 match (last_header, last_end) {
518 (Some(header), Some(end)) => header >= end,
519 (Some(_), None) => true,
520 (None, _) => false,
521 }
522}
523
524pub 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 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 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 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
646fn 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 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 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
744fn 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
766fn 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 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 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 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 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 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 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 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
940pub struct LineWindow {
944 pub body: String,
946 pub total_lines: usize,
948 pub start: usize,
950 pub end: usize,
951}
952
953pub 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
966pub 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
1008fn 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 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 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 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}