use std::fs::{self, OpenOptions};
use std::io::{self, Write};
use std::path::Path;
use fs2::FileExt;
#[must_use]
pub fn describe_write_failure(path: &Path, error: &io::Error) -> String {
if error.kind() == io::ErrorKind::ReadOnlyFilesystem {
return format!(
"cannot write {}: the credential directory is mounted read-only. \
Re-run without `:ro` to authorize, then restore it — serving and \
token renewal do not need write access.",
path.display()
);
}
format!("could not create {}: {error}", path.display())
}
pub fn atomic_write_owner_only(path: &Path, contents: &[u8]) -> io::Result<()> {
let parent = path
.parent()
.ok_or_else(|| io::Error::other("durable path has no parent directory"))?;
fs::create_dir_all(parent)?;
let name = path
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| io::Error::other("durable file name is not valid UTF-8"))?;
let temporary = parent.join(format!(
".{name}.{}.{}.tmp",
std::process::id(),
uuid::Uuid::new_v4()
));
let result = (|| {
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
options.mode(0o600);
}
let mut file = options.open(&temporary)?;
file.write_all(contents)?;
file.sync_all()?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
file.set_permissions(fs::Permissions::from_mode(0o600))?;
}
drop(file);
fs::rename(&temporary, path)?;
sync_directory(parent)
})();
if result.is_err() {
let _ = fs::remove_file(&temporary);
}
result
}
pub fn with_exclusive_lock<T, E>(
path: &Path,
operation: impl FnOnce() -> Result<T, E>,
) -> Result<T, E>
where
E: From<io::Error>,
{
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(E::from)?;
}
let mut options = OpenOptions::new();
options.read(true).write(true).create(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
options.mode(0o600);
}
let lock = options.open(path).map_err(E::from)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
lock.set_permissions(fs::Permissions::from_mode(0o600))
.map_err(E::from)?;
}
lock.lock_exclusive().map_err(E::from)?;
let result = operation();
let unlock = FileExt::unlock(&lock);
match (result, unlock) {
(Err(error), _) => Err(error),
(Ok(_), Err(error)) => Err(E::from(error)),
(Ok(value), Ok(())) => Ok(value),
}
}
#[derive(Debug)]
pub struct FileLockGuard {
file: fs::File,
path: std::path::PathBuf,
}
impl FileLockGuard {
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
}
impl Drop for FileLockGuard {
fn drop(&mut self) {
let _ = FileExt::unlock(&self.file);
}
}
const LOCK_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(20);
fn is_contended(error: &io::Error) -> bool {
error.kind() == io::ErrorKind::WouldBlock
|| (error.raw_os_error().is_some()
&& error.raw_os_error() == fs2::lock_contended_error().raw_os_error())
}
pub async fn lock_exclusive_async(
path: &Path,
timeout: std::time::Duration,
) -> io::Result<FileLockGuard> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut options = OpenOptions::new();
options.read(true).write(true).create(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
options.mode(0o600);
}
let file = options.open(path)?;
let mut waited = std::time::Duration::ZERO;
loop {
match FileExt::try_lock_exclusive(&file) {
Ok(()) => {
return Ok(FileLockGuard {
file,
path: path.to_path_buf(),
});
}
Err(error) if is_contended(&error) => {
if waited >= timeout {
return Err(io::Error::new(
io::ErrorKind::WouldBlock,
format!("timed out waiting for the lock on {}", path.display()),
));
}
tokio::time::sleep(LOCK_POLL_INTERVAL).await;
waited = waited.saturating_add(LOCK_POLL_INTERVAL);
}
Err(error) => return Err(error),
}
}
}
pub fn sync_directory(path: &Path) -> io::Result<()> {
#[cfg(unix)]
{
fs::File::open(path)?.sync_all()
}
#[cfg(not(unix))]
{
let _ = path;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn durable_write_is_owner_only_and_leaves_no_temporary_file() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("state.json");
atomic_write_owner_only(&path, b"one").unwrap();
atomic_write_owner_only(&path, b"two").unwrap();
assert_eq!(fs::read(&path).unwrap(), b"two");
assert_eq!(fs::read_dir(directory.path()).unwrap().count(), 1);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
assert_eq!(
fs::metadata(path).unwrap().permissions().mode() & 0o777,
0o600
);
}
}
#[test]
fn contention_is_told_apart_from_a_lock_that_cannot_work() {
assert!(
is_contended(&fs2::lock_contended_error()),
"the error fs2 documents for a contended lock must be recognised"
);
assert!(is_contended(&io::Error::from(io::ErrorKind::WouldBlock)));
assert!(!is_contended(&io::Error::from(
io::ErrorKind::PermissionDenied
)));
assert!(!is_contended(&io::Error::other("read-only file system")));
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn an_exclusive_lock_excludes_and_then_gives_up() {
use std::os::unix::fs::PermissionsExt as _;
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("nested").join("credential.lock");
let taken = directory.path().join("taken");
{
let guard = lock_exclusive_async(&path, std::time::Duration::from_secs(1))
.await
.expect("first holder");
assert_eq!(guard.path(), path);
assert_eq!(
fs::metadata(&path).unwrap().permissions().mode() & 0o777,
0o600
);
}
let mut holder = std::process::Command::new("sh")
.arg("-c")
.arg(format!(
"exec 9>>'{}'; flock 9 && touch '{}' && sleep 5",
path.display(),
taken.display()
))
.spawn()
.expect("spawn the competing holder");
for _ in 0..200 {
if taken.exists() {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
assert!(taken.exists(), "the competing holder never took the lock");
let refused = lock_exclusive_async(&path, std::time::Duration::from_millis(60)).await;
let error = refused.expect_err("the lock was held by another process");
assert_eq!(error.kind(), io::ErrorKind::WouldBlock);
assert!(error.to_string().contains("credential.lock"), "{error}");
let _ = holder.kill();
let _ = holder.wait();
}
#[test]
fn a_read_only_mount_is_named_as_the_cause() {
let message = describe_write_failure(
Path::new("/data/claude/.credentials.json"),
&io::Error::from(io::ErrorKind::ReadOnlyFilesystem),
);
assert!(
message.contains("/data/claude/.credentials.json"),
"{message}"
);
assert!(message.contains("read-only"), "{message}");
assert!(message.contains(":ro"), "{message}");
assert!(
message.contains("token renewal do not need write"),
"{message}"
);
}
#[test]
fn other_write_failures_keep_the_underlying_error() {
let message = describe_write_failure(
Path::new("/data/x.json"),
&io::Error::from(io::ErrorKind::PermissionDenied),
);
assert!(message.contains("/data/x.json"), "{message}");
assert!(!message.contains("read-only"), "{message}");
}
}