use std::io::Write;
use std::path::Path;
#[cfg(unix)]
const OWNER_ONLY: u32 = 0o600;
#[cfg(unix)]
const OWNER_ONLY_DIR: u32 = 0o700;
pub(crate) fn create_parent_dir(path: &Path) -> std::io::Result<()> {
let Some(parent) = path.parent() else {
return Ok(());
};
if parent.as_os_str().is_empty() || parent.exists() {
return Ok(());
}
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
std::fs::DirBuilder::new()
.recursive(true)
.mode(OWNER_ONLY_DIR)
.create(parent)
}
#[cfg(not(unix))]
{
std::fs::create_dir_all(parent)
}
}
pub(crate) fn restrict_existing(path: &Path) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let Ok(metadata) = std::fs::metadata(path) else {
return;
};
let mode = metadata.permissions().mode();
if mode & 0o077 != 0 {
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(OWNER_ONLY));
}
}
#[cfg(not(unix))]
let _ = path;
}
pub(crate) fn ensure_owner_only(path: &Path) -> std::io::Result<()> {
create_parent_dir(path)?;
if !path.exists() {
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(OWNER_ONLY);
}
match options.open(path) {
Ok(_) => return Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(e) => return Err(e),
}
}
restrict_existing(path);
Ok(())
}
pub(crate) fn write_atomic(path: &Path, contents: &str) -> std::io::Result<()> {
create_parent_dir(path)?;
let directory = match path.parent() {
Some(parent) if !parent.as_os_str().is_empty() => parent.to_path_buf(),
_ => std::path::PathBuf::from("."),
};
let mut file = tempfile::Builder::new()
.prefix(".mcp-repl")
.suffix(".tmp")
.tempfile_in(&directory)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
file.as_file()
.set_permissions(std::fs::Permissions::from_mode(OWNER_ONLY))?;
}
file.write_all(contents.as_bytes())?;
file.flush()?;
file.persist(path)
.map_err(|e| std::io::Error::other(format!("{}: {}", path.display(), e.error)))?;
Ok(())
}
pub(crate) fn write_bytes(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
create_parent_dir(path)?;
let mut options = std::fs::OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(OWNER_ONLY);
}
let mut file = options.open(path)?;
file.write_all(bytes)?;
file.flush()?;
restrict_existing(path);
Ok(())
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
fn mode_of(path: &Path) -> u32 {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(path).unwrap().permissions().mode() & 0o777
}
#[test]
fn written_files_are_owner_only() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nested").join("config.toml");
write_atomic(&path, "[servers]\n").unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "[servers]\n");
assert_eq!(mode_of(&path) & 0o077, 0, "group/other bits must be clear");
}
#[test]
fn rewriting_keeps_permissions_and_leaves_no_temp_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
write_atomic(&path, "first").unwrap();
write_atomic(&path, "second").unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "second");
assert_eq!(mode_of(&path) & 0o077, 0);
let strays: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(Result::ok)
.filter(|e| e.file_name() != "config.toml")
.collect();
assert!(strays.is_empty(), "temporary file left behind");
}
#[test]
fn an_existing_permissive_file_is_tightened() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("history");
std::fs::write(&path, "echo message=hi\n").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
ensure_owner_only(&path).unwrap();
assert_eq!(mode_of(&path) & 0o077, 0);
assert_eq!(std::fs::read_to_string(&path).unwrap(), "echo message=hi\n");
}
#[test]
fn a_new_history_file_is_created_owner_only() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("state").join("history");
ensure_owner_only(&path).unwrap();
assert!(path.exists());
assert_eq!(mode_of(&path) & 0o077, 0);
assert_eq!(mode_of(path.parent().unwrap()) & 0o077, 0);
}
}