use std::ffi::OsString;
use std::fs::{self, File, OpenOptions};
use std::io::{ErrorKind, Write};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use async_trait::async_trait;
use parking_lot::Mutex as ReportLock;
use tokio::sync::Mutex as WriterLock;
use crate::error::{Error, Result};
use crate::shared::credential_store::{
parse_credential_snapshot, CredentialKey, CredentialSnapshot, CredentialStore,
CredentialStoreAdmin, MigrationReport, StoredCredentials,
};
pub const CREDENTIAL_LOCK_SUFFIX: &str = ".lock";
pub const CREDENTIAL_LOCK_STALE_SECS: u64 = 30;
pub const CREDENTIAL_WRITE_EVENT_TARGET: &str = "pmcp::credential_file::write";
const LOCK_POLL_INTERVAL: Duration = Duration::from_millis(10);
const LOCK_WAIT_LIMIT: Duration = Duration::from_secs(45);
#[cfg(unix)]
const PRIVATE_FILE_MODE: u32 = 0o600;
#[cfg(unix)]
const PRIVATE_DIR_MODE: u32 = 0o700;
#[derive(Debug)]
pub struct FileCredentialStore {
path: PathBuf,
lock_path: PathBuf,
writer: WriterLock<()>,
migration_report: ReportLock<Option<MigrationReport>>,
}
impl FileCredentialStore {
pub fn new(path: PathBuf) -> Self {
let mut lock_name = path.clone().into_os_string();
lock_name.push(CREDENTIAL_LOCK_SUFFIX);
Self {
lock_path: PathBuf::from(lock_name),
path,
writer: WriterLock::new(()),
migration_report: ReportLock::new(None),
}
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn lock_path(&self) -> &Path {
&self.lock_path
}
fn read_snapshot(&self) -> Result<CredentialSnapshot> {
let bytes = match fs::read(&self.path) {
Ok(bytes) => bytes,
Err(err) if err.kind() == ErrorKind::NotFound => return Ok(CredentialSnapshot::new()),
Err(err) => return Err(self.unreadable(&err)),
};
let (snapshot, report) =
parse_credential_snapshot(&bytes).map_err(|err| self.unusable(&err))?;
if !report.is_noop() {
*self.migration_report.lock() = Some(report);
}
Ok(snapshot)
}
async fn with_snapshot_mut<F, T>(&self, mutate: F) -> Result<T>
where
F: FnOnce(&mut CredentialSnapshot) -> T + Send,
T: Send,
{
let _serialized = self.writer.lock().await;
let _lock = acquire_lock(&self.lock_path).await?;
let mut snapshot = self.read_snapshot()?;
let before = snapshot.clone();
let outcome = mutate(&mut snapshot);
if snapshot != before {
write_atomic(&self.path, &snapshot.to_bytes()?)?;
}
Ok(outcome)
}
fn unreadable(&self, err: &std::io::Error) -> Error {
Error::internal(format!(
"failed to read the credential file {}: {err}",
self.path.display()
))
}
fn unusable(&self, err: &Error) -> Error {
let path = self.path.display();
Error::validation(format!(
"the credential file {path} could not be understood: {err}. \
No file content is reproduced here; if it cannot be repaired, \
delete {path} and log in again."
))
}
}
#[async_trait]
impl CredentialStore for FileCredentialStore {
async fn load(&self, key: &CredentialKey) -> Result<Option<StoredCredentials>> {
Ok(self.read_snapshot()?.get(key).cloned())
}
async fn save(&self, key: &CredentialKey, credentials: &StoredCredentials) -> Result<()> {
self.with_snapshot_mut(|snapshot| snapshot.insert(key.clone(), credentials.clone()))
.await
}
async fn delete(&self, key: &CredentialKey) -> Result<()> {
self.with_snapshot_mut(|snapshot| {
snapshot.remove(key);
})
.await
}
async fn save_with_issuer(
&self,
key: &CredentialKey,
credentials: &StoredCredentials,
server_key: &str,
issuer: &str,
) -> Result<()> {
self.with_snapshot_mut(|snapshot| {
snapshot.insert(key.clone(), credentials.clone());
snapshot.record_issuer(server_key, issuer);
})
.await
}
async fn last_issuer(&self, server_key: &str) -> Result<Option<String>> {
Ok(self
.read_snapshot()?
.last_issuer(server_key)
.map(str::to_owned))
}
async fn record_issuer(&self, server_key: &str, issuer: &str) -> Result<()> {
self.with_snapshot_mut(|snapshot| snapshot.record_issuer(server_key, issuer))
.await
}
}
#[async_trait]
impl CredentialStoreAdmin for FileCredentialStore {
async fn list_keys(&self) -> Result<Vec<CredentialKey>> {
Ok(self.read_snapshot()?.keys())
}
async fn delete_by_server(&self, server_key: &str) -> Result<usize> {
self.with_snapshot_mut(|snapshot| {
let mut removed = 0usize;
for key in snapshot.keys_for_server(server_key) {
if snapshot.remove(&key) {
removed += 1;
}
}
snapshot.forget_issuer(server_key);
removed
})
.await
}
async fn clear_all(&self) -> Result<usize> {
self.with_snapshot_mut(CredentialSnapshot::clear).await
}
async fn take_migration_report(&self) -> Result<Option<MigrationReport>> {
Ok(self.migration_report.lock().take())
}
}
pub fn default_credential_path() -> Result<PathBuf> {
let mut path = dirs::home_dir().ok_or_else(|| {
Error::internal(
"could not determine the current user's home directory; \
pass an explicit path to FileCredentialStore::new instead",
)
})?;
path.push(".pmcp");
path.push("oauth-cache.json");
Ok(path)
}
fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
let parent = path.parent().ok_or_else(|| {
Error::validation(format!(
"the credential path has no parent directory: {}",
path.display()
))
})?;
create_private_dir(parent)?;
let temporary = temporary_sibling(path)?;
let _cleanup = RemoveOnDrop::new(temporary.clone());
let mut file = create_private_file(&temporary)?;
file.write_all(bytes)
.map_err(|err| io_failure("write", &temporary, &err))?;
file.flush()
.map_err(|err| io_failure("flush", &temporary, &err))?;
file.sync_all()
.map_err(|err| io_failure("synchronize", &temporary, &err))?;
restrict_file(&file, &temporary)?;
drop(file);
fs::rename(&temporary, path)
.map_err(|err| io_failure("atomically rename", &temporary, &err))?;
tracing::debug!(
target: CREDENTIAL_WRITE_EVENT_TARGET,
path = %path.display(),
bytes = bytes.len(),
"wrote the credential file atomically"
);
Ok(())
}
fn temporary_sibling(path: &Path) -> Result<PathBuf> {
let name = path.file_name().ok_or_else(|| {
Error::validation(format!(
"the credential path names no file: {}",
path.display()
))
})?;
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |since| since.as_nanos());
let mut candidate = OsString::from(name);
candidate.push(format!(".{}.{nanos}.tmp", std::process::id()));
Ok(path.with_file_name(candidate))
}
fn create_private_dir(dir: &Path) -> Result<()> {
fs::create_dir_all(dir).map_err(|err| io_failure("create the directory", dir, &err))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
drop(fs::set_permissions(
dir,
fs::Permissions::from_mode(PRIVATE_DIR_MODE),
));
}
Ok(())
}
fn open_exclusive(path: &Path) -> std::io::Result<File> {
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(PRIVATE_FILE_MODE);
}
options.open(path)
}
fn create_private_file(path: &Path) -> Result<File> {
open_exclusive(path).map_err(|err| io_failure("create", path, &err))
}
#[cfg(unix)]
fn restrict_file(file: &File, path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
file.set_permissions(fs::Permissions::from_mode(PRIVATE_FILE_MODE))
.map_err(|err| io_failure("restrict the permissions of", path, &err))
}
#[cfg(not(unix))]
fn restrict_file(_file: &File, _path: &Path) -> Result<()> {
Ok(())
}
fn io_failure(action: &str, path: &Path, err: &std::io::Error) -> Error {
Error::internal(format!("failed to {action} {}: {err}", path.display()))
}
#[derive(Debug)]
struct RemoveOnDrop {
path: PathBuf,
}
impl RemoveOnDrop {
fn new(path: PathBuf) -> Self {
Self { path }
}
}
impl Drop for RemoveOnDrop {
fn drop(&mut self) {
drop(fs::remove_file(&self.path));
}
}
async fn acquire_lock(lock_path: &Path) -> Result<RemoveOnDrop> {
let parent = lock_path.parent().ok_or_else(|| {
Error::validation(format!(
"the credential lock path has no parent directory: {}",
lock_path.display()
))
})?;
create_private_dir(parent)?;
let deadline = Instant::now() + LOCK_WAIT_LIMIT;
loop {
match open_exclusive(lock_path) {
Ok(file) => {
drop(file);
return Ok(RemoveOnDrop::new(lock_path.to_path_buf()));
},
Err(err) if err.kind() == ErrorKind::AlreadyExists => {},
Err(err) => return Err(io_failure("create the lock file", lock_path, &err)),
}
if break_stale_lock(lock_path) {
continue;
}
if Instant::now() >= deadline {
return Err(Error::internal(format!(
"gave up waiting {} seconds for the credential lock {}; \
another process is holding it. If no such process is running, \
the file is safe to delete.",
LOCK_WAIT_LIMIT.as_secs(),
lock_path.display()
)));
}
tokio::time::sleep(LOCK_POLL_INTERVAL).await;
}
}
fn break_stale_lock(lock_path: &Path) -> bool {
let Ok(metadata) = fs::metadata(lock_path) else {
return false;
};
let Ok(modified) = metadata.modified() else {
return false;
};
let Ok(age) = SystemTime::now().duration_since(modified) else {
return false;
};
if age.as_secs() < CREDENTIAL_LOCK_STALE_SECS {
return false;
}
tracing::warn!(
lock = %lock_path.display(),
age_secs = age.as_secs(),
stale_after_secs = CREDENTIAL_LOCK_STALE_SECS,
"breaking an abandoned credential lock; the process that took it did not release it"
);
fs::remove_file(lock_path).is_ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_lock_path_is_the_credential_path_plus_the_documented_suffix() {
let store = FileCredentialStore::new(PathBuf::from("/x/oauth-cache.json"));
assert_eq!(store.path(), Path::new("/x/oauth-cache.json"));
assert_eq!(
store.lock_path(),
Path::new("/x/oauth-cache.json.lock"),
"an operator who finds the file must be able to derive it from the suffix"
);
}
#[test]
fn the_wait_limit_exceeds_the_staleness_window() {
assert!(
LOCK_WAIT_LIMIT.as_secs() > CREDENTIAL_LOCK_STALE_SECS,
"wait {} must exceed staleness {}",
LOCK_WAIT_LIMIT.as_secs(),
CREDENTIAL_LOCK_STALE_SECS
);
}
#[test]
fn a_temporary_sibling_stays_in_the_same_directory() {
let path = Path::new("/x/y/oauth-cache.json");
let temporary = temporary_sibling(path).expect("the path names a file");
assert_eq!(
temporary.parent(),
path.parent(),
"a rename is only atomic within one filesystem"
);
assert_ne!(temporary, path);
assert!(
temporary.to_string_lossy().ends_with(".tmp"),
"{}",
temporary.display()
);
}
#[test]
fn a_path_that_names_no_file_is_refused_rather_than_guessed_at() {
let message = temporary_sibling(Path::new("/"))
.expect_err("a root path names no file")
.to_string();
assert!(message.contains("names no file"), "{message}");
}
#[test]
fn a_missing_lock_is_not_stale() {
assert!(!break_stale_lock(Path::new(
"/nonexistent/pmcp/oauth-cache.json.lock"
)));
}
}