1use std::path::{Path, PathBuf};
13
14use anyhow::{Context, Result, bail, ensure};
15use serde::Deserialize;
16
17use crate::origin::Alias;
18
19#[derive(Debug, Default, Deserialize)]
22#[serde(deny_unknown_fields)]
23pub struct Server {
24 pub port: Option<u16>,
25 pub suffix: Option<String>,
26 pub author: Option<String>,
27 pub theme: Option<String>,
33 pub scheme: Option<String>,
38}
39
40#[derive(Debug, Deserialize)]
41#[serde(deny_unknown_fields)]
42struct AliasEntry {
43 name: String,
44 host: String,
45 #[serde(default)]
50 base: Option<String>,
51}
52
53#[derive(Debug, Default, Deserialize)]
54#[serde(deny_unknown_fields)]
55struct Document {
56 #[serde(default)]
57 server: Server,
58 #[serde(default, rename = "alias")]
61 aliases: Vec<AliasEntry>,
62}
63
64#[derive(Debug)]
65pub struct Config {
66 pub server: Server,
67 pub aliases: Vec<Alias>,
68}
69
70pub fn parse(text: &str) -> Result<Config> {
74 let doc: Document = toml::from_str(text).context("reading the configuration")?;
75
76 if let Some(scheme) = doc.server.scheme.as_deref() {
77 ensure!(
78 scheme == "http",
79 "scheme = {scheme:?} is not supported yet; only \"http\" is. https needs a CA constrained to the suffix, which is designed but not built"
80 );
81 }
82
83 let mut aliases = Vec::with_capacity(doc.aliases.len());
84 for entry in &doc.aliases {
85 aliases.push(Alias::new(&entry.name, &entry.host, entry.base.as_deref())?);
86 }
87 Ok(Config {
88 server: doc.server,
89 aliases,
90 })
91}
92
93pub fn load(path: &Path) -> Result<Config> {
95 let text =
96 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
97 parse(&text).with_context(|| format!("in {}", path.display()))
98}
99
100pub fn default_path() -> Option<PathBuf> {
106 let base = std::env::var_os("XDG_CONFIG_HOME")
107 .or_else(|| std::env::var_os("APPDATA"))
108 .or_else(|| std::env::var_os("LOCALAPPDATA"))
109 .map(PathBuf::from)
110 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))?;
111 Some(base.join("ssh-browser").join("config.toml"))
112}
113
114pub const DEFAULT_PORT: u16 = 7391;
115pub const DEFAULT_SUFFIX: &str = "ssh-browser";
116
117#[derive(Debug, Default)]
120pub struct Overrides {
121 pub port: Option<u16>,
122 pub suffix: Option<String>,
123 pub author: Option<String>,
124 pub aliases: Vec<Alias>,
125}
126
127#[derive(Debug)]
129pub struct Resolved {
130 pub port: u16,
131 pub suffix: String,
132 pub author: String,
133 pub aliases: Vec<Alias>,
134}
135
136pub fn merge(cli: Overrides, file: Config, default_author: String) -> Result<Resolved> {
146 let mut aliases = file.aliases;
147 aliases.extend(cli.aliases);
148 ensure_distinct(&aliases)?;
149
150 Ok(Resolved {
151 port: cli.port.or(file.server.port).unwrap_or(DEFAULT_PORT),
152 suffix: cli
153 .suffix
154 .or(file.server.suffix)
155 .unwrap_or_else(|| DEFAULT_SUFFIX.to_string()),
156 author: cli.author.or(file.server.author).unwrap_or(default_author),
157 aliases,
158 })
159}
160
161pub fn ensure_distinct(aliases: &[Alias]) -> Result<()> {
167 for (i, a) in aliases.iter().enumerate() {
168 if let Some(other) = aliases[..i].iter().find(|b| b.name() == a.name()) {
169 bail!(
170 "alias {:?} is defined twice: {} and {}",
171 a.name(),
172 other.host(),
173 a.host()
174 );
175 }
176 }
177 Ok(())
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 const FULL: &str = r#"
185[server]
186port = 7391
187suffix = "ssh-browser"
188author = "souta"
189
190[[alias]]
191name = "docs"
192host = "myhost"
193base = "/srv/docs"
194
195[[alias]]
196name = "cluster"
197host = "login-node"
198base = "/home/me/public_html"
199"#;
200
201 #[test]
202 fn a_full_file_parses() {
203 let c = parse(FULL).expect("parses");
204 assert_eq!(c.server.port, Some(7391));
205 assert_eq!(c.server.suffix.as_deref(), Some("ssh-browser"));
206 assert_eq!(c.server.author.as_deref(), Some("souta"));
207 assert_eq!(c.aliases.len(), 2);
208 assert_eq!(c.aliases[0].name(), "docs");
209 assert_eq!(c.aliases[1].base(), Some("/home/me/public_html"));
210 }
211
212 #[test]
213 fn a_file_of_only_aliases_is_fine() {
214 let c =
215 parse("[[alias]]\nname = \"docs\"\nhost = \"h\"\nbase = \"/srv\"\n").expect("parses");
216 assert!(c.server.port.is_none());
217 assert_eq!(c.aliases.len(), 1);
218 }
219
220 #[test]
221 fn an_empty_file_is_fine() {
222 assert!(parse("").expect("parses").aliases.is_empty());
223 }
224
225 #[test]
228 fn a_misspelled_key_is_refused_rather_than_ignored() {
229 let e = parse("[server]\nsuffixx = \"dev\"\n").expect_err("refused");
230 assert!(
231 format!("{e:#}").contains("suffixx"),
232 "the error has to name the key: {e:#}"
233 );
234 assert!(
235 parse("[[alias]]\nname = \"a\"\nhost = \"h\"\nbase = \"/b\"\nextra = 1\n").is_err()
236 );
237 assert!(parse("[serverr]\nport = 1\n").is_err());
238 }
239
240 #[test]
243 fn asking_for_https_is_refused_while_it_does_not_exist() {
244 let e = parse("[server]\nscheme = \"https\"\n").expect_err("refused");
245 assert!(format!("{e:#}").contains("https"), "{e:#}");
246 assert!(parse("[server]\nscheme = \"http\"\n").is_ok());
247 }
248
249 #[test]
252 fn an_alias_from_a_file_is_checked_like_one_from_the_command_line() {
253 for bad in [
254 "[[alias]]\nname = \"Docs\"\nhost = \"h\"\nbase = \"/srv\"\n",
255 "[[alias]]\nname = \"a.b\"\nhost = \"h\"\nbase = \"/srv\"\n",
256 "[[alias]]\nname = \"docs\"\nhost = \"h\"\nbase = \"relative\"\n",
257 "[[alias]]\nname = \"docs\"\nhost = \"\"\nbase = \"/srv\"\n",
258 "[[alias]]\nname = \"-docs\"\nhost = \"h\"\nbase = \"/srv\"\n",
263 "[[alias]]\nname = \"docs-\"\nhost = \"h\"\nbase = \"/srv\"\n",
264 ] {
265 assert!(parse(bad).is_err(), "should have been refused:\n{bad}");
266 }
267 }
268
269 #[test]
270 fn a_missing_alias_field_is_refused() {
271 for bad in [
272 "[[alias]]\nhost = \"h\"\nbase = \"/srv\"\n",
273 "[[alias]]\nname = \"docs\"\nbase = \"/srv\"\n",
274 ] {
275 assert!(parse(bad).is_err(), "should have been refused:\n{bad}");
276 }
277 }
278
279 #[test]
285 fn an_alias_without_a_base_means_the_home_directory() {
286 let c = parse("[[alias]]\nname = \"docs\"\nhost = \"h\"\n").expect("parses");
287 assert_eq!(c.aliases[0].base(), None);
288 }
289
290 fn alias(name: &str, host: &str) -> Alias {
291 Alias::new(name, host, Some("/srv")).expect("a valid alias")
292 }
293
294 fn file_with(server: Server, aliases: Vec<Alias>) -> Config {
295 Config { server, aliases }
296 }
297
298 #[test]
300 fn the_command_line_wins_over_the_file() {
301 let file = file_with(
302 Server {
303 port: Some(1111),
304 suffix: Some("from-file".to_string()),
305 author: Some("from-file".to_string()),
306 theme: None,
307 scheme: None,
308 },
309 vec![],
310 );
311 let cli = Overrides {
312 port: Some(2222),
313 suffix: Some("from-cli".to_string()),
314 author: Some("from-cli".to_string()),
315 aliases: vec![],
316 };
317
318 let r = merge(cli, file, "fallback".to_string()).expect("merges");
319 assert_eq!(r.port, 2222);
320 assert_eq!(r.suffix, "from-cli");
321 assert_eq!(r.author, "from-cli");
322 }
323
324 #[test]
325 fn the_file_supplies_what_the_command_line_does_not() {
326 let file = file_with(
327 Server {
328 port: Some(1111),
329 suffix: Some("from-file".to_string()),
330 author: None,
331 theme: None,
332 scheme: None,
333 },
334 vec![],
335 );
336
337 let r = merge(Overrides::default(), file, "fallback".to_string()).expect("merges");
338 assert_eq!(r.port, 1111);
339 assert_eq!(r.suffix, "from-file");
340 assert_eq!(r.author, "fallback");
342 }
343
344 #[test]
345 fn what_neither_supplies_falls_back() {
346 let r = merge(
347 Overrides::default(),
348 file_with(Server::default(), vec![]),
349 "fallback".to_string(),
350 )
351 .expect("merges");
352 assert_eq!(r.port, DEFAULT_PORT);
353 assert_eq!(r.suffix, DEFAULT_SUFFIX);
354 }
355
356 #[test]
359 fn aliases_from_both_places_are_kept() {
360 let r = merge(
361 Overrides {
362 aliases: vec![alias("cli", "h")],
363 ..Overrides::default()
364 },
365 file_with(Server::default(), vec![alias("file", "h")]),
366 "fallback".to_string(),
367 )
368 .expect("merges");
369
370 let names: Vec<&str> = r.aliases.iter().map(Alias::name).collect();
371 assert_eq!(names, ["file", "cli"]);
372 }
373
374 #[test]
377 fn a_name_given_in_both_places_is_refused() {
378 let e = merge(
379 Overrides {
380 aliases: vec![alias("docs", "from-cli")],
381 ..Overrides::default()
382 },
383 file_with(Server::default(), vec![alias("docs", "from-file")]),
384 "fallback".to_string(),
385 )
386 .expect_err("refused");
387 assert!(format!("{e:#}").contains("docs"), "{e:#}");
388 }
389
390 #[test]
391 fn two_aliases_with_one_name_are_refused() {
392 let docs = |host: &str| Alias::new("docs", host, Some("/srv")).expect("valid");
393 assert!(ensure_distinct(&[docs("a"), docs("b")]).is_err());
394 let other = Alias::new("other", "b", Some("/srv")).expect("valid");
395 assert!(ensure_distinct(&[docs("a"), other]).is_ok());
396 }
397
398 #[test]
399 fn the_default_path_is_resolved_at_runtime() {
400 if let Some(p) = default_path() {
403 assert!(p.ends_with(Path::new("ssh-browser").join("config.toml")));
404 }
405 }
406}