use std::{
collections::BTreeMap,
fs::{self, File},
io::{Read as _, Write as _},
path::{Path, PathBuf},
};
use serde::{Deserialize, Serialize};
use sha2::{Digest as _, Sha256};
use time::OffsetDateTime;
use uuid::Uuid;
use crate::error::{CliError, Result};
#[cfg(not(unix))]
use std::fs::OpenOptions;
const APP_DIRECTORY: &str = ".silicon-iam";
const CONFIG_FILE: &str = "config.json";
const CREDENTIALS_FILE: &str = "credentials.json";
const UPDATE_FILE: &str = "update.json";
const STORE_LOCK: &str = "credentials.lock";
#[cfg(unix)]
const LOCK_OPEN_RETRY_DELAYS_MS: [u64; 5] = [1, 2, 4, 8, 16];
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Config {
#[serde(default = "enabled")]
pub auto_update: bool,
#[serde(default)]
pub current_profile: Option<String>,
#[serde(default)]
pub profiles: BTreeMap<String, Profile>,
}
impl Default for Config {
fn default() -> Self {
Self {
auto_update: true,
current_profile: None,
profiles: BTreeMap::new(),
}
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct UpdateState {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub checked_version: Option<String>,
#[serde(default, with = "time::serde::rfc3339::option")]
pub checked_at: Option<OffsetDateTime>,
}
#[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 = "BTreeMap::is_empty")]
pub test_orgs: BTreeMap<Uuid, String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct Credentials {
#[serde(default)]
pub sessions: BTreeMap<String, Session>,
#[serde(default)]
pub test_sessions: BTreeMap<String, BTreeMap<Uuid, Session>>,
#[serde(default)]
pub testing_environment_keys: BTreeMap<String, BTreeMap<Uuid, String>>,
}
impl Credentials {
pub fn session(&self, profile: &str, environment_id: Option<Uuid>) -> Option<&Session> {
match environment_id {
Some(id) => self.test_sessions.get(profile)?.get(&id),
None => self.sessions.get(profile),
}
}
pub fn set_session(&mut self, profile: &str, environment_id: Option<Uuid>, session: Session) {
match environment_id {
Some(id) => {
self.test_sessions
.entry(profile.to_owned())
.or_default()
.insert(id, session);
}
None => {
self.sessions.insert(profile.to_owned(), session);
}
}
}
pub fn remove_session(&mut self, profile: &str, environment_id: Option<Uuid>) -> bool {
match environment_id {
Some(id) => self
.test_sessions
.get_mut(profile)
.and_then(|sessions| sessions.remove(&id))
.is_some(),
None => self.sessions.remove(profile).is_some(),
}
}
pub fn testing_environment_key(&self, profile: &str, environment_id: Uuid) -> Option<&str> {
self.testing_environment_keys
.get(profile)?
.get(&environment_id)
.map(String::as_str)
}
pub fn set_testing_environment_key(
&mut self,
profile: &str,
environment_id: Uuid,
key: String,
) {
self.testing_environment_keys
.entry(profile.to_owned())
.or_default()
.insert(environment_id, key);
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SessionActor {
#[default]
Carbon,
Silicon,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PendingLogoutMode {
CurrentSession,
AllSessions,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PendingLogout {
pub mode: PendingLogoutMode,
pub idempotency_key: String,
}
#[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,
#[serde(default)]
pub actor_type: SessionActor,
#[serde(alias = "carbon_id")]
pub actor_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pending_refresh_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pending_logout: Option<PendingLogout>,
}
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> {
StoreDirectory::open()?.read_json(CONFIG_FILE)
}
pub struct LockedStore {
directory: StoreDirectory,
_lock: File,
}
pub fn lock() -> Result<LockedStore> {
let directory = StoreDirectory::open()?;
let lock = directory.lock(STORE_LOCK)?;
Ok(LockedStore {
directory,
_lock: lock,
})
}
impl LockedStore {
pub fn load_config(&self) -> Result<Config> {
self.directory.read_json(CONFIG_FILE)
}
pub fn save_config(&self, config: &Config) -> Result<()> {
self.directory.write_json(CONFIG_FILE, config)
}
fn load_credentials(&self) -> Result<Credentials> {
self.directory.read_json(CREDENTIALS_FILE)
}
fn save_credentials(&self, credentials: &Credentials) -> Result<()> {
self.directory.write_json(CREDENTIALS_FILE, credentials)
}
}
pub fn load_update_state() -> Result<UpdateState> {
StoreDirectory::open()?.read_json(UPDATE_FILE)
}
pub fn save_update_state(state: &UpdateState) -> Result<()> {
let store = lock()?;
let previous: UpdateState = store.directory.read_json(UPDATE_FILE)?;
if previous.checked_at > state.checked_at
&& previous.checked_at <= Some(OffsetDateTime::now_utc())
{
return Ok(());
}
store.directory.write_json(UPDATE_FILE, state)
}
pub fn try_lock_updater_check() -> Result<Option<File>> {
StoreDirectory::open()?.try_lock("updater-check.lock")
}
pub fn load_credentials() -> Result<Credentials> {
StoreDirectory::open()?.read_json(CREDENTIALS_FILE)
}
pub struct LockedSession {
directory: StoreDirectory,
_lock: File,
profile: String,
environment_id: Option<Uuid>,
}
pub fn lock_session(profile: &str, environment_id: Option<Uuid>) -> Result<LockedSession> {
let directory = StoreDirectory::open()?;
let mut digest = Sha256::new();
digest.update(profile.as_bytes());
digest.update([0]);
if let Some(id) = environment_id {
digest.update(id.as_bytes());
}
let name = format!("session-{:x}.lock", digest.finalize());
let lock = directory.lock(&name)?;
Ok(LockedSession {
directory,
_lock: lock,
profile: profile.to_owned(),
environment_id,
})
}
impl LockedSession {
pub fn session(&self) -> Result<Session> {
let credentials: Credentials = self.directory.read_json(CREDENTIALS_FILE)?;
credentials
.session(&self.profile, self.environment_id)
.cloned()
.ok_or(CliError::NotSignedIn)
}
pub fn remember(&self, session: Session) -> Result<()> {
let _lock = self.directory.lock(STORE_LOCK)?;
let mut credentials: Credentials = self.directory.read_json(CREDENTIALS_FILE)?;
credentials.set_session(&self.profile, self.environment_id, session);
self.directory.write_json(CREDENTIALS_FILE, &credentials)
}
pub fn forget(&self) -> Result<bool> {
let _lock = self.directory.lock(STORE_LOCK)?;
let mut credentials: Credentials = self.directory.read_json(CREDENTIALS_FILE)?;
let existed = credentials.remove_session(&self.profile, self.environment_id);
self.directory.write_json(CREDENTIALS_FILE, &credentials)?;
Ok(existed)
}
}
pub fn remember_testing_environment(
profile: &str,
environment_id: Uuid,
key: String,
) -> Result<()> {
let store = lock()?;
let mut credentials = store.load_credentials()?;
credentials.set_testing_environment_key(profile, environment_id, key);
store.save_credentials(&credentials)
}
struct StoreDirectory {
path: PathBuf,
#[cfg(unix)]
file: File,
}
impl StoreDirectory {
fn open() -> Result<Self> {
let path = home()?;
let mut builder = fs::DirBuilder::new();
builder.recursive(true);
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt as _;
builder.mode(0o700);
}
builder
.create(&path)
.map_err(|error| state_error(&path, error))?;
#[cfg(unix)]
{
use rustix::fs::{Mode, OFlags, open};
use std::os::unix::fs::MetadataExt as _;
let file = File::from(
open(
&path,
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)
.map_err(|error| state_error(&path, error))?,
);
let metadata = file.metadata().map_err(|error| state_error(&path, error))?;
if metadata.uid() != rustix::process::geteuid().as_raw() || metadata.mode() & 0o077 != 0
{
return Err(CliError::Config(format!(
"{} must be owned by the current user and private (0700); use chmod 700 on your IAM home",
path.display()
)));
}
Ok(Self { path, file })
}
#[cfg(not(unix))]
{
let metadata =
fs::symlink_metadata(&path).map_err(|error| state_error(&path, error))?;
if metadata.file_type().is_symlink() || !metadata.is_dir() {
return Err(state_error(
&path,
"the IAM home must be a real directory, not a link",
));
}
Ok(Self { path })
}
}
fn open_file(&self, name: &str, create: bool, exclusive: bool) -> std::io::Result<File> {
#[cfg(unix)]
{
use rustix::fs::{Mode, OFlags, openat};
let mut flags = OFlags::NOFOLLOW | OFlags::CLOEXEC | OFlags::NONBLOCK;
flags |= if create {
OFlags::RDWR | OFlags::CREATE
} else {
OFlags::RDONLY
};
if exclusive {
flags |= OFlags::EXCL;
}
Ok(File::from(openat(
&self.file,
name,
flags,
Mode::from_raw_mode(0o600),
)?))
}
#[cfg(not(unix))]
{
let path = self.path.join(name);
match fs::symlink_metadata(&path) {
Ok(metadata) if metadata.file_type().is_symlink() => {
return Err(std::io::Error::other(
"symbolic links are not allowed in IAM state",
));
}
Err(error) if error.kind() != std::io::ErrorKind::NotFound => return Err(error),
_ => {}
}
let mut options = OpenOptions::new();
options
.read(true)
.write(create)
.create(create)
.create_new(exclusive);
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt as _;
options.custom_flags(0x0020_0000);
}
options.open(path)
}
}
fn validate_file(&self, name: &str, file: &File) -> Result<()> {
let path = self.path.join(name);
let metadata = file.metadata().map_err(|error| state_error(&path, error))?;
if !metadata.is_file() {
return Err(state_error(
&path,
"IAM state must be a regular file, not a link or device",
));
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt as _;
if metadata.uid() != rustix::process::geteuid().as_raw()
|| metadata.mode() & 0o022 != 0
|| metadata.nlink() > 1
{
return Err(state_error(
&path,
"IAM state must be owned by the current user, not writable by others, and not hard-linked",
));
}
}
Ok(())
}
fn open_lock_file(&self, name: &str) -> Result<File> {
#[cfg(not(unix))]
{
let file = self.open_file(name, true, false).map_err(|error| {
state_error(&self.path.join(name), format!("cannot open lock: {error}"))
})?;
self.validate_lock_file(name, &file)?;
Ok(file)
}
#[cfg(unix)]
self.open_unix_lock_file(name)
}
#[cfg(unix)]
fn open_unix_lock_file(&self, name: &str) -> Result<File> {
let mut attempts = 0;
loop {
attempts += 1;
match self.open_file(name, true, false) {
Ok(file) => {
self.validate_lock_file(name, &file)?;
return Ok(file);
}
Err(error) => {
if matches!(
error.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::Interrupted
) && let Some(delay) = LOCK_OPEN_RETRY_DELAYS_MS.get(attempts - 1)
{
std::thread::sleep(std::time::Duration::from_millis(*delay));
self.validate_lock_directory()?;
continue;
}
return Err(state_error(
&self.path.join(name),
format!("cannot open lock after {attempts} attempt(s): {error}"),
));
}
}
}
}
#[cfg(unix)]
fn validate_lock_directory(&self) -> Result<()> {
use rustix::fs::{AtFlags, CWD, fstat, statat};
use std::os::unix::fs::MetadataExt as _;
let metadata = self
.file
.metadata()
.map_err(|error| state_error(&self.path, error))?;
if !metadata.is_dir()
|| metadata.nlink() == 0
|| metadata.uid() != rustix::process::geteuid().as_raw()
|| metadata.mode() & 0o077 != 0
{
return Err(state_error(
&self.path,
"cannot retry lock open: IAM home is no longer a live, owned, private directory",
));
}
let pinned = fstat(&self.file).map_err(|error| state_error(&self.path, error))?;
let named = statat(CWD, &self.path, AtFlags::SYMLINK_NOFOLLOW)
.map_err(|error| state_error(&self.path, error))?;
if pinned.st_dev != named.st_dev || pinned.st_ino != named.st_ino {
return Err(state_error(
&self.path,
"cannot retry lock open: IAM home was replaced or redirected",
));
}
Ok(())
}
fn validate_lock_file(&self, name: &str, file: &File) -> Result<()> {
self.validate_file(name, file)?;
#[cfg(unix)]
{
use rustix::fs::{AtFlags, fstat, statat};
let path = self.path.join(name);
let opened = fstat(file).map_err(|error| state_error(&path, error))?;
let named = statat(&self.file, name, AtFlags::SYMLINK_NOFOLLOW).map_err(|error| {
state_error(&path, format!("cannot verify lock identity: {error}"))
})?;
if opened.st_nlink != 1
|| opened.st_dev != named.st_dev
|| opened.st_ino != named.st_ino
{
return Err(state_error(
&path,
"lock file was removed or replaced; refusing to use a different lock identity",
));
}
}
Ok(())
}
fn lock(&self, name: &str) -> Result<File> {
let file = self.open_lock_file(name)?;
file.lock().map_err(|error| {
state_error(
&self.path.join(name),
format!("cannot acquire lock: {error}"),
)
})?;
self.validate_lock_file(name, &file)?;
Ok(file)
}
fn try_lock(&self, name: &str) -> Result<Option<File>> {
let file = self.open_lock_file(name)?;
match file.try_lock() {
Ok(()) => {
self.validate_lock_file(name, &file)?;
Ok(Some(file))
}
Err(std::fs::TryLockError::WouldBlock) => Ok(None),
Err(error) => Err(state_error(&self.path.join(name), error)),
}
}
fn read_json<T: Default + serde::de::DeserializeOwned>(&self, name: &str) -> Result<T> {
let mut file = match self.open_file(name, false, false) {
Ok(file) => file,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(T::default()),
Err(error) => return Err(state_error(&self.path.join(name), error)),
};
self.validate_file(name, &file)?;
let mut bytes = Vec::new();
file.read_to_end(&mut bytes)
.map_err(|error| state_error(&self.path.join(name), error))?;
serde_json::from_slice(&bytes).map_err(|error| state_error(&self.path.join(name), error))
}
fn write_json<T: Serialize>(&self, name: &str, value: &T) -> Result<()> {
match self.open_file(name, false, false) {
Ok(file) => self.validate_file(name, &file)?,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(state_error(&self.path.join(name), error)),
}
let temporary = format!(".{name}.{}.tmp", Uuid::now_v7());
self.write_temporary(name, &temporary, value)
}
fn write_temporary<T: Serialize>(&self, name: &str, temporary: &str, value: &T) -> Result<()> {
let path = self.path.join(name);
let mut encoded =
serde_json::to_vec_pretty(value).map_err(|error| state_error(&path, error))?;
encoded.push(b'\n');
let mut file = self
.open_file(temporary, true, true)
.map_err(|error| state_error(&path, error))?;
let result = (|| {
file.write_all(&encoded)
.map_err(|error| state_error(&path, error))?;
file.sync_all().map_err(|error| state_error(&path, error))?;
drop(file);
#[cfg(unix)]
{
rustix::fs::renameat(&self.file, temporary, &self.file, name)
.map_err(|error| state_error(&path, error))?;
self.file
.sync_all()
.map_err(|error| state_error(&path, error))?;
}
#[cfg(not(unix))]
fs::rename(self.path.join(temporary), &path)
.map_err(|error| state_error(&path, error))?;
Ok(())
})();
if result.is_err() {
#[cfg(unix)]
let _ = rustix::fs::unlinkat(&self.file, temporary, rustix::fs::AtFlags::empty());
#[cfg(not(unix))]
let _ = fs::remove_file(self.path.join(temporary));
}
result
}
}
fn state_error(path: &Path, error: impl std::fmt::Display) -> CliError {
CliError::Config(format!("cannot access {}: {error}", path.display()))
}
const fn enabled() -> bool {
true
}
#[cfg(test)]
mod tests {
use time::{Duration, OffsetDateTime};
use uuid::Uuid;
use super::{Config, Credentials, Profile, Session, SessionActor};
#[test]
fn automatic_updates_default_on_for_new_and_old_configs() {
assert!(Config::default().auto_update);
let decoded = serde_json::from_str::<Config>("{}");
assert!(decoded.is_ok_and(|config| config.auto_update));
}
#[test]
fn old_profiles_load_without_test_environment_defaults() {
let decoded =
serde_json::from_str::<Profile>(r#"{"url":"https://example.test","org":"production"}"#);
assert!(decoded.is_ok_and(|profile| profile.test_orgs.is_empty()));
}
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,
actor_type: SessionActor::Carbon,
actor_id: "founder".to_owned(),
pending_refresh_key: None,
pending_logout: None,
}
}
#[test]
fn old_carbon_sessions_keep_their_identity() {
let decoded = serde_json::from_str::<Credentials>(
r#"{
"sessions": {
"default": {
"access_token":"cat_x",
"refresh_token":"rft_x",
"expires_at":"2026-09-04T00:00:00Z",
"carbon_id":"founder"
}
}
}"#,
);
let Ok(credentials) = decoded else {
panic!("the old credentials-file shape must remain readable");
};
let Some(session) = credentials.session("default", None) else {
panic!("the old Carbon session must remain present");
};
assert_eq!(session.actor_type, SessionActor::Carbon);
assert_eq!(session.actor_id, "founder");
}
#[test]
fn silicon_sessions_round_trip_with_their_actor_kind() {
let mut credentials = Credentials::default();
let mut silicon = session(Duration::minutes(30));
silicon.access_token = "sat_x".to_owned();
silicon.actor_type = SessionActor::Silicon;
silicon.actor_id = "builder:tos".to_owned();
credentials.set_session("default", None, silicon);
let encoded = serde_json::to_vec(&credentials);
let Ok(encoded) = encoded else {
panic!("credentials must serialize");
};
let decoded = serde_json::from_slice::<Credentials>(&encoded);
let Ok(decoded) = decoded else {
panic!("credentials must deserialize");
};
let Some(session) = decoded.session("default", None) else {
panic!("the Silicon session must remain present");
};
assert_eq!(session.actor_type, SessionActor::Silicon);
assert_eq!(session.actor_id, "builder:tos");
assert_eq!(session.access_token, "sat_x");
}
#[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());
}
#[test]
fn production_and_each_test_environment_have_independent_sessions() {
let first_id = Uuid::from_u128(1);
let second_id = Uuid::from_u128(2);
let mut credentials = Credentials::default();
credentials.set_session("default", None, session(Duration::minutes(5)));
let mut first = session(Duration::minutes(6));
first.actor_id = "first-test".to_owned();
credentials.set_session("default", Some(first_id), first);
let mut second = session(Duration::minutes(7));
second.actor_id = "second-test".to_owned();
credentials.set_session("default", Some(second_id), second);
assert_eq!(
credentials
.session("default", None)
.map(|value| value.actor_id.as_str()),
Some("founder")
);
assert_eq!(
credentials
.session("default", Some(first_id))
.map(|value| value.actor_id.as_str()),
Some("first-test")
);
assert_eq!(
credentials
.session("default", Some(second_id))
.map(|value| value.actor_id.as_str()),
Some("second-test")
);
}
#[test]
fn environment_keys_are_looked_up_by_public_id_and_profile() {
let id = Uuid::from_u128(7);
let mut credentials = Credentials::default();
credentials.set_testing_environment_key("work", id, "a".repeat(32));
assert_eq!(
credentials.testing_environment_key("work", id),
Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
);
assert_eq!(credentials.testing_environment_key("other", id), None);
}
}