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>,
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, Deserialize)]
63#[serde(deny_unknown_fields)]
64struct HostEntry {
65 name: String,
67 #[serde(default)]
69 base: Option<String>,
70 #[serde(default = "yes")]
73 enabled: bool,
74}
75
76fn yes() -> bool {
77 true
78}
79
80#[derive(Debug, Default, Deserialize)]
81#[serde(deny_unknown_fields)]
82struct Document {
83 #[serde(default)]
84 server: Server,
85 #[serde(default, rename = "alias")]
88 aliases: Vec<AliasEntry>,
89 #[serde(default, rename = "host")]
90 hosts: Vec<HostEntry>,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct Reachable {
96 pub name: String,
97 pub base: Option<String>,
98 pub enabled: bool,
99}
100
101#[derive(Debug)]
102pub struct Config {
103 pub server: Server,
104 pub aliases: Vec<Alias>,
105 pub hosts: Vec<Reachable>,
106}
107
108pub fn parse(text: &str) -> Result<Config> {
112 let doc: Document = toml::from_str(text).context("reading the configuration")?;
113
114 if let Some(scheme) = doc.server.scheme.as_deref() {
115 ensure!(
116 scheme == "http" || scheme == "https",
117 "scheme = {scheme:?} is not one this daemon serves; use \"http\" or \"https\""
118 );
119 }
120
121 let mut aliases = Vec::with_capacity(doc.aliases.len());
122 for entry in &doc.aliases {
123 aliases.push(Alias::new(&entry.name, &entry.host, entry.base.as_deref())?);
124 }
125
126 let mut hosts = Vec::with_capacity(doc.hosts.len());
127 for entry in &doc.hosts {
128 Alias::new(&entry.name, &entry.name, entry.base.as_deref())?;
133 ensure!(
134 !hosts.iter().any(|h: &Reachable| h.name == entry.name),
135 "host {:?} is listed twice",
136 entry.name
137 );
138 ensure!(
139 !aliases.iter().any(|a| a.name() == entry.name),
140 "{:?} is both an alias and a host; one of them would decide what that URL means and it is not obvious which",
141 entry.name
142 );
143 hosts.push(Reachable {
144 name: entry.name.clone(),
145 base: entry.base.clone(),
146 enabled: entry.enabled,
147 });
148 }
149
150 Ok(Config {
151 server: doc.server,
152 aliases,
153 hosts,
154 })
155}
156
157pub fn load(path: &Path) -> Result<Config> {
159 let text =
160 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
161 parse(&text).with_context(|| format!("in {}", path.display()))
162}
163
164pub fn default_path() -> Option<PathBuf> {
170 let base = std::env::var_os("XDG_CONFIG_HOME")
171 .or_else(|| std::env::var_os("APPDATA"))
172 .or_else(|| std::env::var_os("LOCALAPPDATA"))
173 .map(PathBuf::from)
174 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))?;
175 Some(base.join("ssh-browser").join("config.toml"))
176}
177
178pub const DEFAULT_PORT: u16 = 7391;
179pub const DEFAULT_SUFFIX: &str = "ssh-browser";
180
181pub const DEFAULT_SCHEME: &str = "http";
184
185#[derive(Debug, Default)]
188pub struct Overrides {
189 pub port: Option<u16>,
190 pub suffix: Option<String>,
191 pub scheme: Option<String>,
192 pub aliases: Vec<Alias>,
193}
194
195#[derive(Debug)]
197pub struct Resolved {
198 pub port: u16,
199 pub suffix: String,
200 pub scheme: String,
201 pub aliases: Vec<Alias>,
202 pub hosts: Vec<Reachable>,
206}
207
208pub fn merge(cli: Overrides, file: Config) -> Result<Resolved> {
218 let mut aliases = file.aliases;
219 aliases.extend(cli.aliases);
220 ensure_distinct(&aliases)?;
221
222 Ok(Resolved {
223 port: cli.port.or(file.server.port).unwrap_or(DEFAULT_PORT),
224 suffix: cli
225 .suffix
226 .or(file.server.suffix)
227 .unwrap_or_else(|| DEFAULT_SUFFIX.to_string()),
228 scheme: cli
229 .scheme
230 .or(file.server.scheme)
231 .unwrap_or_else(|| DEFAULT_SCHEME.to_string()),
232 aliases,
233 hosts: file.hosts,
234 })
235}
236
237pub fn ensure_distinct(aliases: &[Alias]) -> Result<()> {
243 for (i, a) in aliases.iter().enumerate() {
244 if let Some(other) = aliases[..i].iter().find(|b| b.name() == a.name()) {
245 bail!(
246 "alias {:?} is defined twice: {} and {}",
247 a.name(),
248 other.host(),
249 a.host()
250 );
251 }
252 }
253 Ok(())
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259
260 const FULL: &str = r#"
261[server]
262port = 7391
263suffix = "ssh-browser"
264
265[[alias]]
266name = "docs"
267host = "myhost"
268base = "/srv/docs"
269
270[[alias]]
271name = "cluster"
272host = "login-node"
273base = "/home/me/public_html"
274"#;
275
276 #[test]
277 fn a_full_file_parses() {
278 let c = parse(FULL).expect("parses");
279 assert_eq!(c.server.port, Some(7391));
280 assert_eq!(c.server.suffix.as_deref(), Some("ssh-browser"));
281 assert_eq!(c.aliases.len(), 2);
282 assert_eq!(c.aliases[0].name(), "docs");
283 assert_eq!(c.aliases[1].base(), Some("/home/me/public_html"));
284 }
285
286 #[test]
287 fn a_file_of_only_aliases_is_fine() {
288 let c =
289 parse("[[alias]]\nname = \"docs\"\nhost = \"h\"\nbase = \"/srv\"\n").expect("parses");
290 assert!(c.server.port.is_none());
291 assert_eq!(c.aliases.len(), 1);
292 }
293
294 #[test]
295 fn an_empty_file_is_fine() {
296 assert!(parse("").expect("parses").aliases.is_empty());
297 }
298
299 #[test]
302 fn a_misspelled_key_is_refused_rather_than_ignored() {
303 let e = parse("[server]\nsuffixx = \"dev\"\n").expect_err("refused");
304 assert!(
305 format!("{e:#}").contains("suffixx"),
306 "the error has to name the key: {e:#}"
307 );
308 assert!(
309 parse("[[alias]]\nname = \"a\"\nhost = \"h\"\nbase = \"/b\"\nextra = 1\n").is_err()
310 );
311 assert!(parse("[serverr]\nport = 1\n").is_err());
312 }
313
314 #[test]
320 fn the_two_schemes_are_accepted_and_a_third_is_refused() {
321 assert!(parse("[server]\nscheme = \"http\"\n").is_ok());
322 assert!(parse("[server]\nscheme = \"https\"\n").is_ok());
323 for bad in ["HTTPS", "ftp", "wss", "", "http:"] {
324 let e = parse(&format!("[server]\nscheme = \"{bad}\"\n"))
325 .expect_err(&format!("{bad:?} should have been refused"));
326 assert!(format!("{e:#}").contains("scheme"), "{e:#}");
327 }
328 }
329
330 #[test]
336 fn the_default_scheme_is_http() {
337 let r = merge(Overrides::default(), parse("").expect("empty parses")).expect("merges");
338 assert_eq!(r.scheme, "http");
339 }
340
341 #[test]
343 fn the_command_line_scheme_wins() {
344 let file = parse("[server]\nscheme = \"http\"\n").expect("parses");
345 let cli = Overrides {
346 scheme: Some("https".to_string()),
347 ..Overrides::default()
348 };
349 assert_eq!(merge(cli, file).expect("merges").scheme, "https");
350 }
351
352 #[test]
355 fn an_alias_from_a_file_is_checked_like_one_from_the_command_line() {
356 for bad in [
357 "[[alias]]\nname = \"Docs\"\nhost = \"h\"\nbase = \"/srv\"\n",
358 "[[alias]]\nname = \"a.b\"\nhost = \"h\"\nbase = \"/srv\"\n",
359 "[[alias]]\nname = \"docs\"\nhost = \"h\"\nbase = \"relative\"\n",
360 "[[alias]]\nname = \"docs\"\nhost = \"\"\nbase = \"/srv\"\n",
361 "[[alias]]\nname = \"-docs\"\nhost = \"h\"\nbase = \"/srv\"\n",
366 "[[alias]]\nname = \"docs-\"\nhost = \"h\"\nbase = \"/srv\"\n",
367 ] {
368 assert!(parse(bad).is_err(), "should have been refused:\n{bad}");
369 }
370 }
371
372 #[test]
373 fn a_missing_alias_field_is_refused() {
374 for bad in [
375 "[[alias]]\nhost = \"h\"\nbase = \"/srv\"\n",
376 "[[alias]]\nname = \"docs\"\nbase = \"/srv\"\n",
377 ] {
378 assert!(parse(bad).is_err(), "should have been refused:\n{bad}");
379 }
380 }
381
382 #[test]
388 fn an_alias_without_a_base_means_the_home_directory() {
389 let c = parse("[[alias]]\nname = \"docs\"\nhost = \"h\"\n").expect("parses");
390 assert_eq!(c.aliases[0].base(), None);
391 }
392
393 #[test]
394 fn a_host_needs_only_a_name_and_is_on_by_default() {
395 let c = parse(
396 "[[host]]
397name = \"login-node\"
398",
399 )
400 .expect("parses");
401 assert_eq!(c.hosts.len(), 1);
402 assert_eq!(c.hosts[0].name, "login-node");
403 assert_eq!(c.hosts[0].base, None);
404 assert!(
405 c.hosts[0].enabled,
406 "a host somebody wrote down is one they want"
407 );
408 }
409
410 #[test]
411 fn a_host_can_be_turned_off_without_deleting_where_it_is_rooted() {
412 let c = parse(
413 "[[host]]
414name = \"n\"
415base = \"~/w\"
416enabled = false
417",
418 )
419 .expect("parses");
420 assert!(!c.hosts[0].enabled);
421 assert_eq!(c.hosts[0].base.as_deref(), Some("~/w"));
422 }
423
424 #[test]
428 fn a_host_may_not_carry_ssh_details() {
429 for line in [
430 "user = \"me\"",
431 "port = 22",
432 "hostname = \"h\"",
433 "proxyJump = \"j\"",
434 ] {
435 assert!(
436 parse(&format!(
437 "[[host]]
438name = \"n\"
439{line}
440"
441 ))
442 .is_err(),
443 "{line} should have been refused"
444 );
445 }
446 }
447
448 #[test]
451 fn a_name_may_not_mean_two_things() {
452 assert!(
453 parse(
454 "[[host]]
455name = \"n\"
456[[host]]
457name = \"n\"
458"
459 )
460 .is_err()
461 );
462 assert!(
463 parse(
464 "[[alias]]
465name = \"n\"
466host = \"h\"
467[[host]]
468name = \"n\"
469"
470 )
471 .is_err()
472 );
473 }
474
475 #[test]
479 fn a_host_name_that_cannot_be_a_label_is_refused_where_it_is_written() {
480 assert!(
481 parse(
482 "[[host]]
483name = \"-nope\"
484"
485 )
486 .is_err()
487 );
488 assert!(
489 parse(
490 "[[host]]
491name = \"\"
492"
493 )
494 .is_err()
495 );
496 }
497
498 fn alias(name: &str, host: &str) -> Alias {
499 Alias::new(name, host, Some("/srv")).expect("a valid alias")
500 }
501
502 fn file_with(server: Server, aliases: Vec<Alias>) -> Config {
503 Config {
504 server,
505 aliases,
506 hosts: Vec::new(),
507 }
508 }
509
510 #[test]
512 fn the_command_line_wins_over_the_file() {
513 let file = file_with(
514 Server {
515 port: Some(1111),
516 suffix: Some("from-file".to_string()),
517 theme: None,
518 scheme: None,
519 },
520 vec![],
521 );
522 let cli = Overrides {
523 port: Some(2222),
524 suffix: Some("from-cli".to_string()),
525 scheme: None,
526 aliases: vec![],
527 };
528
529 let r = merge(cli, file).expect("merges");
530 assert_eq!(r.port, 2222);
531 assert_eq!(r.suffix, "from-cli");
532 }
533
534 #[test]
535 fn the_file_supplies_what_the_command_line_does_not() {
536 let file = file_with(
537 Server {
538 port: Some(1111),
539 suffix: Some("from-file".to_string()),
540 theme: None,
541 scheme: None,
542 },
543 vec![],
544 );
545
546 let r = merge(Overrides::default(), file).expect("merges");
547 assert_eq!(r.port, 1111);
548 assert_eq!(r.suffix, "from-file");
549 }
551
552 #[test]
553 fn what_neither_supplies_falls_back() {
554 let r = merge(Overrides::default(), file_with(Server::default(), vec![])).expect("merges");
555 assert_eq!(r.port, DEFAULT_PORT);
556 assert_eq!(r.suffix, DEFAULT_SUFFIX);
557 }
558
559 #[test]
562 fn aliases_from_both_places_are_kept() {
563 let r = merge(
564 Overrides {
565 aliases: vec![alias("cli", "h")],
566 ..Overrides::default()
567 },
568 file_with(Server::default(), vec![alias("file", "h")]),
569 )
570 .expect("merges");
571
572 let names: Vec<&str> = r.aliases.iter().map(Alias::name).collect();
573 assert_eq!(names, ["file", "cli"]);
574 }
575
576 #[test]
579 fn a_name_given_in_both_places_is_refused() {
580 let e = merge(
581 Overrides {
582 aliases: vec![alias("docs", "from-cli")],
583 ..Overrides::default()
584 },
585 file_with(Server::default(), vec![alias("docs", "from-file")]),
586 )
587 .expect_err("refused");
588 assert!(format!("{e:#}").contains("docs"), "{e:#}");
589 }
590
591 #[test]
592 fn two_aliases_with_one_name_are_refused() {
593 let docs = |host: &str| Alias::new("docs", host, Some("/srv")).expect("valid");
594 assert!(ensure_distinct(&[docs("a"), docs("b")]).is_err());
595 let other = Alias::new("other", "b", Some("/srv")).expect("valid");
596 assert!(ensure_distinct(&[docs("a"), other]).is_ok());
597 }
598
599 #[test]
600 fn the_default_path_is_resolved_at_runtime() {
601 if let Some(p) = default_path() {
604 assert!(p.ends_with(Path::new("ssh-browser").join("config.toml")));
605 }
606 }
607}