Skip to main content

ssh_browser/config/
mod.rs

1//! A configuration file, so that six hosts are not six command lines.
2//!
3//! Unknown keys are refused rather than ignored. A configuration file is exactly where a typo
4//! is invisible: `suffixx = "dev"` that is quietly dropped leaves the daemon running on a suffix
5//! nobody chose, and looking no different from one that was configured. Refusing costs one
6//! confusing start and saves an hour of confusion later.
7//!
8//! Aliases from a file are built through [`Alias::new`], the same constructor the command line
9//! uses. Two entry points and one set of rules is fine; two entry points and two copies of the
10//! rules is how the looser copy becomes the one that matters.
11
12use std::path::{Path, PathBuf};
13
14use anyhow::{Context, Result, bail, ensure};
15use serde::Deserialize;
16
17use crate::origin::Alias;
18
19/// The `[server]` table. Every key is optional: a file listing only aliases is a perfectly
20/// good file, and the defaults belong to the command line that owns them.
21#[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    /// Accepted so that asking for `https` is refused rather than ignored.
28    ///
29    /// The https mode is designed and not built. Of the three things that could happen to a
30    /// file asking for it, serving http anyway is the worst, because it looks like it worked.
31    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    /// `[[alias]]` in the file, because each table is one alias; `aliases` here, because this
48    /// is all of them.
49    #[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
59/// Parse the text of a configuration file.
60///
61/// Separate from reading one so that every rule below is testable without a filesystem.
62pub 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
82/// Read a configuration file.
83pub 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
89/// Where a configuration file is looked for when none was named.
90///
91/// Resolved at runtime rather than compiled in. The configuration directory and not the
92/// runtime one the control token uses: a token should disappear when the session does, and a
93/// configuration should not, so the two resolve differently on purpose.
94pub 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/// What the command line said, all of it optional because anything it leaves out the file may
107/// supply and anything neither supplies has a default.
108#[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/// What the daemon will actually run with.
117#[derive(Debug)]
118pub struct Resolved {
119    pub port: u16,
120    pub suffix: String,
121    pub author: String,
122    pub aliases: Vec<Alias>,
123}
124
125/// Fold the command line over the file.
126///
127/// Lives here rather than inside `main` so that it can be tested at all: the precedence is
128/// three `or`s and an `extend`, any one of which could be turned around without a single test
129/// noticing, and the result decides which host a URL reaches.
130///
131/// The command line wins, because it is what was typed for this run. Aliases are the
132/// exception and are added rather than replacing: naming one host on the command line should
133/// not silently drop the six in the file.
134pub 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
150/// Refuse two aliases with the same name.
151///
152/// One would shadow the other in the session map, and which one survived would depend on the
153/// order they happened to be added in. A URL quietly pointing at a different host than the one
154/// configured is not something to settle by precedence.
155pub 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    /// Why `deny_unknown_fields` is on. A silently dropped key leaves the daemon running on
215    /// something nobody chose, indistinguishable from something somebody did.
216    #[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    /// Designed and not built. Serving http to a file that asked for https is the one outcome
230    /// that looks like success.
231    #[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    /// The command line's rules, reached through the same constructor rather than written out
239    /// again here.
240    #[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            // A leading or trailing hyphen is a label `guard::classify` refuses on every
248            // request. The constructor used to accept both, so the daemon connected over ssh,
249            // printed the route, listed it as a link — and then served a 403 to anyone who
250            // followed it.
251            "[[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    /// What was typed for this run wins over what was written down for every run.
272    #[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        // Neither said, so the default stands.
312        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    /// Added, not replaced. Naming one host on the command line must not drop the ones in the
328    /// file, which is the difference between an override and an amendment.
329    #[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    /// And a name in both places is a collision, because whichever won would depend on the
346    /// order they happened to be added in.
347    #[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        // Whichever variable the platform offers, the tail is the same and nothing is
372        // compiled in.
373        if let Some(p) = default_path() {
374            assert!(p.ends_with(Path::new("ssh-browser").join("config.toml")));
375        }
376    }
377}