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 scheme: Option<String>,
32}
33
34#[derive(Debug, Deserialize)]
35#[serde(deny_unknown_fields)]
36struct AliasEntry {
37 name: String,
38 host: String,
39 base: String,
40}
41
42#[derive(Debug, Default, Deserialize)]
43#[serde(deny_unknown_fields)]
44struct Document {
45 #[serde(default)]
46 server: Server,
47 #[serde(default, rename = "alias")]
50 aliases: Vec<AliasEntry>,
51}
52
53#[derive(Debug)]
54pub struct Config {
55 pub server: Server,
56 pub aliases: Vec<Alias>,
57}
58
59pub fn parse(text: &str) -> Result<Config> {
63 let doc: Document = toml::from_str(text).context("reading the configuration")?;
64
65 if let Some(scheme) = doc.server.scheme.as_deref() {
66 ensure!(
67 scheme == "http",
68 "scheme = {scheme:?} is not supported yet; only \"http\" is. https needs a CA constrained to the suffix, which is designed but not built"
69 );
70 }
71
72 let mut aliases = Vec::with_capacity(doc.aliases.len());
73 for entry in &doc.aliases {
74 aliases.push(Alias::new(&entry.name, &entry.host, &entry.base)?);
75 }
76 Ok(Config {
77 server: doc.server,
78 aliases,
79 })
80}
81
82pub fn load(path: &Path) -> Result<Config> {
84 let text =
85 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
86 parse(&text).with_context(|| format!("in {}", path.display()))
87}
88
89pub fn default_path() -> Option<PathBuf> {
95 let base = std::env::var_os("XDG_CONFIG_HOME")
96 .or_else(|| std::env::var_os("APPDATA"))
97 .or_else(|| std::env::var_os("LOCALAPPDATA"))
98 .map(PathBuf::from)
99 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))?;
100 Some(base.join("ssh-browser").join("config.toml"))
101}
102
103pub const DEFAULT_PORT: u16 = 7391;
104pub const DEFAULT_SUFFIX: &str = "ssh-browser";
105
106#[derive(Debug, Default)]
109pub struct Overrides {
110 pub port: Option<u16>,
111 pub suffix: Option<String>,
112 pub author: Option<String>,
113 pub aliases: Vec<Alias>,
114}
115
116#[derive(Debug)]
118pub struct Resolved {
119 pub port: u16,
120 pub suffix: String,
121 pub author: String,
122 pub aliases: Vec<Alias>,
123}
124
125pub fn merge(cli: Overrides, file: Config, default_author: String) -> Result<Resolved> {
135 let mut aliases = file.aliases;
136 aliases.extend(cli.aliases);
137 ensure_distinct(&aliases)?;
138
139 Ok(Resolved {
140 port: cli.port.or(file.server.port).unwrap_or(DEFAULT_PORT),
141 suffix: cli
142 .suffix
143 .or(file.server.suffix)
144 .unwrap_or_else(|| DEFAULT_SUFFIX.to_string()),
145 author: cli.author.or(file.server.author).unwrap_or(default_author),
146 aliases,
147 })
148}
149
150pub fn ensure_distinct(aliases: &[Alias]) -> Result<()> {
156 for (i, a) in aliases.iter().enumerate() {
157 if let Some(other) = aliases[..i].iter().find(|b| b.name() == a.name()) {
158 bail!(
159 "alias {:?} is defined twice: {} and {}",
160 a.name(),
161 other.host(),
162 a.host()
163 );
164 }
165 }
166 Ok(())
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172
173 const FULL: &str = r#"
174[server]
175port = 7391
176suffix = "ssh-browser"
177author = "souta"
178
179[[alias]]
180name = "docs"
181host = "myhost"
182base = "/srv/docs"
183
184[[alias]]
185name = "cluster"
186host = "login-node"
187base = "/home/me/public_html"
188"#;
189
190 #[test]
191 fn a_full_file_parses() {
192 let c = parse(FULL).expect("parses");
193 assert_eq!(c.server.port, Some(7391));
194 assert_eq!(c.server.suffix.as_deref(), Some("ssh-browser"));
195 assert_eq!(c.server.author.as_deref(), Some("souta"));
196 assert_eq!(c.aliases.len(), 2);
197 assert_eq!(c.aliases[0].name(), "docs");
198 assert_eq!(c.aliases[1].base(), "/home/me/public_html");
199 }
200
201 #[test]
202 fn a_file_of_only_aliases_is_fine() {
203 let c =
204 parse("[[alias]]\nname = \"docs\"\nhost = \"h\"\nbase = \"/srv\"\n").expect("parses");
205 assert!(c.server.port.is_none());
206 assert_eq!(c.aliases.len(), 1);
207 }
208
209 #[test]
210 fn an_empty_file_is_fine() {
211 assert!(parse("").expect("parses").aliases.is_empty());
212 }
213
214 #[test]
217 fn a_misspelled_key_is_refused_rather_than_ignored() {
218 let e = parse("[server]\nsuffixx = \"dev\"\n").expect_err("refused");
219 assert!(
220 format!("{e:#}").contains("suffixx"),
221 "the error has to name the key: {e:#}"
222 );
223 assert!(
224 parse("[[alias]]\nname = \"a\"\nhost = \"h\"\nbase = \"/b\"\nextra = 1\n").is_err()
225 );
226 assert!(parse("[serverr]\nport = 1\n").is_err());
227 }
228
229 #[test]
232 fn asking_for_https_is_refused_while_it_does_not_exist() {
233 let e = parse("[server]\nscheme = \"https\"\n").expect_err("refused");
234 assert!(format!("{e:#}").contains("https"), "{e:#}");
235 assert!(parse("[server]\nscheme = \"http\"\n").is_ok());
236 }
237
238 #[test]
241 fn an_alias_from_a_file_is_checked_like_one_from_the_command_line() {
242 for bad in [
243 "[[alias]]\nname = \"Docs\"\nhost = \"h\"\nbase = \"/srv\"\n",
244 "[[alias]]\nname = \"a.b\"\nhost = \"h\"\nbase = \"/srv\"\n",
245 "[[alias]]\nname = \"docs\"\nhost = \"h\"\nbase = \"relative\"\n",
246 "[[alias]]\nname = \"docs\"\nhost = \"\"\nbase = \"/srv\"\n",
247 "[[alias]]\nname = \"-docs\"\nhost = \"h\"\nbase = \"/srv\"\n",
252 "[[alias]]\nname = \"docs-\"\nhost = \"h\"\nbase = \"/srv\"\n",
253 ] {
254 assert!(parse(bad).is_err(), "should have been refused:\n{bad}");
255 }
256 }
257
258 #[test]
259 fn a_missing_alias_field_is_refused() {
260 assert!(parse("[[alias]]\nname = \"docs\"\nhost = \"h\"\n").is_err());
261 }
262
263 fn alias(name: &str, host: &str) -> Alias {
264 Alias::new(name, host, "/srv").expect("a valid alias")
265 }
266
267 fn file_with(server: Server, aliases: Vec<Alias>) -> Config {
268 Config { server, aliases }
269 }
270
271 #[test]
273 fn the_command_line_wins_over_the_file() {
274 let file = file_with(
275 Server {
276 port: Some(1111),
277 suffix: Some("from-file".to_string()),
278 author: Some("from-file".to_string()),
279 scheme: None,
280 },
281 vec![],
282 );
283 let cli = Overrides {
284 port: Some(2222),
285 suffix: Some("from-cli".to_string()),
286 author: Some("from-cli".to_string()),
287 aliases: vec![],
288 };
289
290 let r = merge(cli, file, "fallback".to_string()).expect("merges");
291 assert_eq!(r.port, 2222);
292 assert_eq!(r.suffix, "from-cli");
293 assert_eq!(r.author, "from-cli");
294 }
295
296 #[test]
297 fn the_file_supplies_what_the_command_line_does_not() {
298 let file = file_with(
299 Server {
300 port: Some(1111),
301 suffix: Some("from-file".to_string()),
302 author: None,
303 scheme: None,
304 },
305 vec![],
306 );
307
308 let r = merge(Overrides::default(), file, "fallback".to_string()).expect("merges");
309 assert_eq!(r.port, 1111);
310 assert_eq!(r.suffix, "from-file");
311 assert_eq!(r.author, "fallback");
313 }
314
315 #[test]
316 fn what_neither_supplies_falls_back() {
317 let r = merge(
318 Overrides::default(),
319 file_with(Server::default(), vec![]),
320 "fallback".to_string(),
321 )
322 .expect("merges");
323 assert_eq!(r.port, DEFAULT_PORT);
324 assert_eq!(r.suffix, DEFAULT_SUFFIX);
325 }
326
327 #[test]
330 fn aliases_from_both_places_are_kept() {
331 let r = merge(
332 Overrides {
333 aliases: vec![alias("cli", "h")],
334 ..Overrides::default()
335 },
336 file_with(Server::default(), vec![alias("file", "h")]),
337 "fallback".to_string(),
338 )
339 .expect("merges");
340
341 let names: Vec<&str> = r.aliases.iter().map(Alias::name).collect();
342 assert_eq!(names, ["file", "cli"]);
343 }
344
345 #[test]
348 fn a_name_given_in_both_places_is_refused() {
349 let e = merge(
350 Overrides {
351 aliases: vec![alias("docs", "from-cli")],
352 ..Overrides::default()
353 },
354 file_with(Server::default(), vec![alias("docs", "from-file")]),
355 "fallback".to_string(),
356 )
357 .expect_err("refused");
358 assert!(format!("{e:#}").contains("docs"), "{e:#}");
359 }
360
361 #[test]
362 fn two_aliases_with_one_name_are_refused() {
363 let docs = |host: &str| Alias::new("docs", host, "/srv").expect("valid");
364 assert!(ensure_distinct(&[docs("a"), docs("b")]).is_err());
365 let other = Alias::new("other", "b", "/srv").expect("valid");
366 assert!(ensure_distinct(&[docs("a"), other]).is_ok());
367 }
368
369 #[test]
370 fn the_default_path_is_resolved_at_runtime() {
371 if let Some(p) = default_path() {
374 assert!(p.ends_with(Path::new("ssh-browser").join("config.toml")));
375 }
376 }
377}