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