use std::collections::BTreeMap;
use std::env;
use std::io::Write as _;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt as _;
use std::path::{Path, PathBuf};
use horus_gateway::client::{Endpoint, token_from_env};
use horus_gateway::{Error, Result};
use serde::{Deserialize, Serialize};
const MAX_STORE_BYTES: usize = 64 * 1024;
const MAX_ACCOUNTS: usize = 64;
const MAX_TOKEN_BYTES: usize = 512;
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct TokenStoreRecord {
selected_endpoint: Option<String>,
tokens: BTreeMap<String, String>,
}
#[derive(Clone, Debug)]
pub struct GatewayAccounts {
path: PathBuf,
record: TokenStoreRecord,
}
impl GatewayAccounts {
pub fn load() -> Result<Self> {
Self::load_from(token_path()?)
}
pub fn endpoints(&self) -> impl ExactSizeIterator<Item = &str> {
self.record.tokens.keys().map(String::as_str)
}
pub fn selected(&self) -> Option<&str> {
self.record.selected_endpoint.as_deref()
}
pub fn token(&self, endpoint: &Endpoint) -> Option<&str> {
self.record
.tokens
.get(&endpoint.to_string())
.map(String::as_str)
}
pub fn select(&mut self, endpoint: &str) -> Result<()> {
if !self.record.tokens.contains_key(endpoint) {
return Err(Error::Config(format!(
"gateway endpoint `{endpoint}` is not saved"
)));
}
self.record.selected_endpoint = Some(endpoint.into());
Ok(())
}
pub fn add(&mut self, endpoint: &Endpoint, token: String) -> Result<()> {
validate_token(&token)?;
let endpoint = endpoint.to_string();
if !self.record.tokens.contains_key(&endpoint) && self.record.tokens.len() >= MAX_ACCOUNTS {
return Err(Error::Config(
"gateway token file has too many endpoints".into(),
));
}
self.record.tokens.insert(endpoint.clone(), token);
self.record.selected_endpoint = Some(endpoint);
Ok(())
}
pub fn forget(&mut self, endpoint: &str) {
self.record.tokens.remove(endpoint);
if self.selected() == Some(endpoint) {
self.record.selected_endpoint = None;
}
}
pub fn prepare(&self) -> Result<()> {
let parent = parent(&self.path)?;
std::fs::create_dir_all(parent)?;
let file = tempfile::NamedTempFile::new_in(parent)?;
secure(&file)?;
Ok(())
}
pub fn save(&self) -> Result<()> {
validate_record(&self.record)?;
let contents = serde_json::to_vec(&self.record)?;
if contents.len() > MAX_STORE_BYTES {
return Err(Error::Config("gateway token file is too large".into()));
}
let parent = parent(&self.path)?;
std::fs::create_dir_all(parent)?;
let mut file = tempfile::NamedTempFile::new_in(parent)?;
secure(&file)?;
file.write_all(&contents)?;
file.as_file().sync_all()?;
file.persist(&self.path).map_err(|error| error.error)?;
Ok(())
}
fn load_from(path: PathBuf) -> Result<Self> {
let metadata = match std::fs::metadata(&path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(Self {
path,
record: TokenStoreRecord::default(),
});
}
Err(error) => return Err(error.into()),
};
if !metadata.is_file() {
return Err(Error::Config("gateway token path is not a file".into()));
}
#[cfg(unix)]
if metadata.permissions().mode() & 0o077 != 0 {
return Err(Error::Config(
"gateway token file must be readable only by its owner".into(),
));
}
if metadata.len() > MAX_STORE_BYTES as u64 {
return Err(Error::Config("gateway token file is too large".into()));
}
let record = serde_json::from_slice(&std::fs::read(&path)?).map_err(|_| {
Error::Config(format!(
"gateway token file has an unsupported format; delete {} and pair again",
path.display()
))
})?;
validate_record(&record)?;
Ok(Self { path, record })
}
}
pub fn configured_endpoint() -> Result<Endpoint> {
if environment_override_message().is_some() {
return Endpoint::from_env();
}
GatewayAccounts::load()?
.selected()
.map_or_else(Endpoint::from_env, str::parse)
}
pub fn configured_token(endpoint: &Endpoint) -> Result<Option<String>> {
if env::var_os("HORUS_GATEWAY_TOKEN").is_some() {
return token_from_env().map(Some);
}
Ok(GatewayAccounts::load()?.token(endpoint).map(str::to_owned))
}
pub fn environment_override_message() -> Option<&'static str> {
match (
env::var_os("HORUS_GATEWAY_ENDPOINT").is_some(),
env::var_os("HORUS_GATEWAY_TOKEN").is_some(),
) {
(true, true) => Some(
"Gateway selection is controlled by HORUS_GATEWAY_ENDPOINT and HORUS_GATEWAY_TOKEN. Unset them to manage saved gateways.",
),
(true, false) => Some(
"Gateway selection is controlled by HORUS_GATEWAY_ENDPOINT. Unset it to manage saved gateways.",
),
(false, true) => Some(
"Gateway selection is controlled by HORUS_GATEWAY_TOKEN. Unset it to manage saved gateways.",
),
(false, false) => None,
}
}
fn validate_record(record: &TokenStoreRecord) -> Result<()> {
if record.tokens.len() > MAX_ACCOUNTS {
return Err(Error::Config(
"gateway token file has too many endpoints".into(),
));
}
for (endpoint, token) in &record.tokens {
let parsed = endpoint.parse::<Endpoint>()?;
if parsed.to_string() != *endpoint {
return Err(Error::Config(
"saved gateway endpoint is not canonical".into(),
));
}
validate_token(token)?;
}
if record
.selected_endpoint
.as_ref()
.is_some_and(|endpoint| !record.tokens.contains_key(endpoint))
{
return Err(Error::Config(
"selected gateway endpoint is not saved".into(),
));
}
Ok(())
}
fn validate_token(token: &str) -> Result<()> {
if token.is_empty() || token.len() > MAX_TOKEN_BYTES || token.trim() != token {
return Err(Error::Config("saved gateway token is invalid".into()));
}
Ok(())
}
fn token_path() -> Result<PathBuf> {
if let Some(path) = env::var_os("HORUS_GATEWAY_TOKEN_FILE") {
return Ok(path.into());
}
env::var_os("HOME")
.or_else(|| env::var_os("USERPROFILE"))
.map(PathBuf::from)
.map(|path| path.join(".horus").join("gateway-tokens.json"))
.ok_or_else(|| {
Error::Config("cannot determine token path; set HORUS_GATEWAY_TOKEN_FILE".into())
})
}
fn parent(path: &Path) -> Result<&Path> {
path.parent()
.ok_or_else(|| Error::Config("token path has no parent".into()))
}
fn secure(file: &tempfile::NamedTempFile) -> Result<()> {
#[cfg(unix)]
file.as_file()
.set_permissions(std::fs::Permissions::from_mode(0o600))?;
Ok(())
}
#[cfg(test)]
mod tests {
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt as _;
use super::*;
fn accounts(path: &Path) -> GatewayAccounts {
GatewayAccounts::load_from(path.to_path_buf()).expect("load accounts")
}
fn endpoint(value: &str) -> Endpoint {
value.parse().expect("valid endpoint")
}
#[test]
fn selecting_a_saved_account_updates_the_selected_endpoint() {
let directory = tempfile::tempdir().expect("token directory");
let mut accounts = accounts(&directory.path().join("tokens.json"));
accounts
.add(&endpoint("tcp://127.0.0.1:8741"), "local-token".into())
.expect("local account");
accounts
.add(
&endpoint("tls://gateway.example:443"),
"remote-token".into(),
)
.expect("remote account");
accounts
.select("tcp://127.0.0.1:8741")
.expect("select account");
assert_eq!(accounts.selected(), Some("tcp://127.0.0.1:8741"));
}
#[test]
fn forgetting_the_selected_account_clears_selection() {
let directory = tempfile::tempdir().expect("token directory");
let mut accounts = accounts(&directory.path().join("tokens.json"));
accounts
.add(&endpoint("tcp://127.0.0.1:8741"), "local-token".into())
.expect("local account");
accounts.forget("tcp://127.0.0.1:8741");
assert_eq!(accounts.selected(), None);
}
#[test]
fn account_record_round_trips_selection_and_tokens() {
let directory = tempfile::tempdir().expect("token directory");
let path = directory.path().join("tokens.json");
let mut accounts = accounts(&path);
let endpoint = endpoint("tls://gateway.example:443");
accounts
.add(&endpoint, "remote-token".into())
.expect("remote account");
accounts.save().expect("save accounts");
let loaded = GatewayAccounts::load_from(path).expect("reload accounts");
assert_eq!(
(loaded.selected(), loaded.token(&endpoint)),
(Some("tls://gateway.example:443"), Some("remote-token"))
);
}
#[test]
fn old_token_maps_fail_with_repair_guidance() {
let directory = tempfile::tempdir().expect("token directory");
let path = directory.path().join("tokens.json");
std::fs::write(&path, r#"{"tcp://127.0.0.1:8741":"token"}"#).expect("legacy token map");
#[cfg(unix)]
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
.expect("private permissions");
let error = GatewayAccounts::load_from(path).expect_err("old format must fail");
assert!(error.to_string().contains("delete"));
assert!(error.to_string().contains("pair again"));
}
}