use std::fs::{self, OpenOptions, TryLockError};
use std::io::{self, Write};
use std::path::Path;
#[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().map_err(E::from)?;
let result = operation();
let unlock = lock.unlock();
match (result, unlock) {
(Err(error), _) => Err(error),
(Ok(_), Err(error)) => Err(E::from(error)),
(Ok(value), Ok(())) => Ok(value),
}
}
pub fn with_shared_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_shared().map_err(E::from)?;
let result = operation();
let unlock = lock.unlock();
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 _ = self.file.unlock();
}
}
const LOCK_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(20);
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 file.try_lock() {
Ok(()) => {
return Ok(FileLockGuard {
file,
path: path.to_path_buf(),
});
}
Err(TryLockError::WouldBlock) => {
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(TryLockError::Error(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
);
}
}
#[tokio::test]
async fn contention_is_told_apart_from_a_lock_that_cannot_work() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("credential.lock");
let holder = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&path)
.unwrap();
holder.lock().unwrap();
let waiter = OpenOptions::new()
.read(true)
.write(true)
.open(&path)
.unwrap();
assert!(
matches!(waiter.try_lock(), Err(TryLockError::WouldBlock)),
"a contended lock must report WouldBlock, not a platform errno"
);
let refused = lock_exclusive_async(&path, std::time::Duration::from_millis(60)).await;
let error = refused.expect_err("the lock was held");
assert_eq!(error.kind(), io::ErrorKind::WouldBlock);
holder.unlock().unwrap();
assert!(
lock_exclusive_async(&path, std::time::Duration::from_millis(60))
.await
.is_ok(),
"the lock must be available once the holder releases it"
);
}
#[tokio::test]
async fn lock_open_errors_are_returned_to_the_caller() {
let directory = tempfile::tempdir().unwrap();
let blocking_file = directory.path().join("not-a-directory");
fs::write(&blocking_file, b"occupied").unwrap();
let error = lock_exclusive_async(
&blocking_file.join("credential.lock"),
std::time::Duration::from_millis(60),
)
.await
.expect_err("a lock below a regular file cannot be opened");
assert_ne!(error.kind(), io::ErrorKind::WouldBlock);
assert!(error.raw_os_error().is_some(), "{error}");
}
#[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}");
}
}