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    /// What directory listings look like. See `crate::theme`.
28    ///
29    /// The starting value only: a theme chosen later from the dashboard is remembered
30    /// beside the token rather than written back here, because this file is hand-written
31    /// and a daemon that rewrote it would eventually lose somebody's comment.
32    pub theme: Option<String>,
33    /// Accepted so that asking for `https` is refused rather than ignored.
34    ///
35    /// The https mode is designed and not built. Of the three things that could happen to a
36    /// file asking for it, serving http anyway is the worst, because it looks like it worked.
37    pub scheme: Option<String>,
38}
39
40#[derive(Debug, Deserialize)]
41#[serde(deny_unknown_fields)]
42struct AliasEntry {
43    name: String,
44    host: String,
45    /// Omitted means the remote's home directory.
46    ///
47    /// The default that makes an alias worth writing at all: a host name and nothing
48    /// else. Resolved by asking the remote, in `Origin::bind`.
49    #[serde(default)]
50    base: Option<String>,
51}
52
53#[derive(Debug, Default, Deserialize)]
54#[serde(deny_unknown_fields)]
55struct Document {
56    #[serde(default)]
57    server: Server,
58    /// `[[alias]]` in the file, because each table is one alias; `aliases` here, because this
59    /// is all of them.
60    #[serde(default, rename = "alias")]
61    aliases: Vec<AliasEntry>,
62}
63
64#[derive(Debug)]
65pub struct Config {
66    pub server: Server,
67    pub aliases: Vec<Alias>,
68}
69
70/// Parse the text of a configuration file.
71///
72/// Separate from reading one so that every rule below is testable without a filesystem.
73pub fn parse(text: &str) -> Result<Config> {
74    let doc: Document = toml::from_str(text).context("reading the configuration")?;
75
76    if let Some(scheme) = doc.server.scheme.as_deref() {
77        ensure!(
78            scheme == "http",
79            "scheme = {scheme:?} is not supported yet; only \"http\" is. https needs a CA constrained to the suffix, which is designed but not built"
80        );
81    }
82
83    let mut aliases = Vec::with_capacity(doc.aliases.len());
84    for entry in &doc.aliases {
85        aliases.push(Alias::new(&entry.name, &entry.host, entry.base.as_deref())?);
86    }
87    Ok(Config {
88        server: doc.server,
89        aliases,
90    })
91}
92
93/// Read a configuration file.
94pub fn load(path: &Path) -> Result<Config> {
95    let text =
96        std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
97    parse(&text).with_context(|| format!("in {}", path.display()))
98}
99
100/// Where a configuration file is looked for when none was named.
101///
102/// Resolved at runtime rather than compiled in. The configuration directory and not the
103/// runtime one the control token uses: a token should disappear when the session does, and a
104/// configuration should not, so the two resolve differently on purpose.
105pub fn default_path() -> Option<PathBuf> {
106    let base = std::env::var_os("XDG_CONFIG_HOME")
107        .or_else(|| std::env::var_os("APPDATA"))
108        .or_else(|| std::env::var_os("LOCALAPPDATA"))
109        .map(PathBuf::from)
110        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))?;
111    Some(base.join("ssh-browser").join("config.toml"))
112}
113
114pub const DEFAULT_PORT: u16 = 7391;
115pub const DEFAULT_SUFFIX: &str = "ssh-browser";
116
117/// What the command line said, all of it optional because anything it leaves out the file may
118/// supply and anything neither supplies has a default.
119#[derive(Debug, Default)]
120pub struct Overrides {
121    pub port: Option<u16>,
122    pub suffix: Option<String>,
123    pub author: Option<String>,
124    pub aliases: Vec<Alias>,
125}
126
127/// What the daemon will actually run with.
128#[derive(Debug)]
129pub struct Resolved {
130    pub port: u16,
131    pub suffix: String,
132    pub author: String,
133    pub aliases: Vec<Alias>,
134}
135
136/// Fold the command line over the file.
137///
138/// Lives here rather than inside `main` so that it can be tested at all: the precedence is
139/// three `or`s and an `extend`, any one of which could be turned around without a single test
140/// noticing, and the result decides which host a URL reaches.
141///
142/// The command line wins, because it is what was typed for this run. Aliases are the
143/// exception and are added rather than replacing: naming one host on the command line should
144/// not silently drop the six in the file.
145pub fn merge(cli: Overrides, file: Config, default_author: String) -> Result<Resolved> {
146    let mut aliases = file.aliases;
147    aliases.extend(cli.aliases);
148    ensure_distinct(&aliases)?;
149
150    Ok(Resolved {
151        port: cli.port.or(file.server.port).unwrap_or(DEFAULT_PORT),
152        suffix: cli
153            .suffix
154            .or(file.server.suffix)
155            .unwrap_or_else(|| DEFAULT_SUFFIX.to_string()),
156        author: cli.author.or(file.server.author).unwrap_or(default_author),
157        aliases,
158    })
159}
160
161/// Refuse two aliases with the same name.
162///
163/// One would shadow the other in the session map, and which one survived would depend on the
164/// order they happened to be added in. A URL quietly pointing at a different host than the one
165/// configured is not something to settle by precedence.
166pub fn ensure_distinct(aliases: &[Alias]) -> Result<()> {
167    for (i, a) in aliases.iter().enumerate() {
168        if let Some(other) = aliases[..i].iter().find(|b| b.name() == a.name()) {
169            bail!(
170                "alias {:?} is defined twice: {} and {}",
171                a.name(),
172                other.host(),
173                a.host()
174            );
175        }
176    }
177    Ok(())
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    const FULL: &str = r#"
185[server]
186port = 7391
187suffix = "ssh-browser"
188author = "souta"
189
190[[alias]]
191name = "docs"
192host = "myhost"
193base = "/srv/docs"
194
195[[alias]]
196name = "cluster"
197host = "login-node"
198base = "/home/me/public_html"
199"#;
200
201    #[test]
202    fn a_full_file_parses() {
203        let c = parse(FULL).expect("parses");
204        assert_eq!(c.server.port, Some(7391));
205        assert_eq!(c.server.suffix.as_deref(), Some("ssh-browser"));
206        assert_eq!(c.server.author.as_deref(), Some("souta"));
207        assert_eq!(c.aliases.len(), 2);
208        assert_eq!(c.aliases[0].name(), "docs");
209        assert_eq!(c.aliases[1].base(), Some("/home/me/public_html"));
210    }
211
212    #[test]
213    fn a_file_of_only_aliases_is_fine() {
214        let c =
215            parse("[[alias]]\nname = \"docs\"\nhost = \"h\"\nbase = \"/srv\"\n").expect("parses");
216        assert!(c.server.port.is_none());
217        assert_eq!(c.aliases.len(), 1);
218    }
219
220    #[test]
221    fn an_empty_file_is_fine() {
222        assert!(parse("").expect("parses").aliases.is_empty());
223    }
224
225    /// Why `deny_unknown_fields` is on. A silently dropped key leaves the daemon running on
226    /// something nobody chose, indistinguishable from something somebody did.
227    #[test]
228    fn a_misspelled_key_is_refused_rather_than_ignored() {
229        let e = parse("[server]\nsuffixx = \"dev\"\n").expect_err("refused");
230        assert!(
231            format!("{e:#}").contains("suffixx"),
232            "the error has to name the key: {e:#}"
233        );
234        assert!(
235            parse("[[alias]]\nname = \"a\"\nhost = \"h\"\nbase = \"/b\"\nextra = 1\n").is_err()
236        );
237        assert!(parse("[serverr]\nport = 1\n").is_err());
238    }
239
240    /// Designed and not built. Serving http to a file that asked for https is the one outcome
241    /// that looks like success.
242    #[test]
243    fn asking_for_https_is_refused_while_it_does_not_exist() {
244        let e = parse("[server]\nscheme = \"https\"\n").expect_err("refused");
245        assert!(format!("{e:#}").contains("https"), "{e:#}");
246        assert!(parse("[server]\nscheme = \"http\"\n").is_ok());
247    }
248
249    /// The command line's rules, reached through the same constructor rather than written out
250    /// again here.
251    #[test]
252    fn an_alias_from_a_file_is_checked_like_one_from_the_command_line() {
253        for bad in [
254            "[[alias]]\nname = \"Docs\"\nhost = \"h\"\nbase = \"/srv\"\n",
255            "[[alias]]\nname = \"a.b\"\nhost = \"h\"\nbase = \"/srv\"\n",
256            "[[alias]]\nname = \"docs\"\nhost = \"h\"\nbase = \"relative\"\n",
257            "[[alias]]\nname = \"docs\"\nhost = \"\"\nbase = \"/srv\"\n",
258            // A leading or trailing hyphen is a label `guard::classify` refuses on every
259            // request. The constructor used to accept both, so the daemon connected over ssh,
260            // printed the route, listed it as a link — and then served a 403 to anyone who
261            // followed it.
262            "[[alias]]\nname = \"-docs\"\nhost = \"h\"\nbase = \"/srv\"\n",
263            "[[alias]]\nname = \"docs-\"\nhost = \"h\"\nbase = \"/srv\"\n",
264        ] {
265            assert!(parse(bad).is_err(), "should have been refused:\n{bad}");
266        }
267    }
268
269    #[test]
270    fn a_missing_alias_field_is_refused() {
271        for bad in [
272            "[[alias]]\nhost = \"h\"\nbase = \"/srv\"\n",
273            "[[alias]]\nname = \"docs\"\nbase = \"/srv\"\n",
274        ] {
275            assert!(parse(bad).is_err(), "should have been refused:\n{bad}");
276        }
277    }
278
279    /// The short form, and the one worth typing: a name and a host, nothing else.
280    ///
281    /// `None` rather than a path, because where the home directory is lives on the remote.
282    /// Filling it in here would mean this machine's home directory, which belongs to a
283    /// different computer.
284    #[test]
285    fn an_alias_without_a_base_means_the_home_directory() {
286        let c = parse("[[alias]]\nname = \"docs\"\nhost = \"h\"\n").expect("parses");
287        assert_eq!(c.aliases[0].base(), None);
288    }
289
290    fn alias(name: &str, host: &str) -> Alias {
291        Alias::new(name, host, Some("/srv")).expect("a valid alias")
292    }
293
294    fn file_with(server: Server, aliases: Vec<Alias>) -> Config {
295        Config { server, aliases }
296    }
297
298    /// What was typed for this run wins over what was written down for every run.
299    #[test]
300    fn the_command_line_wins_over_the_file() {
301        let file = file_with(
302            Server {
303                port: Some(1111),
304                suffix: Some("from-file".to_string()),
305                author: Some("from-file".to_string()),
306                theme: None,
307                scheme: None,
308            },
309            vec![],
310        );
311        let cli = Overrides {
312            port: Some(2222),
313            suffix: Some("from-cli".to_string()),
314            author: Some("from-cli".to_string()),
315            aliases: vec![],
316        };
317
318        let r = merge(cli, file, "fallback".to_string()).expect("merges");
319        assert_eq!(r.port, 2222);
320        assert_eq!(r.suffix, "from-cli");
321        assert_eq!(r.author, "from-cli");
322    }
323
324    #[test]
325    fn the_file_supplies_what_the_command_line_does_not() {
326        let file = file_with(
327            Server {
328                port: Some(1111),
329                suffix: Some("from-file".to_string()),
330                author: None,
331                theme: None,
332                scheme: None,
333            },
334            vec![],
335        );
336
337        let r = merge(Overrides::default(), file, "fallback".to_string()).expect("merges");
338        assert_eq!(r.port, 1111);
339        assert_eq!(r.suffix, "from-file");
340        // Neither said, so the default stands.
341        assert_eq!(r.author, "fallback");
342    }
343
344    #[test]
345    fn what_neither_supplies_falls_back() {
346        let r = merge(
347            Overrides::default(),
348            file_with(Server::default(), vec![]),
349            "fallback".to_string(),
350        )
351        .expect("merges");
352        assert_eq!(r.port, DEFAULT_PORT);
353        assert_eq!(r.suffix, DEFAULT_SUFFIX);
354    }
355
356    /// Added, not replaced. Naming one host on the command line must not drop the ones in the
357    /// file, which is the difference between an override and an amendment.
358    #[test]
359    fn aliases_from_both_places_are_kept() {
360        let r = merge(
361            Overrides {
362                aliases: vec![alias("cli", "h")],
363                ..Overrides::default()
364            },
365            file_with(Server::default(), vec![alias("file", "h")]),
366            "fallback".to_string(),
367        )
368        .expect("merges");
369
370        let names: Vec<&str> = r.aliases.iter().map(Alias::name).collect();
371        assert_eq!(names, ["file", "cli"]);
372    }
373
374    /// And a name in both places is a collision, because whichever won would depend on the
375    /// order they happened to be added in.
376    #[test]
377    fn a_name_given_in_both_places_is_refused() {
378        let e = merge(
379            Overrides {
380                aliases: vec![alias("docs", "from-cli")],
381                ..Overrides::default()
382            },
383            file_with(Server::default(), vec![alias("docs", "from-file")]),
384            "fallback".to_string(),
385        )
386        .expect_err("refused");
387        assert!(format!("{e:#}").contains("docs"), "{e:#}");
388    }
389
390    #[test]
391    fn two_aliases_with_one_name_are_refused() {
392        let docs = |host: &str| Alias::new("docs", host, Some("/srv")).expect("valid");
393        assert!(ensure_distinct(&[docs("a"), docs("b")]).is_err());
394        let other = Alias::new("other", "b", Some("/srv")).expect("valid");
395        assert!(ensure_distinct(&[docs("a"), other]).is_ok());
396    }
397
398    #[test]
399    fn the_default_path_is_resolved_at_runtime() {
400        // Whichever variable the platform offers, the tail is the same and nothing is
401        // compiled in.
402        if let Some(p) = default_path() {
403            assert!(p.ends_with(Path::new("ssh-browser").join("config.toml")));
404        }
405    }
406}