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 theme: Option<String>,
32 pub scheme: Option<String>,
37}
38
39#[derive(Debug, Deserialize)]
40#[serde(deny_unknown_fields)]
41struct AliasEntry {
42 name: String,
43 host: String,
44 #[serde(default)]
49 base: Option<String>,
50}
51
52#[derive(Debug, Deserialize)]
62#[serde(deny_unknown_fields)]
63struct HostEntry {
64 name: String,
66 #[serde(default)]
68 base: Option<String>,
69 #[serde(default = "yes")]
72 enabled: bool,
73}
74
75fn yes() -> bool {
76 true
77}
78
79#[derive(Debug, Default, Deserialize)]
80#[serde(deny_unknown_fields)]
81struct Document {
82 #[serde(default)]
83 server: Server,
84 #[serde(default, rename = "alias")]
87 aliases: Vec<AliasEntry>,
88 #[serde(default, rename = "host")]
89 hosts: Vec<HostEntry>,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct Reachable {
95 pub name: String,
96 pub base: Option<String>,
97 pub enabled: bool,
98}
99
100#[derive(Debug)]
101pub struct Config {
102 pub server: Server,
103 pub aliases: Vec<Alias>,
104 pub hosts: Vec<Reachable>,
105}
106
107pub fn parse(text: &str) -> Result<Config> {
111 let doc: Document = toml::from_str(text).context("reading the configuration")?;
112
113 if let Some(scheme) = doc.server.scheme.as_deref() {
114 ensure!(
115 scheme == "http",
116 "scheme = {scheme:?} is not supported yet; only \"http\" is. https needs a CA constrained to the suffix, which is designed but not built"
117 );
118 }
119
120 let mut aliases = Vec::with_capacity(doc.aliases.len());
121 for entry in &doc.aliases {
122 aliases.push(Alias::new(&entry.name, &entry.host, entry.base.as_deref())?);
123 }
124
125 let mut hosts = Vec::with_capacity(doc.hosts.len());
126 for entry in &doc.hosts {
127 Alias::new(&entry.name, &entry.name, entry.base.as_deref())?;
132 ensure!(
133 !hosts.iter().any(|h: &Reachable| h.name == entry.name),
134 "host {:?} is listed twice",
135 entry.name
136 );
137 ensure!(
138 !aliases.iter().any(|a| a.name() == entry.name),
139 "{:?} is both an alias and a host; one of them would decide what that URL means and it is not obvious which",
140 entry.name
141 );
142 hosts.push(Reachable {
143 name: entry.name.clone(),
144 base: entry.base.clone(),
145 enabled: entry.enabled,
146 });
147 }
148
149 Ok(Config {
150 server: doc.server,
151 aliases,
152 hosts,
153 })
154}
155
156pub fn load(path: &Path) -> Result<Config> {
158 let text =
159 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
160 parse(&text).with_context(|| format!("in {}", path.display()))
161}
162
163pub fn default_path() -> Option<PathBuf> {
169 let base = std::env::var_os("XDG_CONFIG_HOME")
170 .or_else(|| std::env::var_os("APPDATA"))
171 .or_else(|| std::env::var_os("LOCALAPPDATA"))
172 .map(PathBuf::from)
173 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))?;
174 Some(base.join("ssh-browser").join("config.toml"))
175}
176
177pub const DEFAULT_PORT: u16 = 7391;
178pub const DEFAULT_SUFFIX: &str = "ssh-browser";
179
180#[derive(Debug, Default)]
183pub struct Overrides {
184 pub port: Option<u16>,
185 pub suffix: Option<String>,
186 pub aliases: Vec<Alias>,
187}
188
189#[derive(Debug)]
191pub struct Resolved {
192 pub port: u16,
193 pub suffix: String,
194 pub aliases: Vec<Alias>,
195 pub hosts: Vec<Reachable>,
199}
200
201pub fn merge(cli: Overrides, file: Config) -> Result<Resolved> {
211 let mut aliases = file.aliases;
212 aliases.extend(cli.aliases);
213 ensure_distinct(&aliases)?;
214
215 Ok(Resolved {
216 port: cli.port.or(file.server.port).unwrap_or(DEFAULT_PORT),
217 suffix: cli
218 .suffix
219 .or(file.server.suffix)
220 .unwrap_or_else(|| DEFAULT_SUFFIX.to_string()),
221 aliases,
222 hosts: file.hosts,
223 })
224}
225
226pub fn ensure_distinct(aliases: &[Alias]) -> Result<()> {
232 for (i, a) in aliases.iter().enumerate() {
233 if let Some(other) = aliases[..i].iter().find(|b| b.name() == a.name()) {
234 bail!(
235 "alias {:?} is defined twice: {} and {}",
236 a.name(),
237 other.host(),
238 a.host()
239 );
240 }
241 }
242 Ok(())
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248
249 const FULL: &str = r#"
250[server]
251port = 7391
252suffix = "ssh-browser"
253
254[[alias]]
255name = "docs"
256host = "myhost"
257base = "/srv/docs"
258
259[[alias]]
260name = "cluster"
261host = "login-node"
262base = "/home/me/public_html"
263"#;
264
265 #[test]
266 fn a_full_file_parses() {
267 let c = parse(FULL).expect("parses");
268 assert_eq!(c.server.port, Some(7391));
269 assert_eq!(c.server.suffix.as_deref(), Some("ssh-browser"));
270 assert_eq!(c.aliases.len(), 2);
271 assert_eq!(c.aliases[0].name(), "docs");
272 assert_eq!(c.aliases[1].base(), Some("/home/me/public_html"));
273 }
274
275 #[test]
276 fn a_file_of_only_aliases_is_fine() {
277 let c =
278 parse("[[alias]]\nname = \"docs\"\nhost = \"h\"\nbase = \"/srv\"\n").expect("parses");
279 assert!(c.server.port.is_none());
280 assert_eq!(c.aliases.len(), 1);
281 }
282
283 #[test]
284 fn an_empty_file_is_fine() {
285 assert!(parse("").expect("parses").aliases.is_empty());
286 }
287
288 #[test]
291 fn a_misspelled_key_is_refused_rather_than_ignored() {
292 let e = parse("[server]\nsuffixx = \"dev\"\n").expect_err("refused");
293 assert!(
294 format!("{e:#}").contains("suffixx"),
295 "the error has to name the key: {e:#}"
296 );
297 assert!(
298 parse("[[alias]]\nname = \"a\"\nhost = \"h\"\nbase = \"/b\"\nextra = 1\n").is_err()
299 );
300 assert!(parse("[serverr]\nport = 1\n").is_err());
301 }
302
303 #[test]
306 fn asking_for_https_is_refused_while_it_does_not_exist() {
307 let e = parse("[server]\nscheme = \"https\"\n").expect_err("refused");
308 assert!(format!("{e:#}").contains("https"), "{e:#}");
309 assert!(parse("[server]\nscheme = \"http\"\n").is_ok());
310 }
311
312 #[test]
315 fn an_alias_from_a_file_is_checked_like_one_from_the_command_line() {
316 for bad in [
317 "[[alias]]\nname = \"Docs\"\nhost = \"h\"\nbase = \"/srv\"\n",
318 "[[alias]]\nname = \"a.b\"\nhost = \"h\"\nbase = \"/srv\"\n",
319 "[[alias]]\nname = \"docs\"\nhost = \"h\"\nbase = \"relative\"\n",
320 "[[alias]]\nname = \"docs\"\nhost = \"\"\nbase = \"/srv\"\n",
321 "[[alias]]\nname = \"-docs\"\nhost = \"h\"\nbase = \"/srv\"\n",
326 "[[alias]]\nname = \"docs-\"\nhost = \"h\"\nbase = \"/srv\"\n",
327 ] {
328 assert!(parse(bad).is_err(), "should have been refused:\n{bad}");
329 }
330 }
331
332 #[test]
333 fn a_missing_alias_field_is_refused() {
334 for bad in [
335 "[[alias]]\nhost = \"h\"\nbase = \"/srv\"\n",
336 "[[alias]]\nname = \"docs\"\nbase = \"/srv\"\n",
337 ] {
338 assert!(parse(bad).is_err(), "should have been refused:\n{bad}");
339 }
340 }
341
342 #[test]
348 fn an_alias_without_a_base_means_the_home_directory() {
349 let c = parse("[[alias]]\nname = \"docs\"\nhost = \"h\"\n").expect("parses");
350 assert_eq!(c.aliases[0].base(), None);
351 }
352
353 #[test]
354 fn a_host_needs_only_a_name_and_is_on_by_default() {
355 let c = parse(
356 "[[host]]
357name = \"login-node\"
358",
359 )
360 .expect("parses");
361 assert_eq!(c.hosts.len(), 1);
362 assert_eq!(c.hosts[0].name, "login-node");
363 assert_eq!(c.hosts[0].base, None);
364 assert!(
365 c.hosts[0].enabled,
366 "a host somebody wrote down is one they want"
367 );
368 }
369
370 #[test]
371 fn a_host_can_be_turned_off_without_deleting_where_it_is_rooted() {
372 let c = parse(
373 "[[host]]
374name = \"n\"
375base = \"~/w\"
376enabled = false
377",
378 )
379 .expect("parses");
380 assert!(!c.hosts[0].enabled);
381 assert_eq!(c.hosts[0].base.as_deref(), Some("~/w"));
382 }
383
384 #[test]
388 fn a_host_may_not_carry_ssh_details() {
389 for line in [
390 "user = \"me\"",
391 "port = 22",
392 "hostname = \"h\"",
393 "proxyJump = \"j\"",
394 ] {
395 assert!(
396 parse(&format!(
397 "[[host]]
398name = \"n\"
399{line}
400"
401 ))
402 .is_err(),
403 "{line} should have been refused"
404 );
405 }
406 }
407
408 #[test]
411 fn a_name_may_not_mean_two_things() {
412 assert!(
413 parse(
414 "[[host]]
415name = \"n\"
416[[host]]
417name = \"n\"
418"
419 )
420 .is_err()
421 );
422 assert!(
423 parse(
424 "[[alias]]
425name = \"n\"
426host = \"h\"
427[[host]]
428name = \"n\"
429"
430 )
431 .is_err()
432 );
433 }
434
435 #[test]
439 fn a_host_name_that_cannot_be_a_label_is_refused_where_it_is_written() {
440 assert!(
441 parse(
442 "[[host]]
443name = \"-nope\"
444"
445 )
446 .is_err()
447 );
448 assert!(
449 parse(
450 "[[host]]
451name = \"\"
452"
453 )
454 .is_err()
455 );
456 }
457
458 fn alias(name: &str, host: &str) -> Alias {
459 Alias::new(name, host, Some("/srv")).expect("a valid alias")
460 }
461
462 fn file_with(server: Server, aliases: Vec<Alias>) -> Config {
463 Config {
464 server,
465 aliases,
466 hosts: Vec::new(),
467 }
468 }
469
470 #[test]
472 fn the_command_line_wins_over_the_file() {
473 let file = file_with(
474 Server {
475 port: Some(1111),
476 suffix: Some("from-file".to_string()),
477 theme: None,
478 scheme: None,
479 },
480 vec![],
481 );
482 let cli = Overrides {
483 port: Some(2222),
484 suffix: Some("from-cli".to_string()),
485 aliases: vec![],
486 };
487
488 let r = merge(cli, file).expect("merges");
489 assert_eq!(r.port, 2222);
490 assert_eq!(r.suffix, "from-cli");
491 }
492
493 #[test]
494 fn the_file_supplies_what_the_command_line_does_not() {
495 let file = file_with(
496 Server {
497 port: Some(1111),
498 suffix: Some("from-file".to_string()),
499 theme: None,
500 scheme: None,
501 },
502 vec![],
503 );
504
505 let r = merge(Overrides::default(), file).expect("merges");
506 assert_eq!(r.port, 1111);
507 assert_eq!(r.suffix, "from-file");
508 }
510
511 #[test]
512 fn what_neither_supplies_falls_back() {
513 let r = merge(Overrides::default(), file_with(Server::default(), vec![])).expect("merges");
514 assert_eq!(r.port, DEFAULT_PORT);
515 assert_eq!(r.suffix, DEFAULT_SUFFIX);
516 }
517
518 #[test]
521 fn aliases_from_both_places_are_kept() {
522 let r = merge(
523 Overrides {
524 aliases: vec![alias("cli", "h")],
525 ..Overrides::default()
526 },
527 file_with(Server::default(), vec![alias("file", "h")]),
528 )
529 .expect("merges");
530
531 let names: Vec<&str> = r.aliases.iter().map(Alias::name).collect();
532 assert_eq!(names, ["file", "cli"]);
533 }
534
535 #[test]
538 fn a_name_given_in_both_places_is_refused() {
539 let e = merge(
540 Overrides {
541 aliases: vec![alias("docs", "from-cli")],
542 ..Overrides::default()
543 },
544 file_with(Server::default(), vec![alias("docs", "from-file")]),
545 )
546 .expect_err("refused");
547 assert!(format!("{e:#}").contains("docs"), "{e:#}");
548 }
549
550 #[test]
551 fn two_aliases_with_one_name_are_refused() {
552 let docs = |host: &str| Alias::new("docs", host, Some("/srv")).expect("valid");
553 assert!(ensure_distinct(&[docs("a"), docs("b")]).is_err());
554 let other = Alias::new("other", "b", Some("/srv")).expect("valid");
555 assert!(ensure_distinct(&[docs("a"), other]).is_ok());
556 }
557
558 #[test]
559 fn the_default_path_is_resolved_at_runtime() {
560 if let Some(p) = default_path() {
563 assert!(p.ends_with(Path::new("ssh-browser").join("config.toml")));
564 }
565 }
566}