use std::fs::File;
use std::io::{self, Read as _};
use std::path::Path;
use crate::caveats::Scope;
#[derive(Debug, PartialEq, Eq)]
pub(in crate::agentic) enum TextSnapshot {
Absent,
Present(String),
Unavailable(&'static str),
}
pub(in crate::agentic) fn is_absent_leaf(path: &Path) -> bool {
if !matches!(
std::fs::symlink_metadata(path),
Err(error) if error.kind() == io::ErrorKind::NotFound
) {
return false;
}
matches!(
path.parent().and_then(|parent| std::fs::symlink_metadata(parent).ok()),
Some(metadata) if metadata.is_dir()
)
}
#[cfg(windows)]
fn exact_scope_root(scope: &Scope<String>, path: &Path) -> bool {
let path = path.to_string_lossy();
let normalized_path = crate::caveats::lexically_normalize(&path);
matches!(scope, Scope::Only(roots) if roots.iter().any(|root| {
crate::caveats::lexically_normalize(root) == normalized_path
}))
}
pub(in crate::agentic) fn open_for_scope(
scope: &Scope<String>,
path: &Path,
nofollow: bool,
) -> io::Result<File> {
if !super::tui_permits_path(scope, &path.to_string_lossy()) {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"scope does not permit this read",
));
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
{
let full = path.to_string_lossy();
match super::object_bound_target(scope, &full) {
Some(Some((root, relative))) => {
crate::fs_cap::WorkspaceDir::open_granted_file(Path::new(root), &relative, nofollow)
}
Some(None) => super::open_regular_file(path, nofollow),
None => Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"scope does not permit this read",
)),
}
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
if let Scope::Only(roots) = scope {
if !roots.iter().any(|root| {
super::artifact_path_is_physically_within_workspace(Path::new(root), path)
}) {
#[cfg(windows)]
if nofollow && exact_scope_root(scope, path) && is_absent_leaf(path) {
return Err(io::Error::new(
io::ErrorKind::NotFound,
"exact granted leaf is absent",
));
}
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"physical path is outside the read scope",
));
}
}
super::open_regular_file(path, nofollow)
}
}
pub(in crate::agentic) fn capture(scope: &Scope<String>, path: &Path) -> TextSnapshot {
if !super::tui_permits_path(scope, &path.to_string_lossy()) {
return TextSnapshot::Unavailable("fs_read was not granted");
}
let mut file = match open_for_scope(scope, path, true) {
Ok(file) => file,
Err(error) if error.kind() == io::ErrorKind::NotFound && is_absent_leaf(path) => {
return TextSnapshot::Absent;
}
Err(_) => {
return TextSnapshot::Unavailable(
"the file could not be read as an authorized regular file",
)
}
};
let before = file.metadata().ok();
let limit = super::file_change::MAX_VERSION_BYTES as u64;
if before
.as_ref()
.is_some_and(|metadata| metadata.len() > limit)
{
return TextSnapshot::Unavailable("receipt capture limit exceeded (256 KiB per version)");
}
let mut bytes = Vec::new();
if (&mut file).take(limit + 1).read_to_end(&mut bytes).is_err() {
return TextSnapshot::Unavailable("the file could not be read as UTF-8 text");
}
if bytes.len() as u64 > limit {
return TextSnapshot::Unavailable("receipt capture limit exceeded (256 KiB per version)");
}
let Ok(text) = String::from_utf8(bytes) else {
return TextSnapshot::Unavailable("the file could not be read as UTF-8 text");
};
let after = file.metadata().ok();
match (before, after) {
(Some(before), Some(after))
if before.len() == after.len()
&& after.len() == text.len() as u64
&& before.modified().ok() == after.modified().ok() =>
{
TextSnapshot::Present(text)
}
_ => TextSnapshot::Unavailable("the file changed while being read"),
}
}
pub(super) fn receipt(path: &str, before: &TextSnapshot, after: &TextSnapshot) -> Receipt {
use TextSnapshot::{Absent, Present, Unavailable};
let (old, new) = match (before, after) {
(Unavailable(reason), _) | (_, Unavailable(reason)) => {
return Receipt::unavailable(reason);
}
(Absent, Absent) => {
return Receipt::plain("\n\nNo file was present before or after the operation.".into())
}
(Absent, Present(after)) => (None, Some(after.as_str())),
(Present(before), Absent) => (Some(before.as_str()), None),
(Present(before), Present(after)) => (Some(before.as_str()), Some(after.as_str())),
};
match super::file_change::from_versions(path, old, new) {
Ok(model) => {
let unchanged = old.is_some() && old == new;
let text = format!(
"\n\n{}",
super::file_change::receipt_from_model(&model, unchanged)
);
let headline = format!(
"\n\n{}",
super::file_change::receipt_headline(&model, unchanged)
);
let captured = (!unchanged).then(|| CapturedChange {
model,
path: path.into(),
before: old.map(str::to_owned),
after: new.map(str::to_owned),
});
Receipt {
text,
headline,
captured,
}
}
Err(error) => Receipt::unavailable(error),
}
}
struct CapturedChange {
model: newtui::diff::ChangeSet,
path: String,
before: Option<String>,
after: Option<String>,
}
pub(super) struct Receipt {
text: String,
headline: String,
captured: Option<CapturedChange>,
}
impl Receipt {
fn unavailable(reason: impl std::fmt::Display) -> Self {
let mut receipt = Self::plain(format!("\n\nfile-change receipt unavailable: {reason}"));
receipt.headline =
"\n\nFile operation succeeded; optional change preview unavailable".into();
receipt
}
fn plain(text: String) -> Self {
Self {
headline: text.clone(),
text,
captured: None,
}
}
pub(super) fn present_success(
self,
prefix: String,
suffix: &str,
presentation: &mut dyn super::ToolPresentation,
) -> String {
let model = format!("{prefix}{}{suffix}", self.headline);
let full = self.present(prefix, suffix, presentation);
presentation.display_source(full);
model
}
pub(super) fn present(
self,
prefix: String,
suffix: &str,
presentation: &mut dyn super::ToolPresentation,
) -> String {
let range = prefix.len()..prefix.len() + self.text.len();
let output = format!("{prefix}{}{suffix}", self.text);
if let Some(captured) = self.captured {
presentation.file_change(std::sync::Arc::new(
crate::agentic::FileChangePresentation::new(
captured.model,
captured.path,
captured.before,
captured.after,
self.text,
range,
),
));
}
present(output, presentation)
}
}
pub(super) fn matches(scope: &Scope<String>, path: &Path, expected: &[u8]) -> bool {
open_for_scope(scope, path, false)
.and_then(|file| super::file_contents_match(file, expected))
.unwrap_or(false)
}
pub(super) fn display_text(text: &str) -> std::borrow::Cow<'_, str> {
let safe = crate::notes_scan::neutralize_for_display(text);
if safe.contains('\t') {
std::borrow::Cow::Owned(safe.replace('\t', "<U+0009>"))
} else {
safe
}
}
pub(super) fn present(output: String, presentation: &mut dyn super::ToolPresentation) -> String {
if output
.chars()
.any(|ch| ch == '\t' || crate::notes_scan::display_hazard_name(ch).is_some())
{
let visible = display_text(&output);
presentation.override_result(format!(
"{visible}\n\n[file-change display escapes control characters; this view is not a raw patch.]"
));
}
output
}
pub(super) fn verified_after(
observed: &TextSnapshot,
scope: &Scope<String>,
path: &Path,
expected: Option<&[u8]>,
) -> bool {
match (observed, expected) {
(TextSnapshot::Present(actual), Some(expected)) if actual.as_bytes() != expected => {
return false;
}
(TextSnapshot::Present(_), None) | (TextSnapshot::Absent, Some(_)) => return false,
_ => {}
}
match expected {
Some(expected) => matches(scope, path, expected),
None => absent(scope, path),
}
}
pub(super) fn current_preimage(
observed: &TextSnapshot,
scope: &Scope<String>,
path: &Path,
expected: &[u8],
) -> bool {
match observed {
TextSnapshot::Absent => return false,
TextSnapshot::Present(actual) if actual.as_bytes() != expected => return false,
_ => {}
}
matches(scope, path, expected)
}
pub(super) fn read_for_edit(
scope: &Scope<String>,
path: &Path,
label: &str,
) -> Result<String, super::FileIoError> {
let read = (|| {
let mut file = open_for_scope(scope, path, false)?;
let mut text = String::new();
file.read_to_string(&mut text)?;
Ok::<_, io::Error>(text)
})();
match read {
Ok(text) => Ok(text),
Err(error) => {
if error.kind() == io::ErrorKind::PermissionDenied {
return Err(super::denied_fs_result("fs_write", &path.to_string_lossy()).into());
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
if super::is_fs_containment_denied(&error) {
return Err(super::denied_fs_result("fs_write", &path.to_string_lossy()).into());
}
Err(super::FileIoError::io(
&error,
path,
format!("error: reading {label}: {error}"),
))
}
}
}
pub(super) fn failure(output: String, receipt: &str) -> String {
let prefix = if output.starts_with("error:") || output.starts_with("capability denied:") {
""
} else {
"error: "
};
format!("{prefix}{output}\nObserved file state after the failed operation:{receipt}")
}
pub(super) fn absent(scope: &Scope<String>, path: &Path) -> bool {
matches!(open_for_scope(scope, path, true), Err(error) if error.kind() == io::ErrorKind::NotFound && is_absent_leaf(path))
}
pub(super) fn regular_target(path: &Path) -> bool {
match std::fs::metadata(path) {
Ok(metadata) => metadata.is_file(),
Err(_) => true,
}
}
fn line_range<'a>(text: &'a str, start: usize, end: usize) -> Result<(usize, usize), String> {
let lines: Vec<&'a str> = text.split_inclusive('\n').collect();
if start == 0 || start > end || end > lines.len() {
return Err(format!(
"error: lines {start}-{end} are not a range of this file ({} lines)",
lines.len()
));
}
let from: usize = lines[..start - 1].iter().map(|l| l.len()).sum();
let to: usize = from + lines[start - 1..end].iter().map(|l| l.len()).sum::<usize>();
Ok((from, to))
}
pub(super) fn copy_line_range(
header: &str,
source: &str,
start: usize,
end: usize,
) -> Result<String, String> {
let (from, to) = line_range(source, start, end)?;
let mut out = header.to_string();
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
out.push_str(&source[from..to]);
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
#[test]
fn unavailable_metadata_does_not_claim_a_nonregular_file_type() {
let directory = tempfile::TempDir::new().unwrap();
let path = directory.path().join("loop");
std::os::unix::fs::symlink("loop", &path).unwrap();
assert!(std::fs::metadata(&path).is_err());
assert!(regular_target(&path));
assert!(!regular_target(directory.path()));
}
#[test]
fn oversized_snapshots_are_unavailable_instead_of_partial_diffs() {
let directory = tempfile::TempDir::new().unwrap();
let path = directory.path().join("file");
std::fs::File::create(&path)
.unwrap()
.set_len(256 * 1024 + 1)
.unwrap();
let before = capture(&Scope::All, &path);
assert!(matches!(before, TextSnapshot::Unavailable(_)), "{before:?}");
let output = receipt("file", &before, &TextSnapshot::Present("small\n".into())).text;
assert!(output.contains("unavailable"), "{output}");
assert!(output.contains("limit"), "{output}");
assert!(!output.contains("```diff"), "{output}");
}
#[test]
fn a_later_matching_file_does_not_validate_a_different_observed_postimage() {
let directory = tempfile::TempDir::new().unwrap();
let path = directory.path().join("file");
std::fs::write(&path, "old\n").unwrap();
let before = capture(&Scope::All, &path);
std::fs::write(&path, "partial\n").unwrap();
let after = capture(&Scope::All, &path);
std::fs::write(&path, "requested\n").unwrap();
assert!(!verified_after(
&after,
&Scope::All,
&path,
Some(b"requested\n")
));
let output = failure(
"error: injected write failure".into(),
&receipt("file", &before, &after).text,
);
assert!(output.contains("-old\n+partial\n"), "{output}");
assert!(!output.contains("+requested"), "{output}");
}
#[test]
fn a_stale_captured_preimage_is_refused_even_if_the_file_changes_back() {
let directory = tempfile::TempDir::new().unwrap();
let path = directory.path().join("file");
std::fs::write(&path, "changed\n").unwrap();
let before = capture(&Scope::All, &path);
std::fs::write(&path, "initial\n").unwrap();
assert!(!current_preimage(&before, &Scope::All, &path, b"initial\n"));
std::fs::write(&path, "newer\n").unwrap();
assert!(!current_preimage(&before, &Scope::All, &path, b"changed\n"));
}
#[test]
fn snapshots_separate_absence_from_denial_and_invalid_utf8() {
let directory = tempfile::TempDir::new().unwrap();
let path = directory.path().join("file");
assert_eq!(capture(&Scope::All, &path), TextSnapshot::Absent);
assert!(matches!(
capture(&Scope::none(), &path),
TextSnapshot::Unavailable(_)
));
std::fs::write(&path, [0xff]).unwrap();
assert!(matches!(
capture(&Scope::All, &path),
TextSnapshot::Unavailable(_)
));
std::fs::write(&path, "\t界\r\nno trailing newline").unwrap();
assert_eq!(
capture(&Scope::All, &path),
TextSnapshot::Present("\t界\r\nno trailing newline".into())
);
}
#[cfg(unix)]
#[test]
fn physical_read_scopes_and_final_links_do_not_disclose_outside_contents() {
let root = tempfile::TempDir::new().unwrap();
let outside = tempfile::TempDir::new().unwrap();
std::fs::write(outside.path().join("file"), "outside\n").unwrap();
std::os::unix::fs::symlink(outside.path(), root.path().join("escape")).unwrap();
let scoped = Scope::only([root.path().to_string_lossy().into_owned()]);
assert!(matches!(
capture(&scoped, &root.path().join("escape/file")),
TextSnapshot::Unavailable(_)
));
std::fs::write(root.path().join("inside"), "inside\n").unwrap();
std::os::unix::fs::symlink("inside", root.path().join("link")).unwrap();
assert!(matches!(
capture(&scoped, &root.path().join("link")),
TextSnapshot::Unavailable(_)
));
assert_eq!(
matches(&scoped, &root.path().join("link"), b"inside\n"),
!cfg!(target_os = "macos")
);
}
}
#[cfg(test)]
mod line_range_tests {
use super::copy_line_range;
const SRC: &str = "l1\nl2\nl3\nl4\nl5\n";
#[test]
fn a_copied_range_is_byte_exact_and_follows_the_typed_header() {
assert_eq!(
copy_line_range("use x;\n", SRC, 2, 4).unwrap(),
"use x;\nl2\nl3\nl4\n"
);
assert_eq!(copy_line_range("", SRC, 5, 5).unwrap(), "l5\n");
}
#[test]
fn a_header_without_a_trailing_newline_still_starts_the_copy_on_its_own_line() {
assert_eq!(
copy_line_range("use x;", SRC, 1, 1).unwrap(),
"use x;\nl1\n"
);
}
#[test]
fn a_bad_range_is_refused_with_the_file_length() {
for (start, end) in [(0, 2), (3, 2), (4, 9)] {
let err = copy_line_range("", SRC, start, end).unwrap_err();
assert!(err.contains("5 lines"), "{err}");
}
}
}