use std::{
collections::BTreeMap,
fs,
path::{Path, PathBuf},
};
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use crate::error::{CliError, Result};
const APP_DIRECTORY: &str = ".silicon-iam";
const CONFIG_FILE: &str = "config.json";
const CREDENTIALS_FILE: &str = "credentials.json";
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub current_profile: Option<String>,
#[serde(default)]
pub profiles: BTreeMap<String, Profile>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct Profile {
pub url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub org: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub environment: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct Credentials {
#[serde(default)]
pub sessions: BTreeMap<String, Session>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Session {
pub access_token: String,
pub refresh_token: String,
#[serde(with = "time::serde::rfc3339")]
pub expires_at: OffsetDateTime,
pub carbon_id: String,
}
impl Session {
#[must_use]
pub fn needs_refresh(&self) -> bool {
self.expires_at <= OffsetDateTime::now_utc() + time::Duration::minutes(1)
}
}
pub fn home() -> Result<PathBuf> {
let home = std::env::var_os("SILICON_IAM_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(APP_DIRECTORY)))
.ok_or_else(|| {
CliError::Config(
"cannot locate a home directory; set SILICON_IAM_HOME to choose one".to_owned(),
)
})?;
Ok(home)
}
pub fn load_config() -> Result<Config> {
read_json(&home()?.join(CONFIG_FILE))
}
pub fn save_config(config: &Config) -> Result<()> {
write_json(&home()?.join(CONFIG_FILE), config, false)
}
pub fn load_credentials() -> Result<Credentials> {
read_json(&home()?.join(CREDENTIALS_FILE))
}
pub fn save_credentials(credentials: &Credentials) -> Result<()> {
write_json(&home()?.join(CREDENTIALS_FILE), credentials, true)
}
fn read_json<T: Default + serde::de::DeserializeOwned>(path: &Path) -> Result<T> {
match fs::read(path) {
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|error| {
CliError::Config(format!("{} is not readable: {error}", path.display()))
}),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(T::default()),
Err(error) => Err(CliError::Config(format!(
"cannot read {}: {error}",
path.display()
))),
}
}
fn write_json<T: Serialize>(path: &Path, value: &T, private: bool) -> Result<()> {
let Some(directory) = path.parent() else {
return Err(CliError::Config(format!(
"{} has no parent directory",
path.display()
)));
};
fs::create_dir_all(directory).map_err(|error| {
CliError::Config(format!("cannot create {}: {error}", directory.display()))
})?;
let mut encoded = serde_json::to_vec_pretty(value)
.map_err(|error| CliError::Config(format!("cannot encode {}: {error}", path.display())))?;
encoded.push(b'\n');
fs::write(path, &encoded)
.map_err(|error| CliError::Config(format!("cannot write {}: {error}", path.display())))?;
if private {
restrict(path)?;
}
Ok(())
}
#[cfg(unix)]
fn restrict(path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt as _;
fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(|error| {
CliError::Config(format!(
"cannot restrict permissions on {}: {error}",
path.display()
))
})
}
#[cfg(not(unix))]
fn restrict(_path: &Path) -> Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use time::{Duration, OffsetDateTime};
use super::Session;
fn session(expires_in: Duration) -> Session {
Session {
access_token: "cat_x".to_owned(),
refresh_token: "rft_x".to_owned(),
expires_at: OffsetDateTime::now_utc() + expires_in,
carbon_id: "founder".to_owned(),
}
}
#[test]
fn a_session_is_renewed_before_it_actually_expires() {
assert!(!session(Duration::minutes(30)).needs_refresh());
assert!(session(Duration::seconds(30)).needs_refresh());
assert!(session(Duration::seconds(-1)).needs_refresh());
}
}