use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use crate::error::{Result, XurlError};
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,
}
pub fn default_pending_path() -> Result<PathBuf> {
dirs::home_dir()
.map(|h| h.join(".xurl.pending"))
.ok_or_else(|| {
XurlError::auth(
"could not determine home directory for pending state file. \
Set the HOME environment variable",
)
})
}
pub fn save(state: &PendingOAuth2State, path: &Path) -> Result<()> {
let data = serde_yaml::to_string(state).map_err(|e| XurlError::Auth(e.to_string()))?;
let mut tmp_os = path.as_os_str().to_os_string();
tmp_os.push(".tmp");
let tmp_path = std::path::PathBuf::from(tmp_os);
if tmp_path.exists() {
let _ = fs::remove_file(&tmp_path);
}
{
let mut opts = OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let mut file = opts.open(&tmp_path)?;
file.write_all(data.as_bytes())?;
file.flush()?;
file.sync_all()?;
}
fs::rename(&tmp_path, path)?;
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(XurlError::auth(
"PendingStateNotFound: no pending OAuth2 state file found",
));
}
Err(e) => return Err(e.into()),
};
if meta.file_type().is_symlink() {
return Err(XurlError::auth(
"PendingStatePermissions: pending state file is a symlink (rejected for security)",
));
}
let mode = meta.permissions().mode() & 0o777;
if mode != 0o600 {
return Err(XurlError::auth(format!(
"PendingStatePermissions: expected mode 0600, got {mode:04o}"
)));
}
let file_uid = meta.uid();
let current_uid = unsafe { libc::getuid() };
if file_uid != current_uid {
return Err(XurlError::auth(format!(
"PendingStatePermissions: file owned by uid {file_uid}, expected {current_uid}"
)));
}
}
#[cfg(not(unix))]
if !path.exists() {
return Err(XurlError::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(XurlError::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| XurlError::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(XurlError::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()),
}
}