Skip to main content

htb_cli/
config.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Serialize, Deserialize)]
7pub struct HtbConfig {
8    #[serde(default = "default_output")]
9    pub output: String,
10
11    #[serde(default)]
12    pub vpn_server: Option<u32>,
13
14    #[serde(default)]
15    pub no_color: bool,
16
17    #[serde(default)]
18    pub cache: CacheConfig,
19
20    #[serde(default)]
21    pub ctf_event: Option<u64>,
22}
23
24#[derive(Debug, Serialize, Deserialize)]
25pub struct CacheConfig {
26    #[serde(default = "default_cache_enabled")]
27    pub enabled: bool,
28}
29
30fn default_cache_enabled() -> bool {
31    true
32}
33
34impl Default for CacheConfig {
35    fn default() -> Self {
36        Self {
37            enabled: default_cache_enabled(),
38        }
39    }
40}
41
42fn default_output() -> String {
43    "table".into()
44}
45
46impl Default for HtbConfig {
47    fn default() -> Self {
48        Self {
49            output: default_output(),
50            vpn_server: None,
51            no_color: false,
52            cache: CacheConfig::default(),
53            ctf_event: None,
54        }
55    }
56}
57
58impl HtbConfig {
59    pub fn load(path: Option<&Path>) -> anyhow::Result<Self> {
60        let config_path = match path {
61            Some(p) => p.to_path_buf(),
62            None => config_dir()?.join("config.toml"),
63        };
64
65        if config_path.exists() {
66            let contents = fs::read_to_string(&config_path)?;
67            Ok(toml::from_str(&contents)?)
68        } else {
69            Ok(Self::default())
70        }
71    }
72}
73
74pub fn config_dir() -> Result<PathBuf, crate::error::HtbError> {
75    dirs::home_dir()
76        .ok_or_else(|| crate::error::HtbError::Config("could not determine home directory".into()))
77        .map(|d| d.join(".htb-cli"))
78}
79
80pub fn cache_dir() -> PathBuf {
81    config_dir()
82        .map(|d| d.join("cache"))
83        .unwrap_or_else(|_| std::env::temp_dir().join("htb-cli-cache"))
84}
85
86pub fn token_path() -> Result<PathBuf, crate::error::HtbError> {
87    Ok(config_dir()?.join(".token"))
88}
89
90pub fn read_token() -> Result<String, crate::error::HtbError> {
91    let path = token_path()?;
92    if !path.exists() {
93        return Err(crate::error::HtbError::NotAuthenticated);
94    }
95    let token = fs::read_to_string(&path)?.trim().to_string();
96    if token.is_empty() {
97        return Err(crate::error::HtbError::NotAuthenticated);
98    }
99    Ok(token)
100}
101
102pub fn save_token(token: &str) -> anyhow::Result<()> {
103    let dir = config_dir()?;
104    fs::create_dir_all(&dir)?;
105
106    let path = token_path()?;
107
108    #[cfg(unix)]
109    {
110        use std::io::Write;
111        use std::os::unix::fs::OpenOptionsExt;
112        let mut f = fs::OpenOptions::new()
113            .write(true)
114            .create(true)
115            .truncate(true)
116            .mode(0o600)
117            .open(&path)?;
118        f.write_all(token.as_bytes())?;
119    }
120
121    #[cfg(not(unix))]
122    {
123        fs::write(&path, token)?;
124    }
125
126    Ok(())
127}
128
129pub fn remove_token() -> anyhow::Result<()> {
130    let path = token_path()?;
131    if path.exists() {
132        fs::remove_file(&path)?;
133    }
134    Ok(())
135}
136
137pub fn ctf_token_path() -> Result<PathBuf, crate::error::HtbError> {
138    Ok(config_dir()?.join(".ctf-token"))
139}
140
141pub fn read_ctf_token() -> Result<String, crate::error::HtbError> {
142    let path = ctf_token_path()?;
143    if !path.exists() {
144        return Err(crate::error::HtbError::NotAuthenticated);
145    }
146    let token = fs::read_to_string(&path)?.trim().to_string();
147    if token.is_empty() {
148        return Err(crate::error::HtbError::NotAuthenticated);
149    }
150    Ok(token)
151}
152
153pub fn save_ctf_token(token: &str) -> anyhow::Result<()> {
154    let dir = config_dir()?;
155    fs::create_dir_all(&dir)?;
156
157    let path = ctf_token_path()?;
158
159    #[cfg(unix)]
160    {
161        use std::io::Write;
162        use std::os::unix::fs::OpenOptionsExt;
163        let mut f = fs::OpenOptions::new()
164            .write(true)
165            .create(true)
166            .truncate(true)
167            .mode(0o600)
168            .open(&path)?;
169        f.write_all(token.as_bytes())?;
170    }
171
172    #[cfg(not(unix))]
173    {
174        fs::write(&path, token)?;
175    }
176
177    Ok(())
178}
179
180pub fn remove_ctf_token() -> anyhow::Result<()> {
181    let path = ctf_token_path()?;
182    if path.exists() {
183        fs::remove_file(&path)?;
184    }
185    Ok(())
186}
187
188pub fn read_ctf_event() -> Option<u64> {
189    let path = config_dir().ok()?.join("config.toml");
190    let contents = fs::read_to_string(path).ok()?;
191    let config: HtbConfig = toml::from_str(&contents).ok()?;
192    config.ctf_event
193}
194
195pub fn save_ctf_event(event_id: Option<u64>) -> anyhow::Result<()> {
196    let dir = config_dir()?;
197    fs::create_dir_all(&dir)?;
198    let path = dir.join("config.toml");
199
200    let mut config: HtbConfig = if path.exists() {
201        let contents = fs::read_to_string(&path)?;
202        toml::from_str(&contents)?
203    } else {
204        HtbConfig::default()
205    };
206
207    config.ctf_event = event_id;
208    fs::write(&path, toml::to_string_pretty(&config)?)?;
209    Ok(())
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn default_config() {
218        let config = HtbConfig::default();
219        assert_eq!(config.output, "table");
220        assert_eq!(config.vpn_server, None);
221        assert!(!config.no_color);
222    }
223
224    #[test]
225    fn toml_round_trip() {
226        let config = HtbConfig {
227            output: "json".into(),
228            vpn_server: Some(1),
229            no_color: true,
230            cache: CacheConfig::default(),
231            ctf_event: Some(1434),
232        };
233        let serialized = toml::to_string(&config).unwrap();
234        let deserialized: HtbConfig = toml::from_str(&serialized).unwrap();
235        assert_eq!(deserialized.output, "json");
236        assert_eq!(deserialized.vpn_server, Some(1));
237        assert!(deserialized.no_color);
238        assert_eq!(deserialized.ctf_event, Some(1434));
239    }
240
241    #[test]
242    fn partial_toml_uses_defaults() {
243        let input = r#"output = "json""#;
244        let config: HtbConfig = toml::from_str(input).unwrap();
245        assert_eq!(config.output, "json");
246        assert_eq!(config.vpn_server, None);
247        assert!(!config.no_color);
248    }
249
250    #[test]
251    fn empty_toml_uses_defaults() {
252        let config: HtbConfig = toml::from_str("").unwrap();
253        assert_eq!(config.output, "table");
254    }
255
256    #[test]
257    fn token_round_trip() {
258        let dir = std::env::temp_dir().join("htb-cli-test-token");
259        let _ = std::fs::create_dir_all(&dir);
260        let path = dir.join(".token");
261
262        std::fs::write(&path, "test-token-123").unwrap();
263        let read_back = std::fs::read_to_string(&path).unwrap();
264        assert_eq!(read_back.trim(), "test-token-123");
265
266        let _ = std::fs::remove_dir_all(&dir);
267    }
268
269    #[test]
270    fn ctf_token_path_ends_with_ctf_token() {
271        let path = ctf_token_path().unwrap();
272        assert!(path.ends_with(".ctf-token"));
273    }
274
275    #[test]
276    fn ctf_token_path_differs_from_labs_token() {
277        let labs = token_path().unwrap();
278        let ctf = ctf_token_path().unwrap();
279        assert_ne!(labs, ctf);
280    }
281}