Files
feresaria/crates/ferese-config/src/file.rs
T

208 lines
6.9 KiB
Rust

use std::fs::{self, File, OpenOptions};
use std::io::{Read, Write};
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf};
pub const LIMIT: usize = 60 * 1024;
pub struct ConfigFile {
path: PathBuf,
source: String,
// Keep the sidecar inode: deleting it would let another writer lock a new inode.
_lock: File,
}
impl ConfigFile {
pub fn open(path: &Path) -> Result<Self, String> {
let parent = path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or(Path::new("."));
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
let path = match path.canonicalize() {
Ok(path) => path,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => parent
.canonicalize()
.map_err(|e| e.to_string())?
.join(path.file_name().ok_or("Invalid configuration path")?),
Err(error) => return Err(error.to_string()),
};
let mut lock_name = path
.file_name()
.ok_or("Invalid configuration path")?
.to_os_string();
lock_name.push(".lock");
let lock = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.mode(0o600)
.open(path.with_file_name(lock_name))
.map_err(|e| e.to_string())?;
lock.try_lock().map_err(|error| match error {
fs::TryLockError::WouldBlock => {
"Configuration is being saved by another app. Please try again.".into()
}
fs::TryLockError::Error(error) => error.to_string(),
})?;
let mut file = Self {
path,
source: String::new(),
_lock: lock,
};
file.source = read(&file.path)?;
Ok(file)
}
#[must_use]
pub fn source(&self) -> &str {
&self.source
}
pub fn replace(
self,
desired: &str,
backup: Option<&Path>,
validate: impl FnOnce(&Path) -> Result<(), String>,
) -> Result<(), String> {
if desired.len() > LIMIT {
return Err("Configuration exceeds the 60 KiB limit.".into());
}
let parent = self.path.parent().ok_or("Invalid configuration path")?;
let mut staged = tempfile::NamedTempFile::new_in(parent).map_err(|e| e.to_string())?;
if let Ok(metadata) = fs::metadata(&self.path) {
staged
.as_file()
.set_permissions(metadata.permissions())
.map_err(|e| e.to_string())?;
}
staged
.write_all(desired.as_bytes())
.map_err(|e| e.to_string())?;
staged.as_file().sync_all().map_err(|e| e.to_string())?;
validate(staged.path())?;
if read(&self.path)? != self.source {
return Err("Configuration changed while saving. Please reload and try again.".into());
}
if let Some(path) = backup {
let mut previous =
tempfile::NamedTempFile::new_in(parent).map_err(|e| e.to_string())?;
previous
.write_all(self.source.as_bytes())
.map_err(|e| e.to_string())?;
previous.as_file().sync_all().map_err(|e| e.to_string())?;
previous.persist(path).map_err(|e| e.to_string())?;
}
staged.persist(&self.path).map_err(|e| e.to_string())?;
File::open(parent)
.and_then(|directory| directory.sync_all())
.map_err(|e| e.to_string())?;
Ok(())
}
}
impl Drop for ConfigFile {
fn drop(&mut self) {
// A spawned child can briefly retain the open-file description after our
// descriptor closes. Release the lock before closing the owner's handle.
let _ = self._lock.unlock();
}
}
fn read(path: &Path) -> Result<String, String> {
let file = match File::open(path) {
Ok(file) => file,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(String::new()),
Err(error) => return Err(error.to_string()),
};
let mut source = String::new();
file.take((LIMIT + 1) as u64)
.read_to_string(&mut source)
.map_err(|e| e.to_string())?;
if source.len() > LIMIT {
return Err("Configuration exceeds the 60 KiB limit.".into());
}
Ok(source)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lock_excludes_other_processes_and_survives_replacement() {
const CHILD: &str = "FERESE_CONFIG_LOCK_TEST_PATH";
if let Some(path) = std::env::var_os(CHILD) {
assert!(
ConfigFile::open(Path::new(&path))
.err()
.unwrap()
.contains("another app")
);
return;
}
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.kdl");
for source in ["// first\n", "// second\n"] {
let file = ConfigFile::open(&path).unwrap();
let status = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"file::tests::lock_excludes_other_processes_and_survives_replacement",
])
.env(CHILD, &path)
.status()
.unwrap();
assert!(status.success());
file.replace(source, None, |_| Ok(())).unwrap();
assert_eq!(ConfigFile::open(&path).unwrap().source(), source);
}
}
#[test]
fn symlink_aliases_share_a_lock_and_preserve_the_link() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.kdl");
let alias = directory.path().join("alias.kdl");
fs::write(&path, "// initial").unwrap();
std::os::unix::fs::symlink(&path, &alias).unwrap();
let file = ConfigFile::open(&path).unwrap();
assert!(ConfigFile::open(&alias).is_err());
// A concurrent process spawn can briefly inherit this open-file description.
let inherited = file._lock.try_clone().unwrap();
drop(file);
ConfigFile::open(&alias)
.unwrap()
.replace("// updated", None, |_| Ok(()))
.unwrap();
assert!(alias.is_symlink());
assert_eq!(fs::read_to_string(path).unwrap(), "// updated");
drop(inherited);
}
#[test]
fn failed_validation_releases_lock_and_preserves_contents() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.kdl");
fs::write(&path, "// initial").unwrap();
let file = ConfigFile::open(&path).unwrap();
assert!(
file.replace("// rejected", None, |_| Err("invalid".into()))
.is_err()
);
assert_eq!(ConfigFile::open(&path).unwrap().source(), "// initial");
}
}