use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct FileFingerprint {
pub(crate) size: u64,
pub(crate) mtime_ms: u64,
pub(crate) md5: String,
}
#[derive(Clone, Debug)]
pub(crate) struct FilePreimage {
pub(crate) fp: FileFingerprint,
pub(crate) permissions: std::fs::Permissions,
pub(crate) bytes: Vec<u8>,
pub(crate) text: String,
pub(crate) uses_crlf: bool,
}
pub(crate) fn system_time_to_millis(t: SystemTime) -> u64 {
t.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_millis() as u64)
}
pub(crate) fn reject_symlink(path: &Path) -> Result<(), String> {
if let Ok(meta) = std::fs::symlink_metadata(path) {
if crate::core::pathutil::is_symlink_or_reparse(&meta) {
return Err(format!(
"ERROR: {} is a symlink — refusing to edit through it (TOCTOU protection). \
Edit the symlink target directly via its real path.",
path.display()
));
}
}
Ok(())
}
pub(crate) fn read_file_bytes_limited(
path: &Path,
cap: usize,
) -> Result<(Vec<u8>, std::fs::Metadata), String> {
reject_symlink(path)?;
if let Ok(meta) = std::fs::metadata(path)
&& meta.len() > cap as u64
{
return Err(format!(
"ERROR: file too large ({} bytes, cap {} via LCTX_MAX_READ_BYTES): {}",
meta.len(),
cap,
path.display()
));
}
let mut opts = std::fs::OpenOptions::new();
opts.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.custom_flags(libc::O_NOFOLLOW);
}
let mut file = opts.open(path).map_err(|e| {
#[cfg(unix)]
if e.raw_os_error() == Some(libc::ELOOP) {
return format!(
"ERROR: {} is a symlink — refusing to edit through it (TOCTOU protection).",
path.display()
);
}
format!("ERROR: cannot open {}: {e}", path.display())
})?;
use std::io::Read;
let mut raw: Vec<u8> = Vec::new();
let mut limited = (&mut file).take((cap as u64).saturating_add(1));
limited
.read_to_end(&mut raw)
.map_err(|e| format!("ERROR: cannot read {}: {e}", path.display()))?;
if raw.len() > cap {
return Err(format!(
"ERROR: file too large (cap {} via LCTX_MAX_READ_BYTES): {}",
cap,
path.display()
));
}
let meta = file
.metadata()
.map_err(|e| format!("ERROR: cannot stat {}: {e}", path.display()))?;
Ok((raw, meta))
}
pub(crate) fn fingerprint_from_bytes(bytes: &[u8], meta: &std::fs::Metadata) -> FileFingerprint {
FileFingerprint {
size: bytes.len() as u64,
mtime_ms: meta.modified().map_or(0, system_time_to_millis),
md5: crate::core::hasher::hash_hex(bytes),
}
}
pub(crate) fn read_preimage(
path: &Path,
cap: usize,
allow_lossy_utf8: bool,
) -> Result<FilePreimage, String> {
let (bytes, meta) = read_file_bytes_limited(path, cap)?;
let permissions = meta.permissions();
let fp = fingerprint_from_bytes(&bytes, &meta);
let text = if allow_lossy_utf8 {
String::from_utf8_lossy(&bytes).into_owned()
} else {
String::from_utf8(bytes.clone()).map_err(|_| {
format!(
"ERROR: file is not valid UTF-8 (binary/encoding). Refusing to edit: {}",
path.display()
)
})?
};
let uses_crlf = text.contains("\r\n");
Ok(FilePreimage {
fp,
permissions,
bytes,
text,
uses_crlf,
})
}
pub(crate) fn ensure_preimage_still_matches(
path: &Path,
expected: &FileFingerprint,
cap: usize,
) -> Result<(), String> {
let (bytes, meta) = read_file_bytes_limited(path, cap)?;
let now = fingerprint_from_bytes(&bytes, &meta);
if &now != expected {
return Err(format!(
"ERROR: file changed since read (TOCTOU guard). Re-read and retry: {}\nexpected: size={}, mtime_ms={}, md5={}\nactual: size={}, mtime_ms={}, md5={}",
path.display(),
expected.size,
expected.mtime_ms,
expected.md5,
now.size,
now.mtime_ms,
now.md5
));
}
Ok(())
}
pub(crate) fn default_backup_path(path: &Path) -> Option<PathBuf> {
let parent = path.parent()?;
let filename = path.file_name()?.to_string_lossy();
let pid = std::process::id();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
Some(parent.join(format!("{filename}.lean-ctx.bak.{pid}.{nanos}")))
}
pub(crate) fn write_atomic_bytes_with_permissions(
path: &Path,
bytes: &[u8],
permissions: Option<&std::fs::Permissions>,
) -> Result<(), String> {
crate::core::pathjail::enforce_writable(path)?;
reject_symlink(path)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
crate::core::atomic_fs::write_bytes_with_fallback(path, bytes, permissions)
.map_err(|e| format!("ERROR: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_toctou_via_preimage_guard() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("toctou.txt");
std::fs::write(&path, "aaa\n").unwrap();
let cap = crate::core::limits::max_read_bytes();
let pre = read_preimage(&path, cap, false).unwrap();
std::fs::write(&path, "bbb\n").unwrap();
let err = ensure_preimage_still_matches(&path, &pre.fp, cap).unwrap_err();
assert!(err.contains("TOCTOU guard"), "unexpected error: {err}");
}
#[test]
fn read_preimage_rejects_invalid_utf8_by_default() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bin.dat");
std::fs::write(&path, [0xff, 0xfe, 0xfd]).unwrap();
let cap = crate::core::limits::max_read_bytes();
let err = read_preimage(&path, cap, false).unwrap_err();
assert!(err.contains("not valid UTF-8"), "got: {err}");
assert!(read_preimage(&path, cap, true).is_ok());
}
#[test]
fn fingerprint_is_content_addressed() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("fp.txt");
std::fs::write(&path, "hello\n").unwrap();
let cap = crate::core::limits::max_read_bytes();
let a = read_preimage(&path, cap, false).unwrap().fp;
let b = read_preimage(&path, cap, false).unwrap().fp;
assert_eq!(a, b, "same bytes → same fingerprint");
assert_eq!(a.md5, crate::core::hasher::hash_hex(b"hello\n"));
}
}