Skip to main content

horus_cli/
gateway_accounts.rs

1use std::collections::BTreeMap;
2use std::env;
3use std::io::Write as _;
4#[cfg(unix)]
5use std::os::unix::fs::PermissionsExt as _;
6use std::path::{Path, PathBuf};
7
8use horus_gateway::client::{Endpoint, token_from_env};
9use horus_gateway::{Error, Result};
10use serde::{Deserialize, Serialize};
11
12const MAX_STORE_BYTES: usize = 64 * 1024;
13const MAX_ACCOUNTS: usize = 64;
14const MAX_TOKEN_BYTES: usize = 512;
15
16#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
17#[serde(deny_unknown_fields)]
18struct TokenStoreRecord {
19    selected_endpoint: Option<String>,
20    tokens: BTreeMap<String, String>,
21}
22
23#[derive(Clone, Debug)]
24pub struct GatewayAccounts {
25    path: PathBuf,
26    record: TokenStoreRecord,
27}
28
29impl GatewayAccounts {
30    pub fn load() -> Result<Self> {
31        Self::load_from(token_path()?)
32    }
33
34    pub fn endpoints(&self) -> impl ExactSizeIterator<Item = &str> {
35        self.record.tokens.keys().map(String::as_str)
36    }
37
38    pub fn selected(&self) -> Option<&str> {
39        self.record.selected_endpoint.as_deref()
40    }
41
42    pub fn token(&self, endpoint: &Endpoint) -> Option<&str> {
43        self.record
44            .tokens
45            .get(&endpoint.to_string())
46            .map(String::as_str)
47    }
48
49    pub fn select(&mut self, endpoint: &str) -> Result<()> {
50        if !self.record.tokens.contains_key(endpoint) {
51            return Err(Error::Config(format!(
52                "gateway endpoint `{endpoint}` is not saved"
53            )));
54        }
55        self.record.selected_endpoint = Some(endpoint.into());
56        Ok(())
57    }
58
59    pub fn add(&mut self, endpoint: &Endpoint, token: String) -> Result<()> {
60        validate_token(&token)?;
61        let endpoint = endpoint.to_string();
62        if !self.record.tokens.contains_key(&endpoint) && self.record.tokens.len() >= MAX_ACCOUNTS {
63            return Err(Error::Config(
64                "gateway token file has too many endpoints".into(),
65            ));
66        }
67        self.record.tokens.insert(endpoint.clone(), token);
68        self.record.selected_endpoint = Some(endpoint);
69        Ok(())
70    }
71
72    pub fn forget(&mut self, endpoint: &str) {
73        self.record.tokens.remove(endpoint);
74        if self.selected() == Some(endpoint) {
75            self.record.selected_endpoint = None;
76        }
77    }
78
79    pub fn prepare(&self) -> Result<()> {
80        let parent = parent(&self.path)?;
81        std::fs::create_dir_all(parent)?;
82        let file = tempfile::NamedTempFile::new_in(parent)?;
83        secure(&file)?;
84        Ok(())
85    }
86
87    pub fn save(&self) -> Result<()> {
88        validate_record(&self.record)?;
89        let contents = serde_json::to_vec(&self.record)?;
90        if contents.len() > MAX_STORE_BYTES {
91            return Err(Error::Config("gateway token file is too large".into()));
92        }
93        let parent = parent(&self.path)?;
94        std::fs::create_dir_all(parent)?;
95        let mut file = tempfile::NamedTempFile::new_in(parent)?;
96        secure(&file)?;
97        file.write_all(&contents)?;
98        file.as_file().sync_all()?;
99        file.persist(&self.path).map_err(|error| error.error)?;
100        Ok(())
101    }
102
103    fn load_from(path: PathBuf) -> Result<Self> {
104        let metadata = match std::fs::metadata(&path) {
105            Ok(metadata) => metadata,
106            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
107                return Ok(Self {
108                    path,
109                    record: TokenStoreRecord::default(),
110                });
111            }
112            Err(error) => return Err(error.into()),
113        };
114        if !metadata.is_file() {
115            return Err(Error::Config("gateway token path is not a file".into()));
116        }
117        #[cfg(unix)]
118        if metadata.permissions().mode() & 0o077 != 0 {
119            return Err(Error::Config(
120                "gateway token file must be readable only by its owner".into(),
121            ));
122        }
123        if metadata.len() > MAX_STORE_BYTES as u64 {
124            return Err(Error::Config("gateway token file is too large".into()));
125        }
126        let record = serde_json::from_slice(&std::fs::read(&path)?).map_err(|_| {
127            Error::Config(format!(
128                "gateway token file has an unsupported format; delete {} and pair again",
129                path.display()
130            ))
131        })?;
132        validate_record(&record)?;
133        Ok(Self { path, record })
134    }
135}
136
137pub fn configured_endpoint() -> Result<Endpoint> {
138    if environment_override_message().is_some() {
139        return Endpoint::from_env();
140    }
141    GatewayAccounts::load()?
142        .selected()
143        .map_or_else(Endpoint::from_env, str::parse)
144}
145
146pub fn configured_token(endpoint: &Endpoint) -> Result<Option<String>> {
147    if env::var_os("HORUS_GATEWAY_TOKEN").is_some() {
148        return token_from_env().map(Some);
149    }
150    Ok(GatewayAccounts::load()?.token(endpoint).map(str::to_owned))
151}
152
153pub fn environment_override_message() -> Option<&'static str> {
154    match (
155        env::var_os("HORUS_GATEWAY_ENDPOINT").is_some(),
156        env::var_os("HORUS_GATEWAY_TOKEN").is_some(),
157    ) {
158        (true, true) => Some(
159            "Gateway selection is controlled by HORUS_GATEWAY_ENDPOINT and HORUS_GATEWAY_TOKEN. Unset them to manage saved gateways.",
160        ),
161        (true, false) => Some(
162            "Gateway selection is controlled by HORUS_GATEWAY_ENDPOINT. Unset it to manage saved gateways.",
163        ),
164        (false, true) => Some(
165            "Gateway selection is controlled by HORUS_GATEWAY_TOKEN. Unset it to manage saved gateways.",
166        ),
167        (false, false) => None,
168    }
169}
170
171fn validate_record(record: &TokenStoreRecord) -> Result<()> {
172    if record.tokens.len() > MAX_ACCOUNTS {
173        return Err(Error::Config(
174            "gateway token file has too many endpoints".into(),
175        ));
176    }
177    for (endpoint, token) in &record.tokens {
178        let parsed = endpoint.parse::<Endpoint>()?;
179        if parsed.to_string() != *endpoint {
180            return Err(Error::Config(
181                "saved gateway endpoint is not canonical".into(),
182            ));
183        }
184        validate_token(token)?;
185    }
186    if record
187        .selected_endpoint
188        .as_ref()
189        .is_some_and(|endpoint| !record.tokens.contains_key(endpoint))
190    {
191        return Err(Error::Config(
192            "selected gateway endpoint is not saved".into(),
193        ));
194    }
195    Ok(())
196}
197
198fn validate_token(token: &str) -> Result<()> {
199    if token.is_empty() || token.len() > MAX_TOKEN_BYTES || token.trim() != token {
200        return Err(Error::Config("saved gateway token is invalid".into()));
201    }
202    Ok(())
203}
204
205fn token_path() -> Result<PathBuf> {
206    if let Some(path) = env::var_os("HORUS_GATEWAY_TOKEN_FILE") {
207        return Ok(path.into());
208    }
209    env::var_os("HOME")
210        .or_else(|| env::var_os("USERPROFILE"))
211        .map(PathBuf::from)
212        .map(|path| path.join(".horus").join("gateway-tokens.json"))
213        .ok_or_else(|| {
214            Error::Config("cannot determine token path; set HORUS_GATEWAY_TOKEN_FILE".into())
215        })
216}
217
218fn parent(path: &Path) -> Result<&Path> {
219    path.parent()
220        .ok_or_else(|| Error::Config("token path has no parent".into()))
221}
222
223fn secure(file: &tempfile::NamedTempFile) -> Result<()> {
224    #[cfg(unix)]
225    file.as_file()
226        .set_permissions(std::fs::Permissions::from_mode(0o600))?;
227    Ok(())
228}
229
230#[cfg(test)]
231mod tests {
232    #[cfg(unix)]
233    use std::os::unix::fs::PermissionsExt as _;
234
235    use super::*;
236
237    fn accounts(path: &Path) -> GatewayAccounts {
238        GatewayAccounts::load_from(path.to_path_buf()).expect("load accounts")
239    }
240
241    fn endpoint(value: &str) -> Endpoint {
242        value.parse().expect("valid endpoint")
243    }
244
245    #[test]
246    fn selecting_a_saved_account_updates_the_selected_endpoint() {
247        let directory = tempfile::tempdir().expect("token directory");
248        let mut accounts = accounts(&directory.path().join("tokens.json"));
249        accounts
250            .add(&endpoint("tcp://127.0.0.1:8741"), "local-token".into())
251            .expect("local account");
252        accounts
253            .add(
254                &endpoint("tls://gateway.example:443"),
255                "remote-token".into(),
256            )
257            .expect("remote account");
258
259        accounts
260            .select("tcp://127.0.0.1:8741")
261            .expect("select account");
262
263        assert_eq!(accounts.selected(), Some("tcp://127.0.0.1:8741"));
264    }
265
266    #[test]
267    fn forgetting_the_selected_account_clears_selection() {
268        let directory = tempfile::tempdir().expect("token directory");
269        let mut accounts = accounts(&directory.path().join("tokens.json"));
270        accounts
271            .add(&endpoint("tcp://127.0.0.1:8741"), "local-token".into())
272            .expect("local account");
273
274        accounts.forget("tcp://127.0.0.1:8741");
275
276        assert_eq!(accounts.selected(), None);
277    }
278
279    #[test]
280    fn account_record_round_trips_selection_and_tokens() {
281        let directory = tempfile::tempdir().expect("token directory");
282        let path = directory.path().join("tokens.json");
283        let mut accounts = accounts(&path);
284        let endpoint = endpoint("tls://gateway.example:443");
285        accounts
286            .add(&endpoint, "remote-token".into())
287            .expect("remote account");
288        accounts.save().expect("save accounts");
289
290        let loaded = GatewayAccounts::load_from(path).expect("reload accounts");
291
292        assert_eq!(
293            (loaded.selected(), loaded.token(&endpoint)),
294            (Some("tls://gateway.example:443"), Some("remote-token"))
295        );
296    }
297
298    #[test]
299    fn old_token_maps_fail_with_repair_guidance() {
300        let directory = tempfile::tempdir().expect("token directory");
301        let path = directory.path().join("tokens.json");
302        std::fs::write(&path, r#"{"tcp://127.0.0.1:8741":"token"}"#).expect("legacy token map");
303        #[cfg(unix)]
304        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
305            .expect("private permissions");
306
307        let error = GatewayAccounts::load_from(path).expect_err("old format must fail");
308
309        assert!(error.to_string().contains("delete"));
310        assert!(error.to_string().contains("pair again"));
311    }
312}