use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
const PENDING_TTL_SECS: u64 = 900;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PendingOAuth2State {
pub code_verifier: String,
pub state: String,
pub client_id: String,
pub app_name: String,
pub created_at: u64,
}
#[must_use]
pub fn pending_path_for_store(store_path: &Path) -> PathBuf {
let mut os = store_path.as_os_str().to_os_string();
os.push(".pending");
PathBuf::from(os)
}
pub fn default_pending_path() -> Result<PathBuf> {
dirs::home_dir()
.map(|h| pending_path_for_store(&h.join(".xurl")))
.ok_or_else(|| {
Error::auth(
"could not determine home directory for pending state file. \
Set the HOME environment variable",
)
})
}
pub fn save(state: &PendingOAuth2State, path: &Path) -> Result<()> {
if fs::symlink_metadata(path).is_ok_and(|meta| meta.file_type().is_symlink()) {
return Err(Error::auth(
"PendingStatePermissions: pending state path is a symlink (refusing to write through it)",
));
}
let data = serde_yaml::to_string(state).map_err(|e| Error::Auth(e.to_string()))?;
crate::store::write_atomically(path, data.as_bytes())?;
Ok(())
}
pub fn load(path: &Path) -> Result<PendingOAuth2State> {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
use std::os::unix::fs::PermissionsExt;
let meta = match fs::symlink_metadata(path) {
Ok(m) => m,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(Error::auth(
"PendingStateNotFound: no pending OAuth2 state file found",
));
}
Err(e) => return Err(e.into()),
};
if meta.file_type().is_symlink() {
return Err(Error::auth(
"PendingStatePermissions: pending state file is a symlink (rejected for security)",
));
}
let mode = meta.permissions().mode() & 0o777;
if mode != 0o600 {
return Err(Error::auth(format!(
"PendingStatePermissions: expected mode 0600, got {mode:04o}"
)));
}
let file_uid = meta.uid();
let current_uid = rustix::process::getuid().as_raw();
if file_uid != current_uid {
return Err(Error::auth(format!(
"PendingStatePermissions: file owned by uid {file_uid}, expected {current_uid}"
)));
}
}
#[cfg(not(unix))]
if !path.exists() {
return Err(Error::auth(
"PendingStateNotFound: no pending OAuth2 state file found",
));
}
let data = match fs::read_to_string(path) {
Ok(d) => d,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(Error::auth(
"PendingStateNotFound: no pending OAuth2 state file found",
));
}
Err(e) => return Err(e.into()),
};
let state: PendingOAuth2State =
serde_yaml::from_str(&data).map_err(|e| Error::Auth(e.to_string()))?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
if now.saturating_sub(state.created_at) > PENDING_TTL_SECS {
let _ = fs::remove_file(path);
return Err(Error::auth(
"PendingStateExpired: pending OAuth2 state is older than 15 minutes",
));
}
Ok(state)
}
pub fn delete(path: &Path) -> Result<()> {
match fs::remove_file(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.into()),
}
}