#[derive(Debug)]
pub struct RootedRead {
pub content: String,
pub canonical_path: String,
}
#[derive(Debug)]
pub struct AdmittedRootedRead {
pub content: String,
pub(crate) canonical_path: String,
pub(crate) classification: lean_ctx_protocol::context_gateway::ClassificationV1,
pub storable: bool,
pub complete: bool,
}
#[derive(Debug)]
struct DecodedRead {
content: String,
decode_lossy: bool,
}
pub fn read_file_lossy(path: &str) -> Result<String, std::io::Error> {
read_file_internal(path, "ctx_read", false)
}
pub fn read_file_lossy_admitted_for_cli(
path: &str,
) -> Result<crate::core::context_admission::SourceAdmission, std::io::Error> {
let decoded = read_file_decoded(path)?;
if let Some(policy) = crate::core::policy::runtime::active() {
let outcome = crate::core::policy::content::evaluate_text(&decoded.content, &policy);
crate::core::policy::audit_filter::audit_filter(
"ctx_read",
&outcome.audit,
outcome.blocked,
);
}
crate::core::context_admission::admit_project_file_with_storage(
&decoded.content,
path,
false,
crate::core::context_admission::OutputCompleteness {
decode_lossy: decoded.decode_lossy,
..Default::default()
},
)
}
pub fn read_file_for_tool_rooted(
path: &str,
root: &str,
tool: &str,
) -> Result<String, std::io::Error> {
let mut remaining = crate::core::limits::max_read_bytes();
read_file_for_tool_rooted_budgeted(path, root, tool, &mut remaining)
}
pub fn read_file_for_tool_rooted_budgeted(
path: &str,
root: &str,
tool: &str,
remaining_bytes: &mut usize,
) -> Result<String, std::io::Error> {
read_file_for_tool_rooted_with_path(path, root, tool, remaining_bytes).map(|read| read.content)
}
pub fn read_file_for_tool_rooted_with_path(
path: &str,
root: &str,
tool: &str,
remaining_bytes: &mut usize,
) -> Result<RootedRead, std::io::Error> {
read_file_for_tool_rooted_admitted(path, root, tool, remaining_bytes).map(|read| RootedRead {
content: read.content,
canonical_path: read.canonical_path,
})
}
pub fn publish_requested_source(path: &str, root: &str, tool: &str) {
if crate::core::archive::authority::current_capture().is_none()
|| !crate::core::context_admission::stored::source_authority_required()
{
return;
}
let published = crate::core::context_admission::capture::ADMISSIONS.sync_scope(None, || {
let mut remaining = crate::core::limits::max_read_bytes();
read_file_for_tool_rooted_admitted(path, root, tool, &mut remaining).is_ok()
});
if !published {
crate::core::archive::authority::mark_unbound();
}
}
pub fn read_file_for_tool_rooted_admitted(
path: &str,
root: &str,
tool: &str,
remaining_bytes: &mut usize,
) -> Result<AdmittedRootedRead, std::io::Error> {
read_file_for_tool_rooted_admitted_with(path, root, tool, remaining_bytes, None)
}
pub fn read_file_for_tool_rooted_admitted_with(
path: &str,
root: &str,
tool: &str,
remaining_bytes: &mut usize,
gateway: Option<&crate::core::context_admission::stores::StoreAdmission>,
) -> Result<AdmittedRootedRead, std::io::Error> {
if crate::core::binary_detect::has_binary_extension(path) {
return Err(std::io::Error::other(
"source format is not inspectable as text",
));
}
let authority = crate::core::policy::runtime::REQUEST_PROJECT
.try_with(|slot| slot.borrow().clone())
.ok()
.flatten()
.unwrap_or_else(|| std::path::PathBuf::from(root));
crate::core::policy::runtime::REQUEST_PROJECT.sync_scope(
std::cell::RefCell::new(Some(authority)),
|| {
crate::core::io_boundary::jail_and_check_path(
tool,
std::path::Path::new(path),
std::path::Path::new(root),
)
.map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"source path withheld by policy",
)
})?;
let (file, canonical_path) =
open_rooted_nofollow(std::path::Path::new(path), std::path::Path::new(root))?;
let decoded = read_open_file_lossy_bounded_decoded(
file,
path,
*remaining_bytes,
Some(remaining_bytes),
)?;
let complete = !decoded.decode_lossy;
let admission = admit_read_with(decoded, path, false, tool, gateway)?;
let canonical_path = canonical_path.to_string_lossy().into_owned();
crate::core::archive::authority::publish_read(
std::path::Path::new(root),
&canonical_path,
tool,
&admission.text,
);
Ok(AdmittedRootedRead {
content: admission.text,
canonical_path,
classification: admission.classification,
storable: admission.storable && complete,
complete,
})
},
)
}
#[derive(Debug)]
pub struct RootedUnitSource {
file: std::fs::File,
path: String,
tool: String,
authority: std::path::PathBuf,
byte_len: u64,
gateway: crate::core::context_admission::stores::StoreAdmission,
}
use crate::core::context_admission::carrier::gateway_snapshot as gateway_rules_now;
#[derive(Debug)]
pub struct AdmittedUnit {
pub(crate) content: String,
pub(crate) storable: bool,
}
pub fn open_rooted_unit_source(
path: &str,
root: &str,
tool: &str,
gateway: crate::core::context_admission::stores::StoreAdmission,
) -> Result<RootedUnitSource, std::io::Error> {
if crate::core::binary_detect::has_binary_extension(path) {
return Err(std::io::Error::other(
"source format is not inspectable as text",
));
}
if crate::core::policy::runtime::inherited_source_view().is_none() {
return Err(std::io::Error::other(
"unit admission requires a pinned policy view",
));
}
let authority = crate::core::policy::runtime::REQUEST_PROJECT
.try_with(|slot| slot.borrow().clone())
.ok()
.flatten()
.unwrap_or_else(|| std::path::PathBuf::from(root));
let scope = std::cell::RefCell::new(Some(authority.clone()));
crate::core::policy::runtime::REQUEST_PROJECT.sync_scope(scope, || {
crate::core::io_boundary::jail_and_check_path(
tool,
std::path::Path::new(path),
std::path::Path::new(root),
)
.map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"source path withheld by policy",
)
})?;
let (file, _) =
open_rooted_nofollow(std::path::Path::new(path), std::path::Path::new(root))?;
let byte_len = file.metadata()?.len();
Ok(RootedUnitSource {
file,
path: path.to_owned(),
tool: tool.to_owned(),
authority,
byte_len,
gateway,
})
})
}
impl RootedUnitSource {
pub(crate) fn byte_len(&self) -> u64 {
self.byte_len
}
pub(crate) fn gateway_rules_unchanged(&self) -> bool {
gateway_rules_now().digest() == self.gateway.digest()
}
pub(crate) fn read_raw(&mut self, buffer: &mut [u8]) -> Result<usize, std::io::Error> {
use std::io::Read;
loop {
match self.file.read(buffer) {
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
read => return read,
}
}
}
pub(crate) fn admit_unit(&self, bytes: Vec<u8>) -> Result<AdmittedUnit, std::io::Error> {
self.admit_bytes(bytes, true)
}
pub(crate) fn admit_seam(&self, bytes: Vec<u8>) -> Result<AdmittedUnit, std::io::Error> {
self.admit_bytes(bytes, false)
}
fn admit_bytes(
&self,
bytes: Vec<u8>,
whole_unit: bool,
) -> Result<AdmittedUnit, std::io::Error> {
use crate::core::text_decode::Encoding;
if bytes.len() > crate::core::limits::max_read_bytes() {
return Err(std::io::Error::other(
"source unit exceeded the bounded read limit",
));
}
if crate::core::text_decode::looks_binary(&bytes) {
return Err(std::io::Error::other(
"source format is not inspectable as text",
));
}
let decoded = crate::core::text_decode::decode_checked(bytes);
if !decoded.complete() || !matches!(decoded.encoding, Encoding::Utf8 | Encoding::Utf8Bom) {
return Err(std::io::Error::other(
"source is not UTF-8 text and cannot be read in units",
));
}
if whole_unit && unit_ends_inside_a_rule(&decoded.text) {
return Err(unit_admission_unsupported());
}
let scope = std::cell::RefCell::new(Some(self.authority.clone()));
let admission = crate::core::policy::runtime::REQUEST_PROJECT.sync_scope(scope, || {
if !active_rules_are_unit_safe(&self.gateway) {
return Err(unit_admission_unsupported());
}
admit_read_with(
DecodedRead {
content: decoded.text,
decode_lossy: false,
},
&self.path,
false,
&self.tool,
Some(&self.gateway),
)
})?;
if crate::core::archive::authority::current_capture().is_some()
&& crate::core::context_admission::stored::source_authority_required()
{
crate::core::archive::authority::mark_unbound();
}
Ok(AdmittedUnit {
content: admission.text,
storable: admission.storable,
})
}
}
fn unit_admission_unsupported() -> std::io::Error {
std::io::Error::new(
std::io::ErrorKind::Unsupported,
"source cannot be admitted in units under the active rules",
)
}
fn active_rules_are_unit_safe(
gateway: &crate::core::context_admission::stores::StoreAdmission,
) -> bool {
let pack = crate::core::policy::runtime::active().is_none_or(|active| {
active
.redaction
.iter()
.chain(&active.blocked_patterns)
.all(|(_, pattern)| pattern_is_single_line(pattern.as_str()))
});
let rules = gateway.admission_policy();
pack && rules.semantic.is_none()
&& rules
.secret_patterns
.custom_patterns
.iter()
.all(|pattern| pattern_is_single_line(pattern))
}
use crate::core::context_admission::cross_line::{
ends_inside_classification_field, pattern_is_single_line,
};
pub fn unit_ends_inside_a_rule(text: &str) -> bool {
use std::sync::OnceLock;
const KINDS: &str = r"(?:RSA\s+|EC\s+|DSA\s+|OPENSSH\s+)?PRIVATE\s+KEY-----";
static OPEN_HEADER: OnceLock<regex::Regex> = OnceLock::new();
static HEADER: OnceLock<regex::Regex> = OnceLock::new();
static END: OnceLock<regex::Regex> = OnceLock::new();
let built = |cell: &'static OnceLock<regex::Regex>, pattern: &str| {
cell.get_or_init(|| regex::Regex::new(pattern).expect("valid built-in rule guard"))
};
if ends_inside_classification_field(text) {
return true;
}
let normalized = crate::core::redaction::normalize_secret_text(text);
if !normalized.contains("-----BEGIN") {
return false;
}
let open_header = built(
&OPEN_HEADER,
r"-----BEGIN(?:\s+(?:RSA|EC|DSA|OPENSSH))?(?:\s+PRIVATE)?\s*\z",
);
if open_header.is_match(&normalized) {
return true;
}
let last_header = built(&HEADER, &format!(r"-----BEGIN\s+{KINDS}"))
.find_iter(&normalized)
.last()
.map(|found| found.end());
let last_end = built(&END, &format!(r"-----END\s+{KINDS}"))
.find_iter(&normalized)
.last()
.map(|found| found.start());
match (last_header, last_end) {
(Some(header), Some(end)) => header >= end,
(Some(_), None) => true,
(None, _) => false,
}
}
pub fn open_rooted_source(
path: &std::path::Path,
root: &std::path::Path,
) -> Result<std::fs::File, std::io::Error> {
open_rooted_nofollow(path, root).map(|(file, _)| file)
}
fn read_file_internal(
path: &str,
tool: &str,
enforce_policy: bool,
) -> Result<String, std::io::Error> {
let decoded = read_file_decoded(path)?;
apply_read_policies(decoded, path, false, tool, enforce_policy)
}
fn read_file_decoded(path: &str) -> Result<DecodedRead, std::io::Error> {
if crate::core::binary_detect::has_binary_extension(path) {
let msg = crate::core::binary_detect::binary_file_message(path);
return Err(std::io::Error::other(msg));
}
{
let canonical =
crate::core::pathutil::safe_canonicalize_bounded(std::path::Path::new(path), 2000);
if let Some(cwd) = crate::core::policy::diagnostics::project() {
let root = crate::core::pathutil::safe_canonicalize_bounded(&cwd, 2000);
if !canonical.starts_with(&root) {
let allow = crate::core::pathjail::allow_paths_from_env_and_config();
let data_dir_ok = crate::core::data_dir::lean_ctx_data_dir()
.is_ok_and(|d| canonical.starts_with(d));
let tmp_ok = canonical.starts_with(std::env::temp_dir());
if !allow.iter().any(|a| canonical.starts_with(a)) && !data_dir_ok && !tmp_ok {
tracing::warn!("defense-in-depth: read target may escape current project root");
}
}
}
}
let file = open_with_retry(path)?;
read_open_file_lossy_bounded_decoded(file, path, crate::core::limits::max_read_bytes(), None)
}
pub fn read_file_lossy_rooted(path: &str, root: &str) -> Result<RootedRead, std::io::Error> {
if crate::core::binary_detect::has_binary_extension(path) {
return Err(std::io::Error::other(
crate::core::binary_detect::binary_file_message(path),
));
}
let (file, canonical_path) =
open_rooted_nofollow(std::path::Path::new(path), std::path::Path::new(root))?;
crate::core::policy::runtime::REQUEST_PROJECT.sync_scope(
std::cell::RefCell::new(Some(std::path::PathBuf::from(root))),
|| {
Ok(RootedRead {
content: read_open_file_lossy(file, path, true, "ctx_read", true)?,
canonical_path: canonical_path.to_string_lossy().into_owned(),
})
},
)
}
fn read_open_file_lossy(
file: std::fs::File,
path: &str,
preserve_bytes: bool,
tool: &str,
enforce_policy: bool,
) -> Result<String, std::io::Error> {
read_open_file_lossy_bounded(
file,
path,
preserve_bytes,
tool,
enforce_policy,
crate::core::limits::max_read_bytes(),
None,
)
}
fn read_open_file_lossy_bounded(
file: std::fs::File,
path: &str,
preserve_bytes: bool,
tool: &str,
enforce_policy: bool,
maximum_bytes: usize,
remaining_bytes: Option<&mut usize>,
) -> Result<String, std::io::Error> {
let decoded = read_open_file_lossy_bounded_decoded(file, path, maximum_bytes, remaining_bytes)?;
apply_read_policies(decoded, path, preserve_bytes, tool, enforce_policy)
}
fn read_open_file_lossy_bounded_decoded(
file: std::fs::File,
path: &str,
maximum_bytes: usize,
remaining_bytes: Option<&mut usize>,
) -> Result<DecodedRead, std::io::Error> {
let bytes = read_open_file_bounded_bytes(file, path, maximum_bytes, remaining_bytes)?;
let decoded = crate::core::text_decode::decode_checked(bytes);
let decode_lossy = !decoded.complete();
Ok(DecodedRead {
content: decoded.text,
decode_lossy,
})
}
fn read_open_file_bounded_bytes(
file: std::fs::File,
path: &str,
maximum_bytes: usize,
remaining_bytes: Option<&mut usize>,
) -> Result<Vec<u8>, std::io::Error> {
let cap = maximum_bytes.min(crate::core::limits::max_read_bytes());
if cap == 0 {
return Err(std::io::Error::other("source read budget exhausted"));
}
let meta = file
.metadata()
.map_err(|e| std::io::Error::other(format!("cannot stat open file descriptor: {e}")))?;
if meta.len() > cap as u64 {
return Err(std::io::Error::other(format!(
"file too large ({} bytes, limit {} bytes via LCTX_MAX_READ_BYTES). \
Increase the limit or use a line-range read: mode=\"lines:1-100\"",
meta.len(),
cap
)));
}
use std::io::{BufRead, Read};
let mut bytes = Vec::with_capacity(meta.len() as usize);
let mut reader = std::io::BufReader::with_capacity(cap.saturating_add(1).min(8192), file);
let prefix = reader.fill_buf()?;
if crate::core::text_decode::looks_binary(prefix) {
if let Some(remaining) = remaining_bytes {
*remaining = remaining.saturating_sub(prefix.len());
}
return Err(std::io::Error::other(
crate::core::binary_detect::binary_file_message(path),
));
}
let read_result = reader.take(cap as u64 + 1).read_to_end(&mut bytes);
if let Some(remaining) = remaining_bytes {
*remaining = remaining.saturating_sub(bytes.len());
}
read_result?;
if bytes.len() > cap {
return Err(std::io::Error::other(
"file exceeded the bounded read limit",
));
}
Ok(bytes)
}
fn apply_read_policies(
decoded: DecodedRead,
path: &str,
preserve_bytes: bool,
tool: &str,
enforce_policy: bool,
) -> Result<String, std::io::Error> {
let s = decoded.content;
let agent_acquisition = enforce_policy
|| crate::core::policy::runtime::REQUEST_PROJECT
.try_with(|slot| slot.borrow().is_some())
.unwrap_or(false);
if !agent_acquisition {
return Ok(s);
}
let admission = admit_read(
DecodedRead {
content: s,
decode_lossy: decoded.decode_lossy,
},
path,
preserve_bytes,
tool,
)?;
if crate::core::archive::authority::current_capture().is_some() {
let project = crate::core::policy::runtime::REQUEST_PROJECT
.try_with(|slot| slot.borrow().clone())
.ok()
.flatten()
.unwrap_or_default();
let canonical =
crate::core::pathutil::safe_canonicalize_or_self(std::path::Path::new(path));
crate::core::archive::authority::publish_read(
&project,
&canonical.to_string_lossy(),
tool,
&admission.text,
);
}
Ok(admission.text)
}
fn admit_read(
decoded: DecodedRead,
path: &str,
preserve_bytes: bool,
tool: &str,
) -> Result<crate::core::context_admission::SourceAdmission, std::io::Error> {
admit_read_with(decoded, path, preserve_bytes, tool, None)
}
fn admit_read_with(
decoded: DecodedRead,
path: &str,
preserve_bytes: bool,
tool: &str,
gateway: Option<&crate::core::context_admission::stores::StoreAdmission>,
) -> Result<crate::core::context_admission::SourceAdmission, std::io::Error> {
let original = decoded.content;
if let Some(policy) = crate::core::policy::runtime::active() {
let outcome = crate::core::policy::content::evaluate_text(&original, &policy);
crate::core::policy::audit_filter::audit_filter(tool, &outcome.audit, outcome.blocked);
if decoded.decode_lossy || outcome.blocked || (preserve_bytes && outcome.text != original) {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"source content withheld by policy",
));
}
}
let completeness = crate::core::context_admission::OutputCompleteness {
decode_lossy: decoded.decode_lossy,
..Default::default()
};
match gateway {
Some(snapshot) => crate::core::context_admission::admit_source_with_snapshot(
&original,
path,
preserve_bytes,
completeness,
snapshot,
),
None => crate::core::context_admission::admit_project_file_with_storage(
&original,
path,
preserve_bytes,
completeness,
),
}
}
#[cfg(unix)]
fn open_rooted_nofollow(
path: &std::path::Path,
root: &std::path::Path,
) -> Result<(std::fs::File, std::path::PathBuf), std::io::Error> {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
use std::os::unix::io::{AsRawFd, FromRawFd};
let root = crate::core::pathutil::canonicalize_secure(root)?;
let target = crate::core::pathjail::jail_path(path, &root)
.map_err(|_| std::io::Error::other("file is outside the rooted read boundary"))?;
let relative = target
.strip_prefix(&root)
.map_err(|_| std::io::Error::other("file is outside the rooted read boundary"))?;
let components: Vec<_> = relative.components().collect();
if components.is_empty() {
return Err(std::io::Error::other(
"rooted read target must be a regular file",
));
}
let root_name = CString::new(root.as_os_str().as_bytes())
.map_err(|_| std::io::Error::other("rooted read path contains a NUL byte"))?;
let root_fd = unsafe {
libc::open(
root_name.as_ptr(),
libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
)
};
if root_fd < 0 {
return Err(std::io::Error::last_os_error());
}
let mut directory = unsafe { std::fs::File::from_raw_fd(root_fd) };
for (index, component) in components.iter().enumerate() {
let std::path::Component::Normal(name) = component else {
return Err(std::io::Error::other(
"rooted read path contains an invalid component",
));
};
let name = CString::new(name.as_bytes())
.map_err(|_| std::io::Error::other("rooted read path contains a NUL byte"))?;
let last = index + 1 == components.len();
let flags = if last {
libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK
} else {
libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW
};
let fd = unsafe { libc::openat(directory.as_raw_fd(), name.as_ptr(), flags) };
if fd < 0 {
return Err(std::io::Error::last_os_error());
}
let opened = unsafe { std::fs::File::from_raw_fd(fd) };
if last {
if !opened.metadata()?.is_file() {
return Err(std::io::Error::other(
"rooted read target must be a regular file",
));
}
return Ok((opened, target));
}
directory = opened;
}
unreachable!("non-empty rooted path must return from its final component")
}
#[cfg(windows)]
fn open_rooted_nofollow(
path: &std::path::Path,
root: &std::path::Path,
) -> Result<(std::fs::File, std::path::PathBuf), std::io::Error> {
use std::ffi::OsString;
use std::os::windows::ffi::OsStringExt;
use std::os::windows::io::AsRawHandle;
use windows_sys::Win32::Storage::FileSystem::{
FILE_NAME_NORMALIZED, GetFinalPathNameByHandleW, VOLUME_NAME_DOS,
};
let root = crate::core::pathutil::canonicalize_secure(root)?;
let target = crate::core::pathjail::jail_path(path, &root)
.map_err(|_| std::io::Error::other("file is outside the rooted read boundary"))?;
let file = std::fs::OpenOptions::new().read(true).open(&target)?;
let mut buffer = vec![0u16; 32_768];
let length = unsafe {
GetFinalPathNameByHandleW(
file.as_raw_handle().cast(),
buffer.as_mut_ptr(),
buffer.len() as u32,
FILE_NAME_NORMALIZED | VOLUME_NAME_DOS,
)
};
if length == 0 || length as usize >= buffer.len() {
return Err(std::io::Error::last_os_error());
}
buffer.truncate(length as usize);
let final_path = crate::core::pathutil::strip_verbatim(std::path::PathBuf::from(
OsString::from_wide(&buffer),
));
if !final_path.starts_with(&root) || !file.metadata()?.is_file() {
return Err(std::io::Error::other(
"opened file escaped the rooted read boundary",
));
}
Ok((file, final_path))
}
#[cfg(not(any(unix, windows)))]
fn open_rooted_nofollow(
path: &std::path::Path,
root: &std::path::Path,
) -> Result<(std::fs::File, std::path::PathBuf), std::io::Error> {
let root = crate::core::pathutil::canonicalize_secure(root)?;
let target = crate::core::pathjail::jail_path(path, &root)
.map_err(|_| std::io::Error::other("file is outside the rooted read boundary"))?;
let file = std::fs::File::open(&target)?;
if !file.metadata()?.is_file() {
return Err(std::io::Error::other(
"rooted read target must be a regular file",
));
}
Ok((file, target))
}
pub struct LineWindow {
pub body: String,
pub total_lines: usize,
pub start: usize,
pub end: usize,
}
pub fn parse_disk_anchor_range(payload: &str) -> Option<(usize, usize)> {
let (s, e) = payload.split_once('-')?;
let start = s.trim().parse::<usize>().ok()?.max(1);
let end = e.trim().parse::<usize>().ok()?;
Some((start, end))
}
pub fn read_line_window(path: &str, start: usize, end: usize) -> Option<LineWindow> {
if crate::core::binary_detect::has_binary_extension(path) {
return None;
}
use std::io::BufRead;
let file = open_with_retry(path).ok()?;
let mut reader = std::io::BufReader::new(file);
if reader
.fill_buf()
.ok()?
.iter()
.take(8192)
.any(|byte| *byte == 0)
{
return None;
}
let mut total = 0usize;
let mut collected = Vec::new();
for line in reader.lines() {
let line = line.ok()?;
total += 1;
if total >= start && total <= end {
collected.push(line);
}
}
Some(LineWindow {
body: collected.join("\n"),
total_lines: total,
start: start.min(total.max(1)),
end: end.min(total),
})
}
fn open_with_retry(path: &str) -> Result<std::fs::File, std::io::Error> {
match open_nofollow(path) {
Ok(f) => Ok(f),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
std::thread::sleep(std::time::Duration::from_millis(50));
open_nofollow(path).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
let hint = crate::core::path_suggest::hint(std::path::Path::new(path));
if hint.is_empty() {
std::io::Error::other(format!(
"file not found: {path} — verify the path with ctx_tree or ctx_search"
))
} else {
std::io::Error::other(format!("file not found: {path}{hint}"))
}
} else {
e
}
})
}
Err(e) => Err(e),
}
}
#[cfg(unix)]
fn open_nofollow(path: &str) -> Result<std::fs::File, std::io::Error> {
use std::os::unix::fs::OpenOptionsExt;
use std::path::Path;
let p = Path::new(path);
if let (Some(parent), Some(filename)) = (p.parent(), p.file_name())
&& parent.exists()
{
let canonical_parent = crate::core::pathutil::safe_canonicalize_bounded(parent, 2000);
let canonical_path = canonical_parent.join(filename);
return std::fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW)
.open(&canonical_path);
}
std::fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW)
.open(path)
}
#[cfg(not(unix))]
fn open_nofollow(path: &str) -> Result<std::fs::File, std::io::Error> {
std::fs::File::open(path)
}
#[cfg(test)]
#[path = "rooted_read_admission_tests.rs"]
mod admission_tests;
#[cfg(test)]
mod tests {
use super::read_file_lossy_rooted;
#[test]
fn original_read_budget_counts_masked_and_blocked_content() {
let root = tempfile::tempdir().unwrap();
let policy_path = root.path().join("policy.toml");
std::fs::write(&policy_path,
"name = 'budget'\nversion = '1.0.0'\ndescription = 'fixture'\n[redaction]\nmasked = 'X+'\n[filters]\nclassification = 'block'\n").unwrap();
let pack = crate::core::policy::parse_file(&policy_path).unwrap();
let _policy = crate::core::policy::runtime::TestPolicyOverride::set(Some(
crate::core::policy::resolve(&pack).unwrap(),
));
let masked = root.path().join("masked.txt");
let blocked = root.path().join("blocked.txt");
std::fs::write(&masked, "X".repeat(512)).unwrap();
let blocked_text = format!("CONFIDENTIAL{}", " ".repeat(500));
std::fs::write(&blocked, &blocked_text).unwrap();
let mut remaining = 512 + blocked_text.len();
let content = super::read_file_for_tool_rooted_budgeted(
masked.to_str().unwrap(),
root.path().to_str().unwrap(),
"ctx_symbol",
&mut remaining,
)
.unwrap();
assert!(content.len() < 512);
assert_eq!(remaining, blocked_text.len());
assert!(
super::read_file_for_tool_rooted_budgeted(
blocked.to_str().unwrap(),
root.path().to_str().unwrap(),
"ctx_symbol",
&mut remaining,
)
.is_err()
);
assert_eq!(remaining, 0);
assert!(
super::read_file_for_tool_rooted_budgeted(
masked.to_str().unwrap(),
root.path().to_str().unwrap(),
"ctx_symbol",
&mut remaining,
)
.is_err()
);
}
#[test]
fn rooted_read_binds_content_to_canonical_in_root_source() {
let root = tempfile::tempdir().unwrap();
let path = root.path().join("source.txt");
std::fs::write(&path, "rooted content").unwrap();
let read = read_file_lossy_rooted(&path.to_string_lossy(), &root.path().to_string_lossy())
.unwrap();
assert_eq!(read.content, "rooted content");
assert_eq!(
std::path::Path::new(&read.canonical_path),
crate::core::pathutil::canonicalize_secure(&path).unwrap()
);
}
#[test]
fn rooted_read_resolves_a_relative_path_against_the_root_not_the_cwd() {
let _iso = crate::core::data_dir::isolated_data_dir();
let root = tempfile::tempdir().unwrap();
std::fs::write(root.path().join("relative.txt"), "from the root").unwrap();
let read = read_file_lossy_rooted("relative.txt", &root.path().to_string_lossy()).unwrap();
assert_eq!(read.content, "from the root");
}
#[test]
fn rooted_read_rejects_outside_source_and_binary_content() {
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let outside_path = outside.path().join("outside.txt");
std::fs::write(&outside_path, "outside").unwrap();
assert!(
read_file_lossy_rooted(
&outside_path.to_string_lossy(),
&root.path().to_string_lossy(),
)
.is_err()
);
let binary = root.path().join("binary.txt");
std::fs::write(&binary, b"text\0binary").unwrap();
let error =
read_file_lossy_rooted(&binary.to_string_lossy(), &root.path().to_string_lossy())
.unwrap_err();
assert!(error.to_string().contains("Binary file detected"));
}
#[cfg(unix)]
#[test]
fn rooted_read_resolves_safe_alias_and_rejects_escape_symlink() {
use std::os::unix::fs::symlink;
let root = tempfile::tempdir().unwrap();
let inside = root.path().join("inside.txt");
std::fs::write(&inside, "inside").unwrap();
let safe_alias = root.path().join("safe-alias.txt");
symlink(&inside, &safe_alias).unwrap();
assert_eq!(
read_file_lossy_rooted(
&safe_alias.to_string_lossy(),
&root.path().to_string_lossy(),
)
.unwrap()
.content,
"inside"
);
let outside = tempfile::tempdir().unwrap();
let outside_path = outside.path().join("outside.txt");
std::fs::write(&outside_path, "outside").unwrap();
let escape = root.path().join("escape.txt");
symlink(outside_path, &escape).unwrap();
assert!(
read_file_lossy_rooted(&escape.to_string_lossy(), &root.path().to_string_lossy(),)
.is_err()
);
}
}