use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use tempfile::NamedTempFile;
use thiserror::Error;
pub const SESSION_VERSION: u32 = 1;
pub const SESSION_FILE_ENV: &str = "CAIRN_SESSION_FILE";
const DEFAULT_RELATIVE_PATH: &str = "cairn/session.json";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SessionFile {
pub version: u32,
pub cairn_server_url: String,
pub cairn_service_did: String,
pub pds_url: String,
pub moderator_did: String,
pub moderator_handle: String,
pub access_jwt: String,
pub refresh_jwt: String,
}
#[derive(Debug, Error)]
pub enum SessionError {
#[error("io: {0}")]
Io(#[from] io::Error),
#[error("session file {path} has insecure permissions (mode {mode:o}); expected 600")]
InsecurePermissions {
path: PathBuf,
mode: u32,
},
#[error("session file {path} is owned by another user")]
ForeignOwner {
path: PathBuf,
},
#[error(
"session file {path} has unsupported version {found} (expected {expected}); re-run `cairn login`"
)]
UnsupportedVersion {
path: PathBuf,
found: u32,
expected: u32,
},
#[error("session file {path} is malformed: {source}")]
Malformed {
path: PathBuf,
#[source]
source: serde_json::Error,
},
#[error("could not resolve a config directory (set {env} to override)", env = SESSION_FILE_ENV)]
NoConfigDir,
#[error("cairn CLI on this platform is not supported in v1; use a POSIX filesystem and set {env}", env = SESSION_FILE_ENV)]
UnsupportedPlatform,
}
impl From<crate::credential_file::CredentialFileError> for SessionError {
fn from(e: crate::credential_file::CredentialFileError) -> Self {
use crate::credential_file::CredentialFileError as C;
match e {
C::Io(io) => SessionError::Io(io),
C::InsecurePermissions { path, mode } => {
SessionError::InsecurePermissions { path, mode }
}
C::ForeignOwner { path } => SessionError::ForeignOwner { path },
C::UnsupportedPlatform => SessionError::UnsupportedPlatform,
C::EnvOverrideRejected { env } => SessionError::Io(io::Error::other(format!(
"unexpected env-override rejection for {env}"
))),
}
}
}
pub fn default_path() -> Result<PathBuf, SessionError> {
default_path_with_env(|k| std::env::var_os(k))
}
fn default_path_with_env<F>(get: F) -> Result<PathBuf, SessionError>
where
F: Fn(&str) -> Option<std::ffi::OsString>,
{
if let Some(p) = get(SESSION_FILE_ENV) {
return Ok(PathBuf::from(p));
}
let base = dirs::config_dir().ok_or(SessionError::NoConfigDir)?;
Ok(base.join(DEFAULT_RELATIVE_PATH))
}
impl SessionFile {
pub fn load(path: &Path) -> Result<Option<Self>, SessionError> {
match fs::metadata(path) {
Ok(_) => {}
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e.into()),
}
crate::credential_file::check_mode_and_owner(path)?;
let bytes = fs::read(path)?;
let session: SessionFile =
serde_json::from_slice(&bytes).map_err(|source| SessionError::Malformed {
path: path.to_path_buf(),
source,
})?;
if session.version != SESSION_VERSION {
return Err(SessionError::UnsupportedVersion {
path: path.to_path_buf(),
found: session.version,
expected: SESSION_VERSION,
});
}
Ok(Some(session))
}
pub fn save(&self, path: &Path) -> Result<(), SessionError> {
let parent = path.parent().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "session path has no parent")
})?;
fs::create_dir_all(parent)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(parent, fs::Permissions::from_mode(0o700));
}
let mut tempfile = NamedTempFile::new_in(parent)?;
let body = serde_json::to_vec_pretty(self).expect("SessionFile serializes");
{
use std::io::Write as _;
tempfile.write_all(&body)?;
tempfile.as_file().sync_all()?;
}
tempfile.persist(path).map_err(|e| e.error)?;
Ok(())
}
}
pub fn delete(path: &Path) -> Result<(), SessionError> {
match fs::remove_file(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn default_path_respects_env_override() {
let p = default_path_with_env(|k| {
assert_eq!(k, SESSION_FILE_ENV);
Some("/tmp/cairn-override.json".into())
})
.unwrap();
assert_eq!(p, PathBuf::from("/tmp/cairn-override.json"));
}
#[test]
fn default_path_without_env_falls_back_to_config_dir() {
let p = default_path_with_env(|_| None).unwrap();
assert!(
p.ends_with(DEFAULT_RELATIVE_PATH),
"fallback path must end with {DEFAULT_RELATIVE_PATH}, got {p:?}"
);
}
}