use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
fn invalid_input(msg: &'static str) -> std::io::Error {
std::io::Error::new(std::io::ErrorKind::InvalidInput, msg)
}
pub(crate) fn try_atomic_write(
path: &Path,
bytes: &[u8],
permissions: Option<&std::fs::Permissions>,
) -> std::io::Result<()> {
use std::io::Write;
let parent = path
.parent()
.ok_or_else(|| invalid_input("invalid path (no parent directory)"))?;
let filename = path
.file_name()
.ok_or_else(|| invalid_input("invalid path (no filename)"))?
.to_string_lossy();
let pid = std::process::id();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
let tmp = parent.join(format!(".{filename}.lean-ctx.tmp.{pid}.{nanos}"));
{
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&tmp)?;
f.write_all(bytes)?;
let _ = f.flush();
let _ = f.sync_all();
}
if let Some(perms) = permissions {
let _ = std::fs::set_permissions(&tmp, perms.clone());
}
#[cfg(windows)]
{
if path.exists() {
let _ = std::fs::remove_file(path);
}
}
if let Err(e) = std::fs::rename(&tmp, path) {
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
Ok(())
}
pub(crate) fn in_place_overwrite(
path: &Path,
bytes: &[u8],
permissions: Option<&std::fs::Permissions>,
) -> std::io::Result<()> {
use std::io::Write;
let mut opts = std::fs::OpenOptions::new();
opts.write(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.custom_flags(libc::O_NOFOLLOW);
}
let mut f = opts.open(path)?;
f.write_all(bytes)?;
let _ = f.flush();
let _ = f.sync_all();
if let Some(perms) = permissions {
let _ = std::fs::set_permissions(path, perms.clone());
}
Ok(())
}
pub(crate) fn is_readonly_dir_error(e: &std::io::Error) -> bool {
if e.kind() == std::io::ErrorKind::PermissionDenied {
return true;
}
#[cfg(unix)]
{
matches!(
e.raw_os_error(),
Some(libc::EROFS | libc::EACCES | libc::EPERM)
)
}
#[cfg(not(unix))]
{
false
}
}
pub(crate) fn write_bytes_with_fallback(
path: &Path,
bytes: &[u8],
permissions: Option<&std::fs::Permissions>,
) -> Result<(), String> {
match try_atomic_write(path, bytes, permissions) {
Ok(()) => Ok(()),
Err(e) if is_readonly_dir_error(&e) && path.is_file() => {
in_place_overwrite(path, bytes, permissions).map_err(|fallback_err| {
format!(
"atomic write failed ({e}); in-place fallback also failed: {fallback_err} ({})",
path.display()
)
})
}
Err(e) => Err(format!("atomic write failed: {e} ({})", path.display())),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn readonly_dir_error_classification() {
assert!(is_readonly_dir_error(&std::io::Error::from(
std::io::ErrorKind::PermissionDenied
)));
assert!(!is_readonly_dir_error(&std::io::Error::from(
std::io::ErrorKind::NotFound
)));
#[cfg(unix)]
{
assert!(is_readonly_dir_error(&std::io::Error::from_raw_os_error(
libc::EROFS
)));
assert!(is_readonly_dir_error(&std::io::Error::from_raw_os_error(
libc::EACCES
)));
assert!(is_readonly_dir_error(&std::io::Error::from_raw_os_error(
libc::EPERM
)));
}
}
#[test]
fn try_atomic_write_creates_and_replaces() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("cfg.toml");
try_atomic_write(&path, b"first", None).unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"first");
let strays: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.flatten()
.filter(|e| e.file_name().to_string_lossy().contains(".lean-ctx.tmp."))
.collect();
assert!(strays.is_empty(), "temp file must not linger");
try_atomic_write(&path, b"second", None).unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"second");
}
#[cfg(unix)]
#[test]
fn in_place_overwrite_truncates_existing_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.jsonc");
std::fs::write(&path, b"longer original content").unwrap();
in_place_overwrite(&path, b"short", None).unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"short");
}
#[cfg(unix)]
#[test]
fn fallback_overwrites_when_parent_dir_is_readonly() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("cfg.toml");
std::fs::write(&path, b"original").unwrap();
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o500)).unwrap();
let res = write_bytes_with_fallback(&path, b"updated", None);
let _ = std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700));
res.expect("read-only-dir fallback must succeed");
assert_eq!(std::fs::read(&path).unwrap(), b"updated");
}
}