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}