Skip to main content

ssh_browser/origin/
mod.rs

1//! The HTTP origin: what the browser actually talks to.
2//!
3//! The daemon answers two shapes of request on one loopback listener. A proxied
4//! request arrives in absolute form because a PAC sent it here, and its Host is
5//! `<alias>.<suffix>`; that is the path which gives the page a real origin under
6//! the URL the user typed. A direct request arrives by address and exists so the
7//! daemon is usable without touching proxy settings at all.
8//!
9//! Every request starts at the listing cache, not at the remote. One fresh listing
10//! of a parent directory answers four questions locally -- does this name exist, is
11//! it a directory, is it a symlink, and is the copy the browser already holds still
12//! current -- and only a body the cache does not hold costs a round trip.
13
14pub mod guard;
15pub mod mime;
16pub mod pac;
17pub mod range;
18
19use std::collections::{HashMap, HashSet};
20use std::net::SocketAddr;
21use std::sync::atomic::Ordering;
22use std::sync::{Arc, Mutex};
23use std::time::{Duration, Instant};
24
25use tokio::sync::RwLock;
26
27use anyhow::{Context, Result, bail, ensure};
28use bytes::Bytes;
29use http_body_util::Full;
30use hyper::header::{
31    ACCEPT_RANGES, CACHE_CONTROL, CONTENT_RANGE, CONTENT_SECURITY_POLICY, CONTENT_TYPE, ETAG, HOST,
32    HeaderName, HeaderValue, IF_NONE_MATCH, IF_RANGE, LOCATION, RANGE,
33};
34use hyper::server::conn::http1;
35use hyper::service::service_fn;
36use hyper::{Method, Request, Response, StatusCode};
37use hyper_util::rt::TokioIo;
38use tokio::net::TcpListener;
39
40use crate::cache::{self, Cache};
41use crate::control::{self, Token};
42use crate::fs::sftp::SftpFs;
43use crate::fs::{Entry, RangeReq, RemoteFs};
44use crate::prefetch;
45use crate::reachable;
46use crate::sftp::wire::Attrs;
47use crate::ssh_config;
48use crate::theme;
49use crate::tls;
50use rustls_pki_types::pem::PemObject;
51
52/// A file worth holding whole. Anything larger is served by range and not cached: a
53/// seek into a video must not pull the entire file, and holding one would evict every
54/// page body that makes a revisit free.
55const CACHE_WHOLE_MAX: u64 = 8 * 1024 * 1024;
56
57/// The request headers that change what is served rather than what is found.
58struct Conditions {
59    if_none_match: Option<String>,
60    range: Option<String>,
61    if_range: Option<String>,
62    /// Only ever consulted on the control path, which only a loopback request reaches.
63    control_token: Option<String>,
64    /// What the browser said about who started this request, if a browser started it.
65    ///
66    /// A forbidden header name, so a page can neither set it nor suppress it. See
67    /// `control::from_a_page`.
68    fetch_site: Option<String>,
69}
70
71/// One alias, checked.
72///
73/// The fields are private and [`Alias::new`] is the only way to make one, so there is no
74/// route into the daemon that skips these checks. That matters now that aliases can come
75/// from a configuration file as well as from the command line: two entry points and one
76/// validating constructor is fine, two entry points and two copies of the rules is how the
77/// looser copy becomes the one that gets used.
78#[derive(Debug)]
79pub struct Alias {
80    name: String,
81    host: String,
82    /// Where this alias is rooted, or `None` for the remote's home directory.
83    ///
84    /// Deferred rather than filled in with a guess, because the answer lives on the
85    /// remote. `~` is shell syntax and this transport never runs a shell; expanding it
86    /// here would produce this machine's home directory, which is a different computer's.
87    /// It is resolved once on connecting, by asking.
88    base: Option<String>,
89    named: Named,
90}
91
92/// Where an alias's name came from, which is what failing to open it means.
93///
94/// A name typed for this run is something the reader is standing there waiting on, so a daemon
95/// that started without it would be answering a different question than the one asked. A name
96/// in a config file is what they use in a week: a cluster in maintenance, a laptop off the
97/// VPN, an agent with no key loaded yet. Refusing to start until every one of those answers
98/// makes the daemon useless exactly when it is most wanted -- which is the rule `[[host]]` has
99/// followed all along, written down two hundred lines below this and not applied here.
100#[derive(Clone, Copy, PartialEq, Eq, Debug)]
101pub enum Named {
102    /// Typed on the command line: `ssh-browser serve docs=myhost`.
103    ForThisRun,
104    /// `[[alias]]` in the config file.
105    InTheFile,
106}
107
108impl Alias {
109    pub fn new(name: &str, host: &str, base: Option<&str>) -> Result<Self> {
110        ensure!(!host.is_empty(), "alias {name:?} has no ssh host");
111        // The alias becomes a hostname label, and this is the very function that decides
112        // whether an arriving request's label is acceptable. Asking it, rather than writing
113        // the rule out again, is what stops the two from disagreeing — and they already had:
114        // `-docs` satisfied the copy here and was then refused by `classify` on every single
115        // request, after the daemon had paid for the ssh connection and advertised the route.
116        ensure!(
117            guard::is_label(name),
118            "alias {name:?} must be lowercase letters, digits and hyphens, and may not start or end with a hyphen: it becomes a hostname label"
119        );
120        if let Some(base) = base {
121            ensure!(
122                is_base(base),
123                "alias {name:?} needs a base that is an absolute path, or `~`, or `~/` and a path under the home directory with no `..` in it, got {base:?}"
124            );
125        }
126        Ok(Self {
127            name: name.to_string(),
128            host: host.to_string(),
129            base: base.map(str::to_string),
130            named: Named::InTheFile,
131        })
132    }
133
134    /// The same alias, but typed on the command line rather than read out of a file.
135    ///
136    /// Only `parse_alias` calls this, and the asymmetry is the point: failing to open one of
137    /// these stops the daemon, and that is a thing to opt into at one call site rather than a
138    /// default every construction inherits.
139    pub fn for_this_run(self) -> Self {
140        Self {
141            named: Named::ForThisRun,
142            ..self
143        }
144    }
145
146    pub fn name(&self) -> &str {
147        &self.name
148    }
149
150    pub fn host(&self) -> &str {
151        &self.host
152    }
153
154    /// Where this alias is rooted, or `None` for the remote's home directory.
155    pub fn base(&self) -> Option<&str> {
156        self.base.as_deref()
157    }
158
159    pub fn named(&self) -> Named {
160        self.named
161    }
162}
163
164/// One line of the host list: a host ssh knows, and what this daemon is doing with it.
165#[derive(serde::Serialize)]
166struct KnownHost {
167    alias: String,
168    host: String,
169    #[serde(flatten)]
170    settings: ssh_config::Settings,
171    /// Whether this daemon has an alias for it right now.
172    ///
173    /// Named for what it is rather than "connected": what the extension needs to know is
174    /// whether a URL for this alias will answer, and that is a question about routing.
175    served: bool,
176    /// Whether this daemon opens it on its own, every run.
177    ///
178    /// Distinct from `served`, and the difference is the whole feature: `served` is about
179    /// now, `enabled` is about next time. A host opened by hand is served and not enabled; a
180    /// host enabled while its ssh was down is enabled and not served.
181    enabled: bool,
182    #[serde(skip_serializing_if = "Option::is_none")]
183    unresolved: Option<String>,
184}
185
186/// An alias being served right now.
187///
188/// A separate list from the hosts, because it answers a different question and the two do
189/// not line up: an alias need not be named after its host, so a session opened as
190/// `docs=myhost:/srv` matches no row in ssh_config at all. Reporting only the hosts would
191/// leave it being served and visible nowhere, which is the kind of invisible live state
192/// this daemon is supposed not to have.
193#[derive(serde::Serialize)]
194struct OpenAlias {
195    alias: String,
196    host: String,
197    base: String,
198    url: String,
199    /// Remote round trips this session has cost since it opened.
200    ///
201    /// The central claim of this daemon is a round-trip count, and until this was reported
202    /// the counter behind it existed only for unit tests against a fake remote — so nobody
203    /// could check the claim against their own host and their own site, which is the only
204    /// place it can be wrong in a way that matters. Read it twice and subtract.
205    ///
206    /// Monotonic and per session, so it resets when an alias is closed and reopened.
207    trips: u64,
208}
209
210#[derive(serde::Serialize)]
211struct KnownHosts {
212    open: Vec<OpenAlias>,
213    /// Declared aliases that are not connected, and why.
214    ///
215    /// The other half of `open`, and the half that used to not exist: before the daemon could
216    /// run without every alias connected, a name was either being served or the daemon was not
217    /// running. Now one can be neither, and a reader who is not told which is looking at the
218    /// silent failure this project says it does not have.
219    stalled: Vec<StalledAlias>,
220    hosts: Vec<KnownHost>,
221    unusable: Vec<ssh_config::Unusable>,
222    /// What TLS handshakes have done, under https.
223    ///
224    /// Here as well as on `hello` because this is the route the dashboard polls, and the answer
225    /// belongs where somebody is looking. `None` under http, where there are no handshakes.
226    #[serde(skip_serializing_if = "Option::is_none")]
227    tls: Option<control::Handshakes>,
228}
229
230/// How long a failed dial is remembered before another is attempted.
231///
232/// Not a backoff and not tuning. It is the smallest number that makes a remote page unable to
233/// choose how often this machine opens an ssh, while leaving a reload a real retry. See
234/// `session_for`.
235const DIAL_COOLDOWN: Duration = Duration::from_secs(3);
236
237/// What happened the last time an alias was dialled, and when.
238struct Trouble {
239    at: Instant,
240    why: String,
241}
242
243/// A declared alias with no connection behind it.
244#[derive(serde::Serialize)]
245struct StalledAlias {
246    alias: String,
247    host: String,
248    url: String,
249    /// What ssh said the last time this was tried, or `None` if it has not been tried since
250    /// it was stopped.
251    #[serde(skip_serializing_if = "Option::is_none")]
252    why: Option<String>,
253    /// Stopped from the dashboard rather than unreachable. A different thing to do about it:
254    /// one is fixed by a restart, the other by the host coming back.
255    stopped: bool,
256}
257
258/// Whether a configured base is one this daemon can resolve.
259///
260/// `~` is accepted here and nowhere else in the codebase. It is shell syntax, and this
261/// transport never runs a shell, so it is not passed through to anything: it is a
262/// stand-in for an answer only the remote has, substituted in `bind` once the session
263/// exists. Writing the home path out by hand is the alternative, and it means knowing
264/// another machine's account layout in order to name a directory you can already `cd` to.
265///
266/// `..` is refused rather than normalised. `~/..` quietly meaning the parent of the home
267/// directory is the kind of surprise that belongs in a base path least of all, since the
268/// base is the blast radius of every page served under it.
269fn is_base(base: &str) -> bool {
270    if base.starts_with('/') {
271        return true;
272    }
273    let Some(rest) = base.strip_prefix('~') else {
274        return false;
275    };
276    match rest {
277        "" => true,
278        rest => match rest.strip_prefix('/') {
279            Some(under) => {
280                !under.is_empty()
281                    && under
282                        .split('/')
283                        .all(|c| !c.is_empty() && c != "." && c != "..")
284            }
285            None => false,
286        },
287    }
288}
289
290/// The absolute base an alias is rooted at, asking the remote only when the answer needs
291/// asking.
292///
293/// Separated from `bind` because `bind` starts an ssh subprocess, which no test can, and
294/// this is the part of it with a decision in it.
295async fn resolve_base(base: Option<&str>, fs: &SftpFs) -> Result<String> {
296    let under = match base {
297        None | Some("~") => "",
298        Some(b) => match b.strip_prefix("~/") {
299            Some(under) => under,
300            // Already absolute. Nothing to ask the remote, and asking anyway would put an
301            // ssh round trip in front of every startup for no answer.
302            None => return Ok(b.to_string()),
303        },
304    };
305    let home = fs.home().await?;
306    let home = home.trim_end_matches('/');
307    // A home of `/` would otherwise produce `//work`, which is not the same path
308    // everywhere: POSIX leaves a leading double slash implementation-defined.
309    let home = if home.is_empty() { "" } else { home };
310    Ok(match under {
311        "" if home.is_empty() => "/".to_string(),
312        "" => home.to_string(),
313        under => format!("{home}/{under}"),
314    })
315}
316
317/// One alias's session, or nothing if no such alias is open.
318///
319/// The guard is dropped before returning, so nothing a caller does afterwards holds up
320/// another request.
321impl Origin {
322    async fn session(&self, alias: &str) -> Option<Arc<Session>> {
323        self.sessions.read().await.get(alias).cloned()
324    }
325
326    /// Where a site lives, as a reader would type it.
327    ///
328    /// One place, because the scheme is part of the origin: a URL built with the wrong one is not
329    /// a cosmetic slip but a link into a different origin than the one being served.
330    fn site_url(&self, alias: &str) -> String {
331        format!("{}://{alias}.{}/", self.scheme, self.suffix)
332    }
333
334    /// The root of the suffix: the list of sites, and the way back to it from inside one.
335    ///
336    /// Beside `site_url` and built the same way, because the two differ by a label and getting
337    /// that difference wrong means linking out of the daemon entirely.
338    fn home_url(&self) -> String {
339        format!("{}://{}/", self.scheme, self.suffix)
340    }
341
342    async fn alias_names(&self) -> Vec<String> {
343        let mut names: Vec<String> = self.sessions.read().await.keys().cloned().collect();
344        names.sort();
345        names
346    }
347
348    /// Remote round trips every open session has cost, added up.
349    async fn round_trips(&self) -> u64 {
350        self.sessions
351            .read()
352            .await
353            .values()
354            .map(|s| s.fs.round_trips())
355            .sum()
356    }
357}
358
359struct Session {
360    /// The ssh_config name this was reached by.
361    ///
362    /// Kept because an alias need not be named after its host — `docs=myhost:/srv` is one
363    /// of each — so without it the only thing that could be reported about a live session
364    /// is a name that appears nowhere in ssh_config.
365    host: String,
366    base: String,
367    fs: SftpFs,
368}
369
370pub struct Origin {
371    suffix: String,
372    port: u16,
373    /// The aliases being served right now.
374    ///
375    /// Behind a lock because the set changes while the daemon runs: a host is opened when
376    /// somebody picks it, not when the daemon starts. Starting six ssh sessions so that a
377    /// popup could list six hosts would make looking at the list cost more than using one.
378    ///
379    /// The values are `Arc`d so a request can take its session and let go of the lock.
380    /// Holding a read guard across the awaits a page costs would block every open for the
381    /// length of a remote read, and `Session` owns the ssh child — dropping one kills the
382    /// connection, so it cannot simply be cloned out.
383    sessions: RwLock<HashMap<String, Arc<Session>>>,
384    cache: Cache,
385    token: Token,
386    /// What a directory listing looks like.
387    ///
388    /// Behind a lock because it is chosen from the dashboard while the daemon runs, and it
389    /// is one setting for every alias: an origin that looked different from its neighbour
390    /// for no reason the reader chose would be a bug rather than a feature.
391    theme: RwLock<String>,
392    /// `http` or `https`. What a site's URL starts with, and whether `CONNECT` is answered.
393    ///
394    /// Held here rather than consulted from the config, because six places build a site URL and
395    /// a seventh that forgot would hand somebody a link to the wrong scheme — which under
396    /// https is not a cosmetic difference but a different origin.
397    scheme: String,
398    /// What TLS handshakes have done, which is the only portable way to learn whether the
399    /// authority is trusted.
400    ///
401    /// Nothing can ask a trust store the question directly and get a portable answer — but a
402    /// handshake *is* the answer. One that completes proves the browser accepted the
403    /// certificate; one that fails at this stage almost always means it did not. So the daemon
404    /// stops guessing and reports what happened.
405    handshakes: Handshakes,
406    /// Which extension is the dashboard, once one has said so.
407    ///
408    /// There is one dashboard, and it is the extension's. This is how the root of the suffix
409    /// knows where to send a browser instead of drawing a second one: the extension names
410    /// itself on `hello`, and the root hands that back as the address to go to.
411    ///
412    /// Only ever an id — never a token, never anything about a host — and only from a caller
413    /// that already holds the control token. Held rather than stored, so a daemon that has
414    /// not been connected to since it started simply has no dashboard to point at, which is
415    /// the truth at that moment.
416    dashboard: RwLock<Option<String>>,
417    /// Every alias this daemon serves, whether or not it is connected.
418    ///
419    /// Fixed after `bind`: aliases come from the command line and the config file, and neither
420    /// changes while the daemon runs. Hosts opened from the dashboard are a different set and
421    /// live in `reachable`.
422    declared: HashMap<String, Declared>,
423    /// Why an alias is not connected, from the last attempt.
424    ///
425    /// Kept so the reason survives the request that discovered it. A reader who opens a site
426    /// and gets `ssh: connect to host ... port 22: Network is unreachable` learns something;
427    /// one who opens the dashboard an hour later and sees a name with no explanation beside it
428    /// does not, and that is the same failure told twice.
429    trouble: RwLock<HashMap<String, Trouble>>,
430    /// Declared aliases stopped from the dashboard, for this run only.
431    ///
432    /// Without this, stopping one would be undone by the next request that reached it, and a
433    /// button that undoes itself is worse than no button. Not persisted, because for a
434    /// declared alias the persistent statement is the config file: a restart serves it again,
435    /// and the answer says so rather than leaving somebody to discover it.
436    stopped: RwLock<HashSet<String>>,
437    /// How long a failed dial is remembered. `DIAL_COOLDOWN` everywhere but in tests.
438    ///
439    /// A field rather than the constant read directly, so a test can set it to zero and watch
440    /// the dial happen again. Left as the constant, the only thing a test can observe is the
441    /// same answer twice -- which is what a latched failure looks like too, so the test would
442    /// pass either way and mean nothing.
443    cooldown: Duration,
444    /// The certificate resolver, when there is one.
445    ///
446    /// Held here as well as inside the TLS configuration, because `/_control/certificate` answers
447    /// what is served for a name — and that has to be the same certificate a handshake gets, from
448    /// the same cache, or the key pin it reports is for something nobody will ever see.
449    certificates: Option<Arc<PerName>>,
450    /// The serving certificate, when there is one.
451    ///
452    /// `None` under http, and that is what a `CONNECT` is refused by: there is no certificate to
453    /// terminate with, and answering `200` and then failing the handshake would tell the browser
454    /// the tunnel was fine.
455    tls: Option<Arc<rustls::ServerConfig>>,
456    /// Which `ssh_config` hosts this daemon opens without being asked.
457    ///
458    /// Never consulted while serving a request. It decides what happens at startup and what
459    /// a toggle does, and nothing else — see `crate::reachable` for why an "open it when a
460    /// request arrives" version would hand any web page the ability to start ssh sessions.
461    reachable: RwLock<reachable::Set>,
462}
463
464/// A listening socket and the origin that will answer on it.
465///
466/// Separate from [`Origin`] so that "the port is ours" is a thing the caller holds rather
467/// than something it hopes for. A caller cannot announce that the daemon is up before it
468/// is, because it has nothing to announce until this exists.
469pub struct Bound {
470    origin: Arc<Origin>,
471    listener: TcpListener,
472}
473
474/// What to tell the reader about a daemon that has just come up.
475///
476/// Returned beside [`Bound`] rather than reachable through it, and that separation is
477/// load-bearing rather than tidy. `Bound` owns the `Origin`, and the `Origin` owns the TLS
478/// configuration, and that owns a private key — so a banner line reached through `Bound` is a
479/// string a static analyser must treat as derived from the key, and it said so: CodeQL flagged
480/// both `eprintln!`s in `main` as cleartext logging of the certificate resolver.
481///
482/// It was wrong about the values — these are host names and paths — and right about the shape.
483/// Handing the caller strings that were never near the key makes the question unanswerable
484/// rather than answered.
485pub struct Startup {
486    routes: Vec<String>,
487    refused: Vec<String>,
488    trust: Option<String>,
489}
490
491impl Startup {
492    /// One line per alias, naming where it actually points.
493    ///
494    /// Only available once bound, which is the point: an alias rooted at the home directory has
495    /// no printable base until the remote has been asked.
496    pub fn routes(&self) -> &[String] {
497        &self.routes
498    }
499
500    /// Enabled hosts that would not connect, and what ssh said about each.
501    ///
502    /// Separate from `routes` so a caller cannot print them as though they were working. Empty
503    /// on an ordinary run; not an error, because the daemon is serving everything else.
504    pub fn refused(&self) -> &[String] {
505        &self.refused
506    }
507
508    /// What to say about the certificate, under https.
509    ///
510    /// `None` under http, where there is nothing to trust. Under https there is always
511    /// something to say, because nothing portable can tell whether the authority is *still*
512    /// trusted — and a daemon that advertised `https://...` and said nothing else left a
513    /// first-time reader at `ERR_CERT_AUTHORITY_INVALID` with no way to guess what to do. That
514    /// was measured by following the banner on a machine that had never run this.
515    pub fn trust(&self) -> Option<&str> {
516        self.trust.as_deref()
517    }
518}
519
520/// One alias this daemon is meant to serve, connected or not.
521///
522/// Separate from `sessions`, which is what is connected *now*, because they answer different
523/// questions and answering both with one map is what made a host that was asleep at breakfast
524/// a name the daemon had never heard of at lunch. The PAC routes this set; the dashboard lists
525/// it; a request against it opens a connection if there is not one already.
526struct Declared {
527    host: String,
528    base: Option<String>,
529    named: Named,
530    /// Held while dialling, so a page's subresources do not each start their own ssh.
531    ///
532    /// A first visit to an alias that is not connected is one navigation and then forty
533    /// requests for stylesheets, scripts and images, all within a few milliseconds and all
534    /// finding no session. Without this they would open forty ssh connections, thirty-nine of
535    /// which lose the race in `adopt` and are dropped -- which is the correct answer arrived at
536    /// by the most expensive route available.
537    dialling: tokio::sync::Mutex<()>,
538}
539
540impl Origin {
541    /// Take the port, then connect every alias.
542    ///
543    /// The port first, deliberately. It is the thing that fails immediately and for a
544    /// reason the operator can do something about — another daemon already has it — and a
545    /// handful of ssh handshakes paid before discovering that is time spent to learn
546    /// nothing.
547    ///
548    /// The aliases are connected here rather than on first use so that the first page
549    /// request does not also pay for an ssh handshake.
550    pub async fn bind(
551        aliases: Vec<Alias>,
552        hosts: reachable::Set,
553        suffix: String,
554        scheme: String,
555        port: u16,
556        token: Token,
557        theme: String,
558    ) -> Result<(Bound, Startup)> {
559        let addr = SocketAddr::from(([127, 0, 0, 1], port));
560        let listener = TcpListener::bind(addr)
561            .await
562            .with_context(|| format!("bind {addr}"))?;
563
564        // Held to the same rule the PAC is, and here rather than only there: a suffix the
565        // PAC would refuse is one no alias URL can ever match, so starting with it produces a
566        // daemon that listens and serves nothing.
567        ensure!(
568            pac::is_suffix(&suffix),
569            "suffix {suffix:?} must be lowercase letters, digits, hyphens and dots"
570        );
571        // Refused here rather than at the first listing: it is configuration, so it is
572        // refused where the rest of the configuration is.
573        theme::check(&theme)?;
574
575        // The certificate is made before the listener answers anything, so a misconfigured
576        // https mode fails at startup with a reason rather than on the first request with a
577        // handshake error. Under http nothing is generated at all: no key is written for a
578        // feature nobody asked for.
579        let (tls, certificates, trust) = match scheme.as_str() {
580            "http" => (None, None, None),
581            "https" => {
582                let (config, resolver, advice) = serving_config(&suffix)?;
583                (Some(Arc::new(config)), Some(resolver), Some(advice))
584            }
585            other => bail!("scheme {other:?} is not one this daemon serves; use http or https"),
586        };
587
588        let mut declared = HashMap::new();
589        for a in aliases {
590            // Checked where the map is built, so there is no way to reach one with a name
591            // silently missing from it. A caller may have checked earlier and should;
592            // `insert` returning the displaced value is the check that cannot be skipped.
593            ensure!(
594                declared
595                    .insert(
596                        a.name.clone(),
597                        Declared {
598                            host: a.host.clone(),
599                            base: a.base.clone(),
600                            named: a.named,
601                            dialling: tokio::sync::Mutex::new(()),
602                        },
603                    )
604                    .is_none(),
605                "alias {:?} is defined twice",
606                a.name
607            );
608        }
609
610        let origin = Arc::new(Self {
611            suffix,
612            scheme,
613            certificates,
614            tls,
615            port,
616            sessions: RwLock::new(HashMap::new()),
617            cache: Cache::default(),
618            token,
619            theme: RwLock::new(theme),
620            reachable: RwLock::new(hosts),
621            handshakes: Handshakes::default(),
622            dashboard: RwLock::new(None),
623            declared,
624            trouble: RwLock::new(HashMap::new()),
625            stopped: RwLock::new(HashSet::new()),
626            cooldown: DIAL_COOLDOWN,
627        });
628
629        // Opened here rather than on first use so that the first page request does not also
630        // pay for an ssh handshake -- and at once rather than in turn, because these are
631        // independent handshakes and in sequence three hosts cost the sum of three round trips
632        // before the daemon answers anything.
633        let (mut routes, refused_aliases) = origin.open_declared().await?;
634
635        // Enabled hosts, on the same terms now: reported and retried rather than fatal. They
636        // used to be the only ones treated that way, with the reason written out beside them,
637        // and the reason was never specific to them.
638        let (opened, mut refused) = origin.open_enabled().await;
639        routes.extend(opened);
640        refused.extend(refused_aliases);
641        refused.sort();
642
643        Ok((
644            Bound { origin, listener },
645            Startup {
646                routes,
647                refused,
648                trust,
649            },
650        ))
651    }
652}
653
654impl Bound {
655    pub async fn serve(self) -> Result<()> {
656        let Bound { origin, listener } = self;
657        let self_ = origin;
658
659        loop {
660            let (stream, _) = listener.accept().await?;
661            let me = Arc::clone(&self_);
662            tokio::spawn(async move {
663                let outer = Arc::clone(&me);
664                let service = service_fn(move |req: Request<hyper::body::Incoming>| {
665                    let me = Arc::clone(&outer);
666                    async move {
667                        if req.method() == Method::CONNECT {
668                            return Ok::<_, std::convert::Infallible>(me.tunnel(req));
669                        }
670                        Ok(me.handle(req).await)
671                    }
672                });
673                // Keep-alive is not a nicety here: a page pulls many subresources
674                // and a fresh connection each time would add a local handshake per
675                // request on top of the remote cost.
676                //
677                // `with_upgrades` is what lets the `CONNECT` above hand back the socket. Without
678                // it the response would be sent and the connection then closed, which reads to
679                // the browser as a proxy that accepted the tunnel and dropped it.
680                let _ = http1::Builder::new()
681                    .serve_connection(TokioIo::new(stream), service)
682                    .with_upgrades()
683                    .await;
684            });
685        }
686    }
687}
688
689impl Origin {
690    /// Answer `CONNECT <alias>.<suffix>:443`, then speak TLS inside the socket.
691    ///
692    /// This is the whole of the https mode's plumbing. The browser will not send an https request
693    /// to a proxy in the clear; it asks for a tunnel, and whatever answers inside that tunnel has
694    /// to present a certificate for the name it asked for. So the daemon is both the proxy and
695    /// the server on the other side of it.
696    ///
697    /// The target is checked before the tunnel is granted, and checked by the same `classify` that
698    /// guards every other request. A proxy that tunnels anywhere is an open proxy, and this one
699    /// listens on loopback where every process on the machine can reach it.
700    fn tunnel(self: &Arc<Self>, mut req: Request<hyper::body::Incoming>) -> Response<Full<Bytes>> {
701        // `CONNECT` puts the target in the authority, not the path: `CONNECT host:443 HTTP/1.1`.
702        let Some(authority) = req.uri().authority().map(ToString::to_string) else {
703            return fail(StatusCode::BAD_REQUEST, "CONNECT carries no authority");
704        };
705
706        // The guards run first, before anything about the https mode is consulted. Two reasons:
707        // they are the part that has to hold in either mode, and a daemon that answered "https is
708        // not configured" to a tunnel it would refuse anyway has told the caller something about
709        // its configuration in exchange for nothing.
710        //
711        // Only a name this daemon serves, and only its https port. Tunnelling anywhere else would
712        // turn a loopback listener into a way out of the machine for whatever can reach it —
713        // including a page, through its own subresource loads.
714        let (name, port) = match authority.rsplit_once(':') {
715            Some((name, port)) => (name, port),
716            None => (authority.as_str(), "443"),
717        };
718        if port != "443" {
719            return fail(
720                StatusCode::FORBIDDEN,
721                format!("CONNECT to port {port} is refused; only 443 is tunnelled"),
722            );
723        }
724        if guard::classify(name, "/", &self.suffix, self.port).is_err() {
725            return fail(
726                StatusCode::FORBIDDEN,
727                format!("{name:?} is not a name this daemon serves"),
728            );
729        }
730
731        let Some(config) = self.tls.clone() else {
732            return fail(
733                StatusCode::NOT_IMPLEMENTED,
734                concat!(
735                    "this daemon serves http; CONNECT needs the https mode and a certificate. ",
736                    "Set scheme = \"https\" and see `ssh-browser trust`.",
737                ),
738            );
739        };
740
741        let me = Arc::clone(self);
742        let upgrade = hyper::upgrade::on(&mut req);
743        tokio::spawn(async move {
744            let Ok(upgraded) = upgrade.await else {
745                return;
746            };
747            let acceptor = tokio_rustls::TlsAcceptor::from(config);
748            let tls = match acceptor.accept(TokioIo::new(upgraded)).await {
749                Ok(tls) => {
750                    me.handshakes.completed.fetch_add(1, Ordering::Relaxed);
751                    tls
752                }
753                Err(e) => {
754                    // Reported, and this used to be silent. The browser shows the reader
755                    // `ERR_CERT_AUTHORITY_INVALID` on the page — but the fix is a command, and
756                    // commands live in the terminal, which is where nothing was being said.
757                    //
758                    // Only the first, because a page pulls many subresources and every one of
759                    // them fails the same way: the second copy onwards is noise around the one
760                    // line that matters.
761                    if me.handshakes.failed.fetch_add(1, Ordering::Relaxed) == 0 {
762                        eprintln!();
763                        eprintln!("a browser refused the certificate: {e}");
764                        // Two calls rather than one string with a line continuation in it. Three
765                        // separate messages here have now shipped carrying the source file's own
766                        // indentation into the middle of a sentence.
767                        eprintln!("  almost always the local authority is not trusted yet.");
768                        eprintln!("  `ssh-browser trust` prints how to trust it.");
769                    }
770                    return;
771                }
772            };
773            // Inside the tunnel the requests are ordinary origin-form GETs carrying a `Host`,
774            // so they go through exactly the same handler as the http mode. That is the point of
775            // terminating here rather than proxying onwards: one request path, one set of
776            // guards, and https is a property of the socket rather than a second server.
777            let service = service_fn(move |req: Request<hyper::body::Incoming>| {
778                let me = Arc::clone(&me);
779                async move { Ok::<_, std::convert::Infallible>(me.handle(req).await) }
780            });
781            let _ = http1::Builder::new()
782                .serve_connection(TokioIo::new(tls), service)
783                .await;
784        });
785
786        // 200 with no body is what the proxy protocol wants; the socket becomes the tunnel.
787        Response::builder()
788            .status(StatusCode::OK)
789            .body(Full::new(Bytes::new()))
790            .unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR, "building the tunnel"))
791    }
792}
793
794impl Origin {
795    /// Generic over the body type so a test can drive it without constructing
796    /// hyper's `Incoming`, which only a real connection can produce.
797    pub async fn handle<B>(&self, req: Request<B>) -> Response<Full<Bytes>>
798    where
799        B: hyper::body::Body,
800        B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
801    {
802        let Some(host) = host_of(&req) else {
803            return fail(StatusCode::BAD_REQUEST, "request carries no Host");
804        };
805        let path = req.uri().path().to_string();
806        let cond = Conditions {
807            if_none_match: header(&req, IF_NONE_MATCH),
808            range: header(&req, RANGE),
809            if_range: header(&req, IF_RANGE),
810            control_token: req
811                .headers()
812                .get(control::TOKEN_HEADER)
813                .and_then(|v| v.to_str().ok())
814                .map(str::to_string),
815            fetch_site: req
816                .headers()
817                .get(control::FETCH_SITE_HEADER)
818                .and_then(|v| v.to_str().ok())
819                .map(str::to_string),
820        };
821        let method = req.method().clone();
822        let query = req.uri().query().map(str::to_string);
823
824        // The body is read for the control prefix and nowhere else. Reading it on every
825        // request would let any caller make the daemon hold memory it has no use for.
826        let control_body = if path.starts_with(control::PATH_PREFIX) {
827            match read_body(req.into_body()).await {
828                Ok(b) => b,
829                Err(e) => return fail(StatusCode::BAD_REQUEST, e),
830            }
831        } else {
832            Bytes::new()
833        };
834
835        match guard::classify(&host, &path, &self.suffix, self.port) {
836            // Refusing by Host is the DNS-rebinding defence, not a malfunction, so
837            // it says why rather than failing blankly.
838            Err(e) => fail(StatusCode::FORBIDDEN, format!("{e:#}")),
839            Ok(guard::Target::Direct { path }) => {
840                self.direct(&method, path, &cond, query.as_deref(), &control_body)
841                    .await
842            }
843            Ok(guard::Target::Alias { alias, path }) => {
844                self.alias(&method, alias, path, &cond, query.as_deref())
845                    .await
846            }
847            Ok(guard::Target::Index { path }) => self.index(&method, path).await,
848        }
849    }
850
851    /// The front door: `http(s)://<suffix>/`, listing what is being served.
852    ///
853    /// The same list the loopback listener shows at its root, on a real origin instead. That
854    /// difference is the point: from here a link to a site is a navigation to a *different*
855    /// origin, which is what those links mean. Reached through the loopback listener they are
856    /// links within one origin, because everything there shares one.
857    ///
858    /// Read-only and with no control API, like any alias origin. This is a page the browser can
859    /// be pointed at, so it is held to what a page may do.
860    async fn index(&self, method: &Method, path: &str) -> Response<Full<Bytes>> {
861        if !matches!(*method, Method::GET | Method::HEAD) {
862            return fail(
863                StatusCode::METHOD_NOT_ALLOWED,
864                format!("{method} is not allowed: this origin is read-only"),
865            );
866        }
867        // Only the root. There is nothing else here — every path belongs to a site, and a site
868        // is a different origin — so anything else is a 404 rather than a redirect that would
869        // guess which site was meant.
870        if path != "/" {
871            return fail(
872                StatusCode::NOT_FOUND,
873                format!(
874                    "{path:?} is not here: this origin is the list of sites, and each site is its own origin"
875                ),
876            );
877        }
878        // Only here. The loopback listener is what a browser without the extension is told
879        // to use, so sending *it* into an extension page would be sending it somewhere it
880        // cannot go -- and that listener is the one place the fallback is the real answer.
881        let to = self.dashboard.read().await.clone();
882        own_page(self.alias_index(to.as_deref()).await)
883    }
884
885    async fn direct(
886        &self,
887        method: &Method,
888        path: &str,
889        cond: &Conditions,
890        query: Option<&str>,
891        body: &[u8],
892    ) -> Response<Full<Bytes>> {
893        // Reachable only from a loopback Host, which `guard::classify` has already
894        // separated from alias requests. An alias page cannot arrive here.
895        if path.starts_with(control::PATH_PREFIX) {
896            // First, and separately from the token: no page reaches this API at all,
897            // whatever it has got hold of.
898            if control::from_a_page(cond.fetch_site.as_deref()) {
899                return control::text(
900                    StatusCode::FORBIDDEN,
901                    "the control API is not reachable from a page",
902                );
903            }
904            // The handshake, and the only route that does not need the token -- it is
905            // where the token comes from. Handing it over is safe precisely because the
906            // line above has already established that nothing page-shaped is asking, and
907            // a caller that is not a browser at all could read the token file anyway.
908            //
909            // This is what removes the paste. An extension cannot read a file, so before
910            // this the first run meant copying sixty-four hex characters out of a terminal.
911            if method == Method::GET && control::route_of(path) == "token" {
912                return control::text(StatusCode::OK, self.token.as_str());
913            }
914            // Every other control route goes through the gate, and there is no way past
915            // it. The gate repeats the page check rather than trusting the branch above to
916            // have run, so that no future route can reach it having skipped one.
917            if let Some(refusal) = control::gate(
918                method,
919                cond.fetch_site.as_deref(),
920                cond.control_token.as_deref(),
921                &self.token,
922            ) {
923                return refusal;
924            }
925            return self.control(method, path, query, body).await;
926        }
927
928        if path == "/proxy.pac" {
929            return match pac::script(&self.suffix, self.port) {
930                Ok(body) => plain_ok("application/x-ns-proxy-autoconfig", Bytes::from(body)),
931                Err(e) => fail(StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")),
932            };
933        }
934
935        let rest = path.trim_start_matches('/');
936        if rest.is_empty() {
937            return own_page(self.alias_index(None).await);
938        }
939
940        let (alias, sub) = rest.split_once('/').unwrap_or((rest, ""));
941        self.alias(method, alias, &format!("/{sub}"), cond, query)
942            .await
943    }
944
945    async fn alias(
946        &self,
947        method: &Method,
948        alias: &str,
949        path: &str,
950        cond: &Conditions,
951        query: Option<&str>,
952    ) -> Response<Full<Bytes>> {
953        // The alias origin is read-only, and says so rather than quietly serving a POST
954        // as if it were a GET. The shape of this answer is part of the boundary: there
955        // is no write path on this origin and there will not be one. Writes go through
956        // the control API, which a page served from here cannot reach.
957        if !matches!(*method, Method::GET | Method::HEAD) {
958            return fail(
959                StatusCode::METHOD_NOT_ALLOWED,
960                format!("{method} is not allowed: this origin is read-only"),
961            );
962        }
963
964        // Twice at most, and the second time is not a policy but a consequence.
965        //
966        // A connection can be gone without this process knowing yet: the driver task learns
967        // of it when its own read fails, which is *after* a request has been handed down the
968        // pipe. So a request that arrives in that window is sent into a connection that looks
969        // alive, and comes back `sftp session closed before replying`. Checking liveness first
970        // cannot close that window -- nothing can, from this side.
971        //
972        // What closes it is checking afterwards. If the answer failed and the connection is
973        // now known to be gone, that was not the remote saying no, it was the pipe, and the
974        // request has not been answered at all. Measured against a real host by killing the
975        // ssh under a live daemon: without this a reload returned 502 and only the one after
976        // it succeeded, which is a retry that needs to be done twice to count as one.
977        for attempt in 0..2 {
978            let session = match self.session_for(alias).await {
979                Reached::Open(session) => session,
980                Reached::Unknown => {
981                    return fail(StatusCode::NOT_FOUND, format!("no alias named {alias:?}"));
982                }
983                Reached::Down(why) => {
984                    return fail(
985                        StatusCode::BAD_GATEWAY,
986                        format!(
987                            "{alias} is not connected: {why}\n\n\
988                             Reload to try again. Nothing needs restarting: the alias is still \
989                             served, and the next request opens the ssh afresh. The line above is \
990                             what the host said when asked just now."
991                        ),
992                    );
993                }
994                Reached::Stopped => {
995                    return fail(
996                        StatusCode::SERVICE_UNAVAILABLE,
997                        format!(
998                            "{alias} was stopped from the dashboard.\n\n\
999                             It is in this daemon's configuration, so restarting serves it again. \
1000                             Reloading will not: a stop the next request undid would not be a stop."
1001                        ),
1002                    );
1003                }
1004            };
1005
1006            let res = self.serve_alias(&session, alias, path, cond, query).await;
1007            if attempt == 0 && res.status().is_server_error() && !session.fs.is_alive() {
1008                self.evict(alias, &session).await;
1009                continue;
1010            }
1011            return res;
1012        }
1013        unreachable!("the loop returns on its last pass")
1014    }
1015
1016    /// Drop a session, but only the one the caller was holding.
1017    ///
1018    /// Compared by pointer rather than by name: another request may have re-dialled while
1019    /// this one was failing, and removing that fresh session by name would start the same
1020    /// dance over for whoever holds it.
1021    async fn evict(&self, alias: &str, held: &Arc<Session>) {
1022        let mut sessions = self.sessions.write().await;
1023        if sessions.get(alias).is_some_and(|s| Arc::ptr_eq(s, held)) {
1024            sessions.remove(alias);
1025        }
1026    }
1027
1028    /// One attempt at serving, over a connection the caller has already obtained.
1029    async fn serve_alias(
1030        &self,
1031        session: &Session,
1032        alias: &str,
1033        path: &str,
1034        cond: &Conditions,
1035        query: Option<&str>,
1036    ) -> Response<Full<Bytes>> {
1037        let resolved = match guard::resolve(&session.base, path) {
1038            Ok(p) => p,
1039            Err(e) => return fail(StatusCode::FORBIDDEN, format!("{e:#}")),
1040        };
1041
1042        // `?ls` asks for one level of the tree, and it asks that of a directory holding an
1043        // `index.html` as much as of any other. Resolving to the index first meant the tree
1044        // fetched a whole document and spliced it into itself: measured against a gallery on
1045        // souta's host, 15 kB of rendered page arriving where 468 bytes of `<ul>` belonged,
1046        // and a file list with somebody's figures sitting inside it.
1047        //
1048        // Answered here rather than inside `autoindex_of`, because by the time control reaches
1049        // that function the question has already been turned into a different one.
1050        let wants_list = query == Some("ls");
1051        let wants_dir = path.ends_with('/');
1052        if wants_dir && wants_list {
1053            return self
1054                .autoindex_of(session, alias, path, &resolved, query)
1055                .await;
1056        }
1057        let file = if wants_dir {
1058            format!("{resolved}/index.html")
1059        } else {
1060            resolved.clone()
1061        };
1062
1063        // Every component between the alias base and the file, base first. The base
1064        // itself is not checked: it is what the operator configured, and no request
1065        // can change it.
1066        let chain = components(&session.base, &file);
1067        if chain.is_empty() {
1068            return self
1069                .autoindex_of(session, alias, path, &resolved, query)
1070                .await;
1071        }
1072        let last = chain.len() - 1;
1073
1074        // Settled before anything is asked of the remote, because unlike a symlink this needs
1075        // nothing from the remote to decide — and deciding it later would mean asking the
1076        // remote to open `.ssh` in order to then refuse it.
1077        if let Some((_, name)) = chain.iter().find(|(_, n)| hidden(n)) {
1078            return fail(
1079                StatusCode::FORBIDDEN,
1080                format!("refusing {name}: names beginning with a dot are not served"),
1081            );
1082        }
1083
1084        let held = match self.listings_along(session, &chain).await {
1085            Ok(held) => held,
1086            // Whatever ssh said, rather than this daemon's word for not knowing.
1087            Err((at, why)) => {
1088                return fail(
1089                    StatusCode::BAD_GATEWAY,
1090                    format!("{path}: listing {at} failed: {why}"),
1091                );
1092            }
1093        };
1094
1095        // Symlinks are settled before anything else, so the answer cannot depend on
1096        // whether the target happens to exist: a symlink is refused either way, and
1097        // checking it separately is what lets the write path share exactly this rule.
1098        if let Some(at) = first_symlink(&held, &chain) {
1099            return fail(
1100                StatusCode::FORBIDDEN,
1101                format!("refusing symlink at {at} (its target is not checked)"),
1102            );
1103        }
1104
1105        let mut found_last = None;
1106        for (i, (dir, name)) in chain.iter().enumerate() {
1107            let Some(attrs) = attrs_in(&held, dir, name) else {
1108                // Absent. For a directory request that only means there is no
1109                // index.html, so fall through to a listing of the directory itself.
1110                if i == last && wants_dir {
1111                    return self
1112                        .autoindex_of(session, alias, path, &resolved, query)
1113                        .await;
1114                }
1115                return fail(StatusCode::NOT_FOUND, format!("not found: {path}"));
1116            };
1117
1118            if i < last && !attrs.is_dir() {
1119                return fail(
1120                    StatusCode::NOT_FOUND,
1121                    format!("{path}: {dir}/{name} is not a directory"),
1122                );
1123            }
1124            if i == last {
1125                found_last = Some(attrs);
1126            }
1127        }
1128        let attrs = found_last.expect("the walk assigns on its final iteration");
1129
1130        if attrs.is_dir() {
1131            if wants_dir {
1132                // `<dir>/index.html` is itself a directory. Fall back to a listing.
1133                return self
1134                    .autoindex_of(session, alias, path, &resolved, query)
1135                    .await;
1136            }
1137            // Without the trailing slash every relative link on the page below
1138            // would resolve one level too high.
1139            return redirect(&format!("{path}/"));
1140        }
1141
1142        let tag = cache::etag(&attrs);
1143
1144        // The conditional GET never leaves this process: the validator came from the
1145        // cached listing, so a browser already holding the current copy is answered
1146        // with zero remote round trips. That is invariant 2.
1147        if let (Some(tag), Some(header)) = (tag.as_deref(), cond.if_none_match.as_deref())
1148            && cache::etag_matches(header, tag)
1149        {
1150            return not_modified(tag);
1151        }
1152
1153        // Size comes from the listing, which is what makes a range answerable without
1154        // first fetching the file to discover how long it is.
1155        let size = attrs.size.unwrap_or(0);
1156        let wanted = match cond.range.as_deref() {
1157            Some(header) => range::resolve(header, cond.if_range.as_deref(), size),
1158            None => range::Resolved::Whole,
1159        };
1160        if wanted == range::Resolved::Unsatisfiable {
1161            return unsatisfiable(size);
1162        }
1163
1164        // A body already held answers a range by slicing, with no round trip at all.
1165        if let Some(body) = self.cache.body(&file, &attrs) {
1166            return respond(&file, body, tag.as_deref(), &wanted, size);
1167        }
1168
1169        // Too large to hold: fetch only what was asked for. This branch is what makes
1170        // seeking in a video possible. Without it a seek pulls the whole file, and
1171        // holding that file would evict every page body that makes a revisit free.
1172        if let range::Resolved::Part { start, end } = wanted
1173            && size > CACHE_WHOLE_MAX
1174        {
1175            let req = RangeReq {
1176                path: file.clone(),
1177                offset: start,
1178                len: end - start + 1,
1179            };
1180            let mut got = session.fs.read_ranges(std::slice::from_ref(&req)).await;
1181            return match got.pop() {
1182                Some(Ok(body)) => partial(
1183                    mime::guess(&file),
1184                    Bytes::from(body),
1185                    tag.as_deref(),
1186                    start,
1187                    end,
1188                    size,
1189                ),
1190                Some(Err(e)) => {
1191                    self.cache.forget_listing(&chain[last].0);
1192                    fail(StatusCode::NOT_FOUND, format!("{path}: {e:#}"))
1193                }
1194                None => fail(
1195                    StatusCode::INTERNAL_SERVER_ERROR,
1196                    "read_ranges returned no result",
1197                ),
1198            };
1199        }
1200
1201        // The length the listing already gave is what turns this from a poll into one round
1202        // trip. `read_batch` cannot know how long a file is, so it asks for 32 KiB at a time
1203        // until a short read tells it to stop: one round trip per chunk index. `read_ranges`
1204        // is handed the length and issues every chunk before awaiting any, so the file costs
1205        // one however long it is. The prefetcher has always done this; the request a reader
1206        // actually waits on did not.
1207        //
1208        // It is the direct path that needs it most, because the files that reach it are the
1209        // ones the prefetcher could not have warmed: the page itself, which has to be read
1210        // before it can be scanned, and anything a script fetches at runtime. Measured
1211        // against real Documenter output — a 700 KB `index.html` and a 2 MB
1212        // `search_index.js`, neither of them visible to an HTML scan — the page cost 95
1213        // remote round trips before this and 26 after. What remains is listings, which
1214        // expire before a slow page has finished loading; that is a separate problem.
1215        let mut got = match size {
1216            0 => session.fs.read_batch(std::slice::from_ref(&file)).await,
1217            size => {
1218                let req = RangeReq {
1219                    path: file.clone(),
1220                    offset: 0,
1221                    len: size,
1222                };
1223                let mut ranged = session.fs.read_ranges(std::slice::from_ref(&req)).await;
1224                match ranged.pop() {
1225                    Some(Ok(body)) if body.len() as u64 == size => vec![Ok(body)],
1226                    // Anything else means the listing no longer describes the file, or the
1227                    // read failed. Falling back to the poll rather than answering with what
1228                    // arrived: a body shorter than the length it is served with is precisely
1229                    // the silent truncation this daemon must not produce, and the poll finds
1230                    // the real length or the real error. It costs a round trip in a case that
1231                    // is a race, and nothing in the case that is not.
1232                    _ => session.fs.read_batch(std::slice::from_ref(&file)).await,
1233                }
1234            }
1235        };
1236        match got.pop() {
1237            Some(Ok(body)) => {
1238                let body = Bytes::from(body);
1239                self.cache.put_body(&file, &attrs, body.clone());
1240                // Before answering, not after. The browser will ask for this page's
1241                // subresources six at a time, and each wave it has to discover is a round
1242                // trip; fetching them here costs one and makes the waves cache hits. Waiting
1243                // also makes the invariant a guarantee rather than a race with the browser.
1244                if mime::guess(&file).starts_with("text/html") {
1245                    self.warm_subresources(session, path, &body).await;
1246                }
1247                respond(&file, body, tag.as_deref(), &wanted, size)
1248            }
1249            // The listing promised this file and the remote refused it, so the listing
1250            // is wrong. Holding it for the rest of its TTL would repeat the same wrong
1251            // answer.
1252            Some(Err(e)) => {
1253                self.cache.forget_listing(&chain[last].0);
1254                fail(StatusCode::NOT_FOUND, format!("{path}: {e:#}"))
1255            }
1256            None => fail(
1257                StatusCode::INTERNAL_SERVER_ERROR,
1258                "read_batch returned no result",
1259            ),
1260        }
1261    }
1262
1263    async fn control(
1264        &self,
1265        method: &Method,
1266        path: &str,
1267        query: Option<&str>,
1268        body: &[u8],
1269    ) -> Response<Full<Bytes>> {
1270        match (method, control::route_of(path)) {
1271            (&Method::GET, "hello") => {
1272                // Where the dashboard is, which the root of the suffix needs and cannot
1273                // discover: a page cannot enumerate extensions, so the extension says.
1274                if let Some(id) = extension_id(query) {
1275                    *self.dashboard.write().await = Some(id);
1276                }
1277                let aliases = self.alias_names().await;
1278                control::hello(
1279                    &aliases,
1280                    &self.suffix,
1281                    &self.scheme,
1282                    self.round_trips().await,
1283                    self.tls.is_some().then(|| control::Handshakes {
1284                        completed: self.handshakes.completed.load(Ordering::Relaxed),
1285                        failed: self.handshakes.failed.load(Ordering::Relaxed),
1286                    }),
1287                )
1288            }
1289            (&Method::GET, "hosts") => self.list_hosts().await,
1290            (&Method::POST, "open") => self.open_host(body).await,
1291            (&Method::POST, "close") => self.close_alias(body).await,
1292            (&Method::POST, "enabled") => self.set_enabled(body).await,
1293            (&Method::GET, "certificate") => self.show_certificate(query),
1294            (&Method::GET, "theme") => self.show_theme().await,
1295            (&Method::POST, "theme") => self.set_theme(body).await,
1296            (&Method::GET, route) => {
1297                control::text(StatusCode::NOT_FOUND, format!("no control route {route:?}"))
1298            }
1299            (_, route) => control::text(
1300                StatusCode::METHOD_NOT_ALLOWED,
1301                format!("{method} is not allowed on {route:?}"),
1302            ),
1303        }
1304    }
1305
1306    /// `GET /_control/hosts`
1307    ///
1308    /// What ssh already knows how to reach, which is the list the extension offers. It
1309    /// comes from `~/.ssh/config` rather than from this daemon's own configuration,
1310    /// because a host you can already `ssh` to is a host you should be able to open
1311    /// without writing it down a second time.
1312    ///
1313    /// Answering this connects to nothing. It is a list of what could be opened, and a
1314    /// daemon that opened six ssh sessions to answer a popup would make looking at the
1315    /// list cost more than using it.
1316    async fn list_hosts(&self) -> Response<Full<Bytes>> {
1317        let found = match ssh_config::read() {
1318            Ok(found) => found,
1319            Err(e) => {
1320                return control::text(
1321                    StatusCode::INTERNAL_SERVER_ERROR,
1322                    format!("reading ssh_config: {e:#}"),
1323                );
1324            }
1325        };
1326
1327        // Every `ssh -G` at once. One subprocess per host is cheap, but run in sequence
1328        // the list would take the sum of them, and this is the request a reader waits on
1329        // before they can do anything at all.
1330        let described: Vec<_> = found
1331            .hosts
1332            .iter()
1333            .map(|h| {
1334                let host = h.host.clone();
1335                tokio::spawn(async move { ssh_config::describe(&host).await })
1336            })
1337            .collect();
1338
1339        let open = {
1340            let sessions = self.sessions.read().await;
1341            let mut open: Vec<OpenAlias> = sessions
1342                .iter()
1343                .map(|(alias, s)| OpenAlias {
1344                    alias: alias.clone(),
1345                    host: s.host.clone(),
1346                    base: s.base.clone(),
1347                    url: self.site_url(alias),
1348                    trips: s.fs.round_trips(),
1349                })
1350                .collect();
1351            open.sort_by(|a, b| a.alias.cmp(&b.alias));
1352            open
1353        };
1354        // Read once, before the loop, rather than taking the lock per host.
1355        let enabled: Vec<String> = self
1356            .reachable
1357            .read()
1358            .await
1359            .enabled()
1360            .map(|h| h.name.clone())
1361            .collect();
1362        let mut hosts = Vec::with_capacity(found.hosts.len());
1363        for (h, task) in found.hosts.iter().zip(described) {
1364            // A host ssh cannot describe is still listed, with the reason attached.
1365            // Dropping it would make a misconfigured host look like one that is not in
1366            // the file, and those have different fixes.
1367            let (settings, unresolved) = match task.await {
1368                Ok(Ok(settings)) => (settings, None),
1369                Ok(Err(e)) => (ssh_config::Settings::default(), Some(format!("{e:#}"))),
1370                Err(e) => (ssh_config::Settings::default(), Some(e.to_string())),
1371            };
1372            hosts.push(KnownHost {
1373                alias: h.alias.clone(),
1374                host: h.host.clone(),
1375                settings,
1376                served: open.iter().any(|o| o.alias == h.alias),
1377                enabled: enabled.iter().any(|name| name == &h.alias),
1378                unresolved,
1379            });
1380        }
1381        // Every declared alias that is not in `open`. Read from `declared` rather than from
1382        // the difference of two lists somewhere else, so an alias cannot be absent from both.
1383        let (trouble, stopped) = (self.trouble.read().await, self.stopped.read().await);
1384        let mut stalled: Vec<StalledAlias> = self
1385            .declared
1386            .iter()
1387            .filter(|(name, _)| !open.iter().any(|o| &&o.alias == name))
1388            .map(|(name, d)| StalledAlias {
1389                alias: name.clone(),
1390                host: d.host.clone(),
1391                url: self.site_url(name),
1392                why: trouble.get(name).map(|t| t.why.clone()),
1393                stopped: stopped.contains(name),
1394            })
1395            .collect();
1396        stalled.sort_by(|a, b| a.alias.cmp(&b.alias));
1397        drop((trouble, stopped));
1398
1399        control::json(&KnownHosts {
1400            open,
1401            stalled,
1402            hosts,
1403            unusable: found.unusable,
1404            tls: self.tls.is_some().then(|| control::Handshakes {
1405                completed: self.handshakes.completed.load(Ordering::Relaxed),
1406                failed: self.handshakes.failed.load(Ordering::Relaxed),
1407            }),
1408        })
1409    }
1410
1411    /// `POST /_control/open` -- start serving one of the hosts ssh already knows.
1412    ///
1413    /// This is what replaces configuring an alias before you can look at anything. The
1414    /// host is picked from the list, the daemon connects, and the URL comes back.
1415    ///
1416    /// **Only a host named in ssh_config can be opened.** Not because the token is
1417    /// insufficient, but because "ssh to an arbitrary host on request" is a larger
1418    /// primitive than this needs to be, and the list the extension offers is already the
1419    /// menu. A host that is not on it is a config change, which is a deliberate act.
1420    async fn open_host(&self, body: &[u8]) -> Response<Full<Bytes>> {
1421        #[derive(serde::Deserialize)]
1422        #[serde(deny_unknown_fields)]
1423        struct Ask {
1424            host: String,
1425            /// Absolute, or `~`, or `~/path`. Absent means the home directory.
1426            #[serde(default)]
1427            base: Option<String>,
1428        }
1429
1430        let ask: Ask = match serde_json::from_slice(body) {
1431            Ok(ask) => ask,
1432            Err(e) => {
1433                return control::text(
1434                    StatusCode::BAD_REQUEST,
1435                    format!("open needs a JSON body naming a host: {e}"),
1436                );
1437            }
1438        };
1439
1440        let found = match ssh_config::read() {
1441            Ok(found) => found,
1442            Err(e) => {
1443                return control::text(
1444                    StatusCode::INTERNAL_SERVER_ERROR,
1445                    format!("reading ssh_config: {e:#}"),
1446                );
1447            }
1448        };
1449        // Matched against ssh_config rather than trusted, and matched case-insensitively
1450        // because that is how hostnames compare: the extension shows `panza` and the file
1451        // says `Panza`.
1452        let Some(known) = found
1453            .hosts
1454            .iter()
1455            .find(|h| h.host.eq_ignore_ascii_case(&ask.host) || h.alias == ask.host)
1456        else {
1457            return control::text(
1458                StatusCode::NOT_FOUND,
1459                format!("{:?} is not a host in your ssh_config", ask.host),
1460            );
1461        };
1462
1463        let alias = match Alias::new(&known.alias, &known.host, ask.base.as_deref()) {
1464            Ok(alias) => alias,
1465            Err(e) => return control::text(StatusCode::BAD_REQUEST, format!("{e:#}")),
1466        };
1467
1468        // Already open is an answer, not an error: two tabs asking at once should both
1469        // get the URL. A *different* base is refused, though. Reconnecting under one
1470        // would change what an origin means underneath any page already open in it,
1471        // which is the one thing an origin must not do.
1472        if let Some(open) = self.session(&known.alias).await {
1473            // Asking for no base is asking for no particular one, so an alias already
1474            // open is simply the answer. The popup relies on this: it opens a host by
1475            // naming it, and a host the config file already roots somewhere would
1476            // otherwise answer a plain click with a conflict about a base nobody asked for.
1477            let Some(asked) = alias.base() else {
1478                return self.opened(&known.alias, &known.host, &open.base);
1479            };
1480            // Resolved against the session that is already there, rather than compared as
1481            // written. `~/work` and `/home/souta/work` are the same base, and a check that
1482            // could not tell would either refuse an identical request or -- worse -- accept
1483            // a different one, handing back a URL rooted somewhere the caller did not ask
1484            // for. The round trip is paid on a path that is not a page load.
1485            let wanted = match resolve_base(Some(asked), &open.fs).await {
1486                Ok(base) => base,
1487                Err(e) => {
1488                    return control::text(
1489                        StatusCode::BAD_GATEWAY,
1490                        format!("working out where to root {}: {e:#}", known.alias),
1491                    );
1492                }
1493            };
1494            if wanted != open.base {
1495                return control::text(
1496                    StatusCode::CONFLICT,
1497                    format!(
1498                        "{} is already open at {}, and {} is not the same place; a second base would change what that origin means underneath any page open in it",
1499                        known.alias, open.base, wanted
1500                    ),
1501                );
1502            }
1503            return self.opened(&known.alias, &known.host, &open.base);
1504        }
1505
1506        let fs = match SftpFs::connect(&known.host).await {
1507            Ok(fs) => fs,
1508            Err(e) => {
1509                // The reason is passed through rather than flattened to "could not
1510                // connect". It is ssh's, and ssh's reasons are the ones with a fix in
1511                // them: a jump host that is down, a key that is not loaded, a name that
1512                // does not resolve.
1513                return control::text(
1514                    StatusCode::BAD_GATEWAY,
1515                    format!("ssh to {}: {e:#}", known.host),
1516                );
1517            }
1518        };
1519        let base = match resolve_base(alias.base(), &fs).await {
1520            Ok(base) => base,
1521            Err(e) => {
1522                return control::text(
1523                    StatusCode::BAD_GATEWAY,
1524                    format!("working out where to root {}: {e:#}", known.alias),
1525                );
1526            }
1527        };
1528
1529        // Inserted under the write lock, and a session that lost the race is dropped
1530        // rather than replacing the winner. Dropping it closes that ssh child, which is
1531        // the right end for a connection nothing is using; replacing the winner would
1532        // close one that requests are already going through.
1533        let session = {
1534            let mut sessions = self.sessions.write().await;
1535            Arc::clone(sessions.entry(known.alias.clone()).or_insert_with(|| {
1536                Arc::new(Session {
1537                    host: known.host.clone(),
1538                    base,
1539                    fs,
1540                })
1541            }))
1542        };
1543        self.opened(&known.alias, &known.host, &session.base)
1544    }
1545
1546    /// `POST /_control/close` -- stop serving an alias.
1547    ///
1548    /// The other half of `open`, and what makes changing where an alias is rooted possible
1549    /// at all: reopening under a second base is refused while the first is live, because
1550    /// it would change what an origin means underneath any page open in it. Closing first
1551    /// makes that an act somebody chose rather than something that happened to them.
1552    ///
1553    /// Also the only way to give back an ssh connection without stopping the daemon.
1554    async fn close_alias(&self, body: &[u8]) -> Response<Full<Bytes>> {
1555        #[derive(serde::Deserialize)]
1556        #[serde(deny_unknown_fields)]
1557        struct Ask {
1558            alias: String,
1559        }
1560
1561        let ask: Ask = match serde_json::from_slice(body) {
1562            Ok(ask) => ask,
1563            Err(e) => {
1564                return control::text(
1565                    StatusCode::BAD_REQUEST,
1566                    format!("close needs a JSON body naming an alias: {e}"),
1567                );
1568            }
1569        };
1570
1571        // Removed under the write lock, so two callers cannot both believe they closed it.
1572        // Dropping the `Arc` is what ends the ssh session, and a request already in flight
1573        // holds one — so the connection goes when the last reader is done with it rather
1574        // than out from under them.
1575        // Marked before the session goes, so no request can slip between the two and re-open
1576        // what is being closed. Only for declared aliases: a host opened from the dashboard is
1577        // not in `declared`, so removing its session is already the whole of stopping it.
1578        if self.declared.contains_key(&ask.alias) {
1579            self.stopped.write().await.insert(ask.alias.clone());
1580        }
1581        let gone = self.sessions.write().await.remove(&ask.alias);
1582        match gone {
1583            Some(session) => {
1584                #[derive(serde::Serialize)]
1585                struct Closed<'a> {
1586                    alias: &'a str,
1587                    host: &'a str,
1588                    base: &'a str,
1589                }
1590                control::json(&Closed {
1591                    alias: &ask.alias,
1592                    host: &session.host,
1593                    base: &session.base,
1594                })
1595            }
1596            // Distinguished from success on purpose. "Closed something" and "there was
1597            // nothing to close" look identical to a caller that is told neither, and the
1598            // second usually means the alias was spelled wrong.
1599            None => control::text(
1600                StatusCode::NOT_FOUND,
1601                format!("no alias named {:?} is open", ask.alias),
1602            ),
1603        }
1604    }
1605
1606    /// Connect one host and work out where it is rooted.
1607    ///
1608    /// Owns its arguments and borrows nothing, so it can run in a task and several of them can
1609    /// run at once. Does not touch the session map: dialling and adopting are separated so that
1610    /// the concurrent path at startup and the one-at-a-time path behind a toggle share the part
1611    /// that talks to ssh, rather than each having a copy of it to drift.
1612    async fn dial(alias: String, host: String, base: Option<String>) -> Result<Session> {
1613        let fs = SftpFs::connect(&host)
1614            .await
1615            .with_context(|| format!("ssh to {host}"))?;
1616        let resolved = resolve_base(base.as_deref(), &fs)
1617            .await
1618            .with_context(|| format!("working out where to root {alias}"))?;
1619        Ok(Session {
1620            host,
1621            base: resolved,
1622            fs,
1623        })
1624    }
1625
1626    /// Put a connected session in the map, or keep the one that got there first.
1627    ///
1628    /// A session that lost the race is dropped rather than replacing the winner. Dropping it
1629    /// closes that ssh child, which is the right end for a connection nothing is using;
1630    /// replacing the winner would close one that requests are already going through.
1631    ///
1632    /// Unless the winner is dead, in which case it is replaced. Otherwise a reconnection could
1633    /// never land: `live` evicts the corpse, this would put the new session behind it, and the
1634    /// alias would be permanently unreachable by a daemon that was reconnecting correctly.
1635    async fn adopt(&self, alias: &str, session: Session) -> Arc<Session> {
1636        let mut sessions = self.sessions.write().await;
1637        match sessions.get(alias) {
1638            Some(held) if held.fs.is_alive() => Arc::clone(held),
1639            _ => {
1640                let session = Arc::new(session);
1641                sessions.insert(alias.to_string(), Arc::clone(&session));
1642                session
1643            }
1644        }
1645    }
1646
1647    async fn connect(&self, alias: &str, host: &str, base: Option<&str>) -> Result<Arc<Session>> {
1648        let session = Self::dial(
1649            alias.to_string(),
1650            host.to_string(),
1651            base.map(str::to_string),
1652        )
1653        .await?;
1654        Ok(self.adopt(alias, session).await)
1655    }
1656
1657    /// Open every declared alias, at once, and say which ones would not.
1658    ///
1659    /// Returns the routes that came up and a line per one that did not. Only a name typed for
1660    /// this run is an error: the reader is standing there waiting on it, so starting without it
1661    /// would answer a different question than the one asked. A name in a config file is
1662    /// reported and left declared, which is what makes it retryable.
1663    async fn open_declared(&self) -> Result<(Vec<String>, Vec<String>)> {
1664        let mut dialling = tokio::task::JoinSet::new();
1665        for (name, d) in &self.declared {
1666            let (name, host, base) = (name.clone(), d.host.clone(), d.base.clone());
1667            dialling.spawn(async move {
1668                let got = Self::dial(name.clone(), host, base).await;
1669                (name, got)
1670            });
1671        }
1672
1673        let (mut routes, mut refused) = (Vec::new(), Vec::new());
1674        while let Some(finished) = dialling.join_next().await {
1675            let (name, got) = finished.context("dialling an alias")?;
1676            let d = &self.declared[&name];
1677            match got {
1678                Ok(session) => {
1679                    // Built from the resolved base, so what is announced is where requests
1680                    // will actually go. Formatting it from the alias beforehand would print
1681                    // the word "home" and leave the reader to find out which directory that
1682                    // was.
1683                    routes.push(format!(
1684                        "  {}://{name}.{}/  ->  {}:{}",
1685                        self.scheme, self.suffix, session.host, session.base
1686                    ));
1687                    self.adopt(&name, session).await;
1688                }
1689                Err(e) => {
1690                    if d.named == Named::ForThisRun {
1691                        return Err(e).with_context(|| {
1692                            format!("alias {name} -> ssh host {}, named for this run", d.host)
1693                        });
1694                    }
1695                    // Said now and remembered, because the reader will meet it again when they
1696                    // open the site and the two should agree.
1697                    let why = format!("{e:#}");
1698                    refused.push(format!("  {name} could not be opened: {why}"));
1699                    self.trouble.write().await.insert(
1700                        name,
1701                        Trouble {
1702                            at: Instant::now(),
1703                            why,
1704                        },
1705                    );
1706                }
1707            }
1708        }
1709        routes.sort();
1710        Ok((routes, refused))
1711    }
1712}
1713
1714/// What asking for an alias's connection found.
1715///
1716/// Four answers because there are four situations, and an `Option` can carry two. Collapsing
1717/// them is how a host that was asleep became indistinguishable from a name nobody ever wrote
1718/// down -- which is the bug this whole path exists to end, so the shape says it.
1719enum Reached {
1720    Open(Arc<Session>),
1721    /// Declared, and the ssh would not come up. Carries what ssh said.
1722    Down(String),
1723    /// Declared, and stopped from the dashboard for this run.
1724    Stopped,
1725    /// Not an alias this daemon serves.
1726    Unknown,
1727}
1728
1729impl Origin {
1730    /// The connection for an alias, opening one if there is not a live one already.
1731    ///
1732    /// This is the retry. There is no timer and no background loop: a reader who reloads has
1733    /// asked for exactly one more attempt, which is the right number, and a host that is down
1734    /// does not get dialled every thirty seconds by a daemon nobody is using.
1735    ///
1736    /// Three outcomes, and they are different answers. A live session serves. A declared alias
1737    /// with no live session is dialled here and either serves or says why. A name that was
1738    /// never declared is not an alias at all.
1739    async fn session_for(&self, alias: &str) -> Reached {
1740        if let Some(live) = self.live(alias).await {
1741            return Reached::Open(live);
1742        }
1743        let Some(d) = self.declared.get(alias) else {
1744            return Reached::Unknown;
1745        };
1746        // Asked before dialling, so stopping something does not cost an ssh handshake to
1747        // discover.
1748        if self.stopped.read().await.contains(alias) {
1749            return Reached::Stopped;
1750        }
1751
1752        // One dial per alias at a time, and the check again inside it. A first visit is one
1753        // navigation and then every subresource on the page, all arriving before the first ssh
1754        // has finished; without this each of them starts its own.
1755        let _dialling = d.dialling.lock().await;
1756        if let Some(live) = self.live(alias).await {
1757            return Reached::Open(live);
1758        }
1759
1760        // A failure is remembered for a few seconds, and inside that window this answers from
1761        // the memory rather than dialling again.
1762        //
1763        // Because every request to an alias origin arrives through the proxy, and a page can
1764        // make them: `<img src="http://docs.ssh-browser/x">` in a loop is a remote document
1765        // deciding how often this machine opens an ssh. It cannot name a host that was not
1766        // declared -- `declared` is fixed at startup and is not `ssh_config` -- so the worst it
1767        // reaches is a host the reader already asked to have served. But "already yours" is not
1768        // "free", and one attempt per request is a rate somebody else chooses.
1769        //
1770        // Short enough that reloading is still a retry, which is the whole point of the retry
1771        // being a reload: nobody reads a failure and reloads inside three seconds.
1772        if let Some(t) = self.trouble.read().await.get(alias)
1773            && t.at.elapsed() < self.cooldown
1774        {
1775            return Reached::Down(t.why.clone());
1776        }
1777
1778        match Self::dial(alias.to_string(), d.host.clone(), d.base.clone()).await {
1779            Ok(session) => {
1780                self.trouble.write().await.remove(alias);
1781                Reached::Open(self.adopt(alias, session).await)
1782            }
1783            Err(e) => {
1784                let why = format!("{e:#}");
1785                self.trouble.write().await.insert(
1786                    alias.to_string(),
1787                    Trouble {
1788                        at: Instant::now(),
1789                        why: why.clone(),
1790                    },
1791                );
1792                Reached::Down(why)
1793            }
1794        }
1795    }
1796
1797    /// The session for an alias, if there is one and it is still connected.
1798    ///
1799    /// A dead one is dropped here rather than returned. Holding it would mean answering every
1800    /// request with `sftp session is gone` until somebody restarted the daemon, which is the
1801    /// one failure mode a reconnecting daemon must not have.
1802    async fn live(&self, alias: &str) -> Option<Arc<Session>> {
1803        if self.sessions.read().await.get(alias)?.fs.is_alive() {
1804            return self.sessions.read().await.get(alias).cloned();
1805        }
1806        let mut sessions = self.sessions.write().await;
1807        // Checked again under the write lock: another request may have replaced it since, and
1808        // evicting the replacement would start this over.
1809        if sessions.get(alias).is_some_and(|s| !s.fs.is_alive()) {
1810            sessions.remove(alias);
1811        }
1812        sessions.get(alias).cloned()
1813    }
1814
1815    /// Open every enabled host, at once, and say which ones would not.
1816    ///
1817    /// At once rather than in turn: these are independent ssh handshakes, and in sequence six
1818    /// hosts would cost the sum of six round-trip times before the daemon answered anything.
1819    async fn open_enabled(&self) -> (Vec<String>, Vec<String>) {
1820        let wanted: Vec<reachable::Host> = self.reachable.read().await.enabled().cloned().collect();
1821        if wanted.is_empty() {
1822            return (Vec::new(), Vec::new());
1823        }
1824
1825        // Looked up in ssh_config rather than dialled by the name in the file, for two reasons
1826        // and the second is the important one.
1827        //
1828        // The name in the file is the *label* — lowercase, because it becomes a hostname — and
1829        // the ssh_config `Host` it came from need not be spelled the same. A file saying
1830        // `panza` for a config that says `Panza` produced `Could not resolve hostname panza` on
1831        // every start, while enabling it in the first place had worked: that path had the
1832        // ssh_config entry in hand and this one only had the label.
1833        //
1834        // And it is the same gate `open` and `enabled` have. Without it this is a path that
1835        // ssh's to whatever names are in a file, with no check that ssh has ever heard of them
1836        // — a second, looser door into the one thing this daemon is careful about.
1837        let known = match ssh_config::read() {
1838            Ok(found) => found.hosts,
1839            Err(e) => {
1840                let mut refused: Vec<String> = wanted
1841                    .iter()
1842                    .map(|h| {
1843                        format!(
1844                            "  {} is enabled but ssh_config could not be read: {e:#}",
1845                            h.name
1846                        )
1847                    })
1848                    .collect();
1849                refused.sort();
1850                return (Vec::new(), refused);
1851            }
1852        };
1853
1854        let mut dialling = tokio::task::JoinSet::new();
1855        let mut refused = Vec::new();
1856        for host in wanted {
1857            let Some(entry) = entry_for(&known, &host.name) else {
1858                // Named and gone: the ssh_config entry was renamed or removed since this was
1859                // turned on. Said rather than retried silently, because the fix is in a file
1860                // the reader owns.
1861                refused.push(format!(
1862                    "  {} is enabled but is no longer a host in your ssh_config",
1863                    host.name
1864                ));
1865                continue;
1866            };
1867            let (label, target) = (entry.alias.clone(), entry.host.clone());
1868            dialling.spawn(async move {
1869                let got = Self::dial(label.clone(), target, host.base.clone()).await;
1870                (label, got)
1871            });
1872        }
1873
1874        let mut opened = Vec::new();
1875        while let Some(finished) = dialling.join_next().await {
1876            let (name, got) = match finished {
1877                Ok(pair) => pair,
1878                // The task itself failed rather than the ssh in it -- a panic. Reported the same
1879                // way, because from here it is the same fact: this host is not being served and
1880                // the reader has to be told which one.
1881                Err(e) => {
1882                    refused.push(format!("  an enabled host could not be opened: {e}"));
1883                    continue;
1884                }
1885            };
1886            match got {
1887                Ok(session) => {
1888                    let base = session.base.clone();
1889                    self.adopt(&name, session).await;
1890                    opened.push(format!(
1891                        "  {}://{name}.{}/  ->  {name}:{base}",
1892                        self.scheme, self.suffix
1893                    ));
1894                }
1895                // ssh's own words, not "could not connect". ssh's reasons are the ones with a
1896                // fix in them: a jump host that is down, a key that is not loaded, a name that
1897                // does not resolve.
1898                Err(e) => refused.push(format!("  {name} is enabled but did not answer: {e:#}")),
1899            }
1900        }
1901        opened.sort();
1902        refused.sort();
1903        (opened, refused)
1904    }
1905
1906    fn opened(&self, alias: &str, host: &str, base: &str) -> Response<Full<Bytes>> {
1907        #[derive(serde::Serialize)]
1908        struct Opened<'a> {
1909            alias: &'a str,
1910            host: &'a str,
1911            base: &'a str,
1912            url: String,
1913        }
1914        control::json(&Opened {
1915            alias,
1916            host,
1917            base,
1918            url: self.site_url(alias),
1919        })
1920    }
1921
1922    /// `POST /_control/enabled` -- open a host every run, or stop.
1923    ///
1924    /// The difference from `open` is that this is remembered. `open` serves a host until the
1925    /// daemon stops; this says to open it next time too, which is what turns "click the host,
1926    /// then use the URL" into "use the URL".
1927    ///
1928    /// Turning one on connects it now as well, because a setting that only took effect after a
1929    /// restart would be indistinguishable from one that did not work. Turning one off closes it
1930    /// now, for the same reason in reverse: a host still answering after you switched it off
1931    /// reads as the switch having failed.
1932    async fn set_enabled(&self, body: &[u8]) -> Response<Full<Bytes>> {
1933        #[derive(serde::Deserialize)]
1934        #[serde(deny_unknown_fields)]
1935        struct Ask {
1936            host: String,
1937            enabled: bool,
1938            /// Where to root it, for the first time it is turned on.
1939            #[serde(default)]
1940            base: Option<String>,
1941        }
1942
1943        let ask: Ask = match serde_json::from_slice(body) {
1944            Ok(ask) => ask,
1945            Err(e) => {
1946                return control::text(
1947                    StatusCode::BAD_REQUEST,
1948                    format!("enabled needs a JSON body naming a host and whether it is on: {e}"),
1949                );
1950            }
1951        };
1952
1953        // The same gate `open` has, and for the same reason: "ssh to an arbitrary host on
1954        // request" is a larger primitive than this needs to be, and the ssh_config list is
1955        // already the menu. Checked before anything is remembered, so a typo does not leave a
1956        // name in the file that will be retried at every start forever.
1957        let found = match ssh_config::read() {
1958            Ok(found) => found,
1959            Err(e) => {
1960                return control::text(
1961                    StatusCode::INTERNAL_SERVER_ERROR,
1962                    format!("reading ssh_config: {e:#}"),
1963                );
1964            }
1965        };
1966        let Some(known) = found
1967            .hosts
1968            .iter()
1969            .find(|h| h.host.eq_ignore_ascii_case(&ask.host) || h.alias == ask.host)
1970        else {
1971            return control::text(
1972                StatusCode::NOT_FOUND,
1973                format!("{:?} is not a host in your ssh_config", ask.host),
1974            );
1975        };
1976
1977        // Held to the same rules an alias is, before it is written down anywhere. A name that
1978        // cannot be a hostname label would be remembered and then refused on every request.
1979        if let Err(e) = Alias::new(&known.alias, &known.host, ask.base.as_deref()) {
1980            return control::text(StatusCode::BAD_REQUEST, format!("{e:#}"));
1981        }
1982
1983        if ask.enabled {
1984            // Connected before it is remembered. A host that cannot be reached is not written
1985            // into the file, so the answer is the same failure `open` would give rather than a
1986            // silent "saved" followed by a URL that does not work.
1987            if self.session(&known.alias).await.is_none()
1988                && let Err(e) = self
1989                    .connect(&known.alias, &known.host, ask.base.as_deref())
1990                    .await
1991            {
1992                return control::text(StatusCode::BAD_GATEWAY, format!("{e:#}"));
1993            }
1994        } else {
1995            self.sessions.write().await.remove(&known.alias);
1996        }
1997
1998        let remembered = {
1999            let mut set = self.reachable.write().await;
2000            set.set(&known.alias, ask.enabled, ask.base.clone());
2001            // Best effort, and reported beside the result rather than instead of it: failing to
2002            // write a file under the state directory must not undo a change that has already
2003            // taken effect.
2004            reachable::remember(&set).is_ok()
2005        };
2006
2007        #[derive(serde::Serialize)]
2008        struct Switched<'a> {
2009            host: &'a str,
2010            enabled: bool,
2011            remembered: bool,
2012            url: Option<String>,
2013        }
2014        control::json(&Switched {
2015            host: &known.alias,
2016            enabled: ask.enabled,
2017            remembered,
2018            url: ask.enabled.then(|| self.site_url(&known.alias)),
2019        })
2020    }
2021
2022    /// `GET /_control/certificate?name=<alias>.<suffix>` -- what is served for that name.
2023    ///
2024    /// Two reasons this exists rather than being an implementation detail.
2025    ///
2026    /// A reader being asked to trust a root may reasonably want to see what it signs before
2027    /// deciding, and "run openssl on this file" is a poor answer to that when the file is a root
2028    /// and the question is about a leaf.
2029    ///
2030    /// And it makes the https mode testable without a trust store. A browser can be told to
2031    /// accept one specific public key for one launch — Chromium's
2032    /// `--ignore-certificate-errors-spki-list` takes exactly the `pin` below — which means the
2033    /// whole path can be exercised in CI, where installing a root is not an option. Measured:
2034    /// with the pin, an https alias origin reports `isSecureContext` with service workers,
2035    /// `crypto.subtle` and `caches`, and nothing on the machine changes.
2036    fn show_certificate(&self, query: Option<&str>) -> Response<Full<Bytes>> {
2037        let Some(certificates) = self.certificates.as_ref() else {
2038            return control::text(
2039                StatusCode::NOT_IMPLEMENTED,
2040                "this daemon serves http, so there is no certificate",
2041            );
2042        };
2043
2044        // The name has to be asked for, because there is a certificate per name and no single
2045        // "the" certificate. Defaulting to something would answer a question nobody asked.
2046        let Some(name) = query.and_then(|q| {
2047            q.split('&')
2048                .find_map(|pair| pair.strip_prefix("name="))
2049                .map(str::to_string)
2050        }) else {
2051            return control::text(
2052                StatusCode::BAD_REQUEST,
2053                "certificate needs a name, e.g. ?name=docs.ssh-browser",
2054            );
2055        };
2056
2057        // Through the resolver's own cache, so this is the certificate a handshake gets rather
2058        // than a second one that happens to be valid. Minting a fresh one here is what the first
2059        // version did, and the pin it reported was for a certificate nobody would ever be served.
2060        let Some(minted) = certificates.certificate_for(&name) else {
2061            return control::text(
2062                StatusCode::BAD_REQUEST,
2063                format!("{name:?} is not a name this daemon can vouch for"),
2064            );
2065        };
2066
2067        #[derive(serde::Serialize)]
2068        struct Served<'a> {
2069            name: &'a str,
2070            certificate: &'a str,
2071            /// Base64 of the SHA-256 of the certificate's `SubjectPublicKeyInfo`.
2072            ///
2073            /// The form a browser takes for a one-launch pin, so it can be handed straight to
2074            /// `--ignore-certificate-errors-spki-list` without anybody computing a digest — and
2075            /// without trusting a root to try the https mode at all.
2076            pin: &'a str,
2077            authority: &'a str,
2078        }
2079        control::json(&Served {
2080            name: &name,
2081            certificate: &minted.certificate_pem,
2082            pin: &minted.pin,
2083            authority: certificates.authority.certificate_pem(),
2084        })
2085    }
2086
2087    /// `GET /_control/theme` -- what listings look like, and what else they could.
2088    async fn show_theme(&self) -> Response<Full<Bytes>> {
2089        #[derive(serde::Serialize)]
2090        struct Choice<'a> {
2091            name: &'a str,
2092            label: &'a str,
2093            /// `light`, `dark`, or `system`, so the dashboard can group them.
2094            variant: &'a str,
2095        }
2096        #[derive(serde::Serialize)]
2097        struct Themes<'a> {
2098            current: &'a str,
2099            /// The current palette, as the `:root` block that carries it.
2100            ///
2101            /// Sent rather than named, because the dashboard cannot read a `.yaml` compiled
2102            /// into this binary and a second copy of sixteen hex values in TypeScript is the
2103            /// thing this whole arrangement exists to avoid. The dashboard drops it into the
2104            /// page and the one stylesheet both halves share reads it.
2105            css: String,
2106            themes: Vec<Choice<'a>>,
2107        }
2108        // The list comes from the daemon rather than being written out again in the
2109        // dashboard. Two copies of it is how a theme gets added and stays invisible.
2110        let current = self.theme.read().await;
2111        control::json(&Themes {
2112            css: theme::css_for(&current),
2113            current: &current,
2114            themes: theme::all()
2115                .iter()
2116                .map(|t| Choice {
2117                    name: &t.name,
2118                    label: &t.label,
2119                    variant: t.variant,
2120                })
2121                .collect(),
2122        })
2123    }
2124
2125    /// `POST /_control/theme` -- choose one, and remember it.
2126    async fn set_theme(&self, body: &[u8]) -> Response<Full<Bytes>> {
2127        #[derive(serde::Deserialize)]
2128        #[serde(deny_unknown_fields)]
2129        struct Ask {
2130            name: String,
2131        }
2132        let ask: Ask = match serde_json::from_slice(body) {
2133            Ok(ask) => ask,
2134            Err(e) => {
2135                return control::text(
2136                    StatusCode::BAD_REQUEST,
2137                    format!("theme needs a JSON body naming one: {e}"),
2138                );
2139            }
2140        };
2141        // Checked before anything is changed, so a typo leaves the daemon as it was rather
2142        // than half-moved to a theme that does not exist.
2143        if let Err(e) = theme::check(&ask.name) {
2144            return control::text(StatusCode::BAD_REQUEST, format!("{e:#}"));
2145        }
2146
2147        *self.theme.write().await = ask.name.clone();
2148        // Remembered on a best effort. Failing to write a file under the runtime directory
2149        // must not undo a change the reader can already see on the next listing, so it is
2150        // reported beside the result rather than instead of it.
2151        let remembered = theme::remember(&ask.name).is_ok();
2152        #[derive(serde::Serialize)]
2153        struct Chose<'a> {
2154            current: &'a str,
2155            remembered: bool,
2156        }
2157        control::json(&Chose {
2158            current: &ask.name,
2159            remembered,
2160        })
2161    }
2162
2163    async fn autoindex_of(
2164        &self,
2165        session: &Session,
2166        alias: &str,
2167        path: &str,
2168        resolved: &str,
2169        query: Option<&str>,
2170    ) -> Response<Full<Bytes>> {
2171        // Taken from the resolved path rather than from the request, so the tree shows a
2172        // filename as it is spelled on disk rather than percent-escaped. `resolved` always
2173        // begins with the base, because that is what resolving it against the base means.
2174        let rel = resolved
2175            .strip_prefix(&session.base)
2176            .unwrap_or("")
2177            .to_string();
2178        let entries = match self.listing_of(session, resolved).await {
2179            Ok(entries) => entries,
2180            Err(e) => return fail(StatusCode::NOT_FOUND, format!("{path}: {e:#}")),
2181        };
2182        let sites = self.sites_among(session, resolved, &entries).await;
2183
2184        // `?ls` is one level of the same tree, as the HTML fragment that goes inside it.
2185        // It is what the tree fetches when a folder is expanded.
2186        //
2187        // A fragment rather than JSON so that there is exactly one thing that knows how a
2188        // row is written. A JSON reply would mean a second renderer in the page's script,
2189        // in another language, which is two places for a class name to be spelled and one
2190        // of them to be spelled wrong.
2191        //
2192        // It is not a new capability either: a page under this alias can already read every
2193        // path under it, and this says no more than the listing below does.
2194        if query == Some("ls") {
2195            let mut out = String::new();
2196            render_level(&mut out, &rel, &rows_of(&entries, &sites), &[]);
2197            return plain_ok("text/html; charset=utf-8", Bytes::from(out));
2198        }
2199
2200        // The ancestors are already in the cache: the walk that resolved this path warmed
2201        // every one of them to check for symlinks. So a tree opened four levels down costs
2202        // no more round trips than the listing it replaces.
2203        let mut levels = Vec::new();
2204        let mut at = session.base.clone();
2205        for part in rel.split('/').filter(|p| !p.is_empty()) {
2206            if let Some(entries) = self.cache.listing_entries(&at) {
2207                let here = at.strip_prefix(&session.base).unwrap_or("").to_string();
2208                // Only the level the reader is standing in is scanned for sites, so only it
2209                // can mark them. Scanning every level would multiply the one extra round
2210                // trip by the depth of the path, which is the thing this is careful not to
2211                // do; expanding a folder scans it, so a mark appears where you look.
2212                levels.push((here, rows_of(&entries, &HashSet::new())));
2213            }
2214            at.push('/');
2215            at.push_str(part);
2216        }
2217        levels.push((rel.clone(), rows_of(&entries, &sites)));
2218
2219        plain_ok(
2220            "text/html; charset=utf-8",
2221            Bytes::from(autoindex(
2222                alias,
2223                &rel,
2224                &levels,
2225                &self.theme.read().await,
2226                &self.home_url(),
2227            )),
2228        )
2229    }
2230
2231    /// A directory's entries, from the cache when they are there.
2232    async fn listing_of(&self, session: &Session, dir: &str) -> Result<Vec<Entry>> {
2233        if let Some(entries) = self.cache.listing_entries(dir) {
2234            return Ok(entries);
2235        }
2236        let entries = session.fs.list_dir(dir).await?;
2237        self.cache.put_listing(dir, &entries);
2238        Ok(entries)
2239    }
2240
2241    /// Which of these subdirectories are themselves sites.
2242    ///
2243    /// A directory holding an `index.html` is served *as* that page, so it is a site rather
2244    /// than a folder, and saying so is what souta actually asked for. Grouping the HTML in
2245    /// one listing does not find a Pinax board, because a board is `out/ft_demo/index.html`
2246    /// and the directory you are standing in has no HTML in it at all.
2247    ///
2248    /// One extra round trip, because every listing is issued together -- not one per
2249    /// subdirectory. It is spent on a directory listing and never on a page load, so the
2250    /// round-trip invariant for serving a page is untouched.
2251    ///
2252    /// It is also not purely a cost: the listings it fetches are the ones the next click
2253    /// needs, so stepping into any of these subdirectories afterwards costs nothing.
2254    async fn sites_among(
2255        &self,
2256        session: &Session,
2257        dir: &str,
2258        entries: &[Entry],
2259    ) -> HashSet<String> {
2260        /// Beyond this, the scan is buying less than it costs: a directory with hundreds of
2261        /// subdirectories is not one somebody is scanning by eye for a report.
2262        const MAX_SCAN: usize = 64;
2263
2264        let names: Vec<&str> = entries
2265            .iter()
2266            .filter(|e| e.attrs.is_dir() && e.name != "." && e.name != ".." && !hidden(&e.name))
2267            .map(|e| e.name.as_str())
2268            .take(MAX_SCAN)
2269            .collect();
2270        if names.is_empty() {
2271            return HashSet::new();
2272        }
2273
2274        let paths: Vec<String> = names.iter().map(|n| format!("{dir}/{n}")).collect();
2275        // Already-known listings are not asked for again. Going back up a level is the
2276        // ordinary case and would otherwise re-list every sibling.
2277        let missing: Vec<String> = paths
2278            .iter()
2279            .filter(|p| self.cache.listing_entries(p).is_none())
2280            .cloned()
2281            .collect();
2282        if !missing.is_empty() {
2283            for (path, got) in missing.iter().zip(session.fs.list_dirs(&missing).await) {
2284                if let Ok(entries) = got {
2285                    self.cache.put_listing(path, &entries);
2286                }
2287                // A subdirectory that cannot be listed is simply not a site. It is not an
2288                // error for this page: the reader asked for the directory they are in, and
2289                // a permission problem one level down is theirs to meet when they click.
2290            }
2291        }
2292
2293        names
2294            .iter()
2295            .zip(paths.iter())
2296            .filter(|(_, path)| {
2297                self.cache.listing_entries(path).is_some_and(|listing| {
2298                    listing
2299                        .iter()
2300                        .any(|e| e.name == "index.html" && !e.attrs.is_dir())
2301                })
2302            })
2303            .map(|(name, _)| (*name).to_string())
2304            .collect()
2305    }
2306
2307    /// Read what an HTML page is about to ask for, in one batch.
2308    ///
2309    /// One round trip to list the directories they live in, then one batch of reads — and
2310    /// neither grows with the number of subresources. When they sit beside the document, which
2311    /// is what a generated report looks like, the listing is already held and the listing round
2312    /// disappears.
2313    ///
2314    /// Two at most, and it really is two. The reads go through `read_ranges` rather than
2315    /// `read_batch` precisely so that this holds: `read_batch` has to poll in 32 KiB chunks
2316    /// because it does not know how long a file is, which made a one-megabyte bundle
2317    /// thirty-two round trips here. The listing already says how long each one is.
2318    ///
2319    /// Every reference goes through the same resolution and the same symlink rule as a real
2320    /// request, on purpose. A page is untrusted input, and a prefetcher that skipped those
2321    /// checks could be told to read a file the operator's configuration says is out of
2322    /// bounds. Serving it would still be refused, but reading it is already the wrong act.
2323    ///
2324    /// Failures are dropped in silence here, which is the one place in this codebase that is
2325    /// right: a reference that cannot be read is about to be requested for real, and that
2326    /// request reports the failure properly. Saying anything now would be guessing at whether
2327    /// the reader was going to care.
2328    async fn warm_subresources(&self, session: &Session, doc_path: &str, html: &[u8]) {
2329        let refs = prefetch::scan(html, prefetch::MAX_SUBRESOURCES);
2330        if refs.is_empty() {
2331            return;
2332        }
2333        // The directory the document is in, in URL terms, which is what a relative reference
2334        // on the page is relative to.
2335        let dir_of_doc = match doc_path.rsplit_once('/') {
2336            Some((head, _)) => head,
2337            None => "",
2338        };
2339
2340        // Resolved first, so that a reference climbing out of the base is gone before it can
2341        // contribute a directory to list.
2342        let mut wanted: Vec<(String, Vec<(String, String)>)> = Vec::new();
2343        for r in &refs {
2344            let url = if r.starts_with('/') {
2345                r.clone()
2346            } else {
2347                format!("{dir_of_doc}/{r}")
2348            };
2349            let Ok(resolved) = guard::resolve(&session.base, &url) else {
2350                continue;
2351            };
2352            let chain = components(&session.base, &resolved);
2353            if chain.is_empty() {
2354                continue;
2355            }
2356            // The same rule the request path applies, applied here too — a page naming
2357            // `.ssh/id_ed25519` in an `<img src>` must not get it read into the cache on the
2358            // strength of the request that would refuse it never being made.
2359            if chain.iter().any(|(_, n)| hidden(n)) {
2360                continue;
2361            }
2362            // Checked against what is already known before anything new is listed. Without
2363            // this a page could get a directory behind a symlink listed purely by naming it,
2364            // and the symlink rule exists precisely so that the daemon does not go there.
2365            // The check runs again after the listings, for components not yet known.
2366            if self.first_symlink_cached(&chain).is_some() {
2367                continue;
2368            }
2369            // And every directory this reference would cause to be listed has to be one the
2370            // cache can already prove is not behind a symlink. `first_symlink` alone is not
2371            // enough: it sees only what is cached, so a symlink one level below the deepest
2372            // listing held is invisible to it and would be opened by the very batch meant to
2373            // discover it.
2374            if !chain
2375                .iter()
2376                .all(|(dir, _)| self.listable(&session.base, dir))
2377            {
2378                continue;
2379            }
2380            wanted.push((resolved, chain));
2381        }
2382
2383        let all: Vec<(String, String)> = wanted.iter().flat_map(|(_, c)| c.clone()).collect();
2384        // Prefetching only ever makes a page faster, so a directory that would not list is
2385        // not an error for the request that triggered it: the subresource is fetched the
2386        // ordinary way afterwards and fails, or does not, on its own terms.
2387        let held = self.held_listings(session, &all).await;
2388
2389        let mut to_read = Vec::new();
2390        for (resolved, chain) in &wanted {
2391            if first_symlink(&held, chain).is_some() {
2392                continue;
2393            }
2394            let (dir, name) = &chain[chain.len() - 1];
2395            let Some(attrs) = attrs_in(&held, dir, name) else {
2396                continue;
2397            };
2398            if attrs.is_dir() {
2399                continue;
2400            }
2401            // The size has to be known, and not merely defaulted to zero, because it is what
2402            // the read below asks for. A listing that did not report one leaves nothing to
2403            // ask for, and requesting zero bytes would cache an empty body for a file that
2404            // has contents.
2405            let Some(size) = attrs.size else {
2406                continue;
2407            };
2408            // Nothing to warm at zero, and warming it is where a listing that lies about the
2409            // size does damage: a ranged read asks for exactly what it was told, so a file
2410            // reported as empty is fetched as empty and then served that way. A real empty
2411            // file loses nothing by being read on request.
2412            //
2413            // A file too large to hold, at the other end, would be read only to be declined
2414            // by the cache and read again by the real request anyway.
2415            if size == 0 || size > CACHE_WHOLE_MAX {
2416                continue;
2417            }
2418            if self.cache.body(resolved, &attrs).is_some() {
2419                continue;
2420            }
2421            to_read.push((resolved.clone(), attrs, size));
2422        }
2423        if to_read.is_empty() {
2424            return;
2425        }
2426
2427        // `read_ranges` rather than `read_batch`, because the size is already known.
2428        //
2429        // `read_batch` cannot know how long a file is, so it polls in 32 KiB chunks until it
2430        // sees a short read: one round trip per chunk index, which makes a one-megabyte
2431        // bundle thirty-two of them. `read_ranges` is handed the length, so it computes every
2432        // chunk before issuing any and the whole file costs one. The listing this function
2433        // already depends on is what supplies the length, so nothing extra is asked for.
2434        let reqs: Vec<RangeReq> = to_read
2435            .iter()
2436            .map(|(path, _, size)| RangeReq {
2437                path: path.clone(),
2438                offset: 0,
2439                len: *size,
2440            })
2441            .collect();
2442
2443        for ((path, attrs, size), got) in to_read.iter().zip(session.fs.read_ranges(&reqs).await) {
2444            let Ok(body) = got else {
2445                continue;
2446            };
2447            // Short of what the listing promised means the file changed underneath us. The
2448            // cache key records the old size, so holding a body that no longer matches it
2449            // would serve the next reader a length the bytes do not have. Leaving it out
2450            // costs one prefetch; the real request reads it afresh.
2451            if body.len() as u64 != *size {
2452                continue;
2453            }
2454            self.cache.put_body(path, attrs, Bytes::from(body));
2455        }
2456    }
2457
2458    /// Fetch every ancestor listing not already held, in one batch.
2459    ///
2460    /// One round trip regardless of depth, which is the whole reason `list_dirs` is a batch
2461    /// rather than a loop. A directory that cannot be listed is simply left absent from the
2462    /// cache; the caller diagnoses that against the path the request actually named.
2463    /// Every directory along a path, taken out of the cache once and then held.
2464    ///
2465    /// Held, rather than looked up again as the walk goes. The cache has a two-second TTL,
2466    /// so asking whether a listing is there and then asking for the listing are two
2467    /// questions with a gap between them, and a request arriving on the boundary got `true`
2468    /// for the first and `false` for the second. That produced a 404 reading "cannot list"
2469    /// about a directory that plainly existed, on roughly one e2e run in six. Taking the
2470    /// entries once removes the gap rather than narrowing it.
2471    ///
2472    /// A failure carries the remote's own reason out. It used to be dropped and reported as
2473    /// "cannot list", which is this daemon saying it does not know rather than ssh saying
2474    /// why — the difference between a message somebody can act on and one they cannot.
2475    async fn listings_along(
2476        &self,
2477        session: &Session,
2478        chain: &[(String, String)],
2479    ) -> Result<HashMap<String, Vec<Entry>>, (String, String)> {
2480        let mut held: HashMap<String, Vec<Entry>> = HashMap::new();
2481        let mut missing: Vec<String> = Vec::new();
2482        for (dir, _) in chain {
2483            if held.contains_key(dir) {
2484                continue;
2485            }
2486            match self.cache.listing_entries(dir) {
2487                Some(entries) => {
2488                    held.insert(dir.clone(), entries);
2489                }
2490                // Deduplicated because the prefetcher passes the chains of many files at
2491                // once and several of them normally share a directory. Listing one twice in
2492                // a batch costs no extra round trip, but it does cost the remote the work.
2493                None if !missing.contains(dir) => missing.push(dir.clone()),
2494                None => {}
2495            }
2496        }
2497        if missing.is_empty() {
2498            return Ok(held);
2499        }
2500        for (dir, result) in missing.iter().zip(session.fs.list_dirs(&missing).await) {
2501            match result {
2502                Ok(entries) => {
2503                    self.cache.put_listing(dir, &entries);
2504                    held.insert(dir.clone(), entries);
2505                }
2506                // Absence is not a failure to report. A component that is not there, or
2507                // that is a file being used as a directory, is a 404 and the walk says so
2508                // on its own — answering 502 would blame the remote for a path the reader
2509                // got wrong. Anything else is the remote refusing, and that reason travels.
2510                Err(e) if crate::fs::is_absent(&e) => {}
2511                Err(e) => return Err((dir.clone(), format!("{e:#}"))),
2512            }
2513        }
2514        Ok(held)
2515    }
2516
2517    /// Can this directory be listed without asking the remote to walk through a symlink?
2518    ///
2519    /// True only when every step from the alias base down to it is already known — from a
2520    /// listing already held — to be a real directory. A step that is not known yet is not
2521    /// assumed safe, because SFTP v3 `OPENDIR` has no `O_NOFOLLOW`: asking the remote to
2522    /// list a path *is* asking it to follow whatever symlinks are in that path, and the
2523    /// answer arrives too late to un-ask. The base itself is operator configuration, not
2524    /// something a request reaches, so it is the one directory taken on trust.
2525    fn listable(&self, base: &str, dir: &str) -> bool {
2526        if dir.trim_end_matches('/') == base.trim_end_matches('/') {
2527            return true;
2528        }
2529        components(base, dir).iter().all(|(parent, name)| {
2530            self.cache
2531                .attrs_of(parent, name)
2532                .is_some_and(|a| a.is_dir() && !a.is_symlink())
2533        })
2534    }
2535
2536    /// The first component of a chain that is a symlink, if any.
2537    ///
2538    /// Shared between reading and writing deliberately. A write that reached through a
2539    /// symlinked directory could place a file outside the alias base entirely, which is
2540    /// strictly worse than reading through one, so the two must not be able to drift apart.
2541    /// The same walk for the write path, over listings held for the same reason.
2542    ///
2543    /// A failure leaves the symlink check with nothing to check, and the write then fails
2544    /// with the remote's own reason. There is no better answer to give from here.
2545    async fn held_listings(
2546        &self,
2547        session: &Session,
2548        chain: &[(String, String)],
2549    ) -> HashMap<String, Vec<Entry>> {
2550        self.listings_along(session, chain)
2551            .await
2552            .unwrap_or_default()
2553    }
2554}
2555
2556impl Origin {
2557    /// The symlink check over what is *already* cached and nothing more.
2558    ///
2559    /// The prefetcher runs this before it lists anything, so that a page cannot get a
2560    /// directory behind a symlink listed purely by naming it. Deliberately not the held
2561    /// version: the question here is what is known without asking.
2562    fn first_symlink_cached(&self, chain: &[(String, String)]) -> Option<String> {
2563        chain.iter().find_map(|(dir, name)| {
2564            self.cache
2565                .attrs_of(dir, name)
2566                .filter(Attrs::is_symlink)
2567                .map(|_| format!("{dir}/{name}"))
2568        })
2569    }
2570}
2571
2572/// One entry's attrs, out of the listings this request is holding.
2573fn attrs_in(held: &HashMap<String, Vec<Entry>>, dir: &str, name: &str) -> Option<Attrs> {
2574    held.get(dir)
2575        .and_then(|entries| entries.iter().find(|e| e.name == name))
2576        .map(|e| e.attrs)
2577}
2578
2579fn first_symlink(held: &HashMap<String, Vec<Entry>>, chain: &[(String, String)]) -> Option<String> {
2580    chain.iter().find_map(|(dir, name)| {
2581        attrs_in(held, dir, name)
2582            .filter(Attrs::is_symlink)
2583            .map(|_| format!("{dir}/{name}"))
2584    })
2585}
2586
2587/// The `?dashboard=` an extension names itself with on `hello`, if it is one.
2588///
2589/// Strict to the point of being boring, because this ends up inside a URL in a `<script>` on
2590/// the daemon's own page. Chrome derives an extension id as thirty-two letters from `a` to
2591/// `p` and nothing else, so anything else is not an id and is dropped rather than escaped:
2592/// there is no string that passes this and means something other than an extension.
2593fn extension_id(query: Option<&str>) -> Option<String> {
2594    let id = query?
2595        .split('&')
2596        .find_map(|p| p.strip_prefix("dashboard="))?;
2597    (id.len() == 32 && id.bytes().all(|b| b.is_ascii_lowercase() && b <= b'p'))
2598        .then(|| id.to_string())
2599}
2600
2601/// What the dashboard looks like, compiled in.
2602///
2603/// Shared with the extension, which links the same file out of `dist/` — see the note at the
2604/// top of it. The root of the suffix and the dashboard are one page in two places, and one
2605/// stylesheet is what keeps them one page.
2606const DASHBOARD_CSS: &str = include_str!("../../assets/dashboard.css");
2607
2608impl Origin {
2609    /// The root of the suffix, and the root of the loopback listener: the front door.
2610    ///
2611    /// There is one dashboard and it is the extension's, so the first thing this does is hand
2612    /// a browser over to it. Drawing a second one here was the alternative and it is the wrong
2613    /// one twice over: the halves that matter — the hosts you can serve, serving one, stopping
2614    /// one — are control-API calls that no page reaches, and two drawings of one page is two
2615    /// things to keep identical.
2616    ///
2617    /// What is left below the hand-over is the fallback, and it is only ever seen by something
2618    /// that cannot follow it: a local tool with `--proxy`, a daemon nothing has connected to
2619    /// yet, or a suffix the extension was not built with. It says what is being served and
2620    /// nothing that is not already public.
2621    async fn alias_index(&self, dashboard: Option<&str>) -> String {
2622        let mut sites: Vec<(String, String, String)> = self
2623            .sessions
2624            .read()
2625            .await
2626            .iter()
2627            .map(|(alias, s)| (alias.clone(), s.host.clone(), s.base.clone()))
2628            .collect();
2629        sites.sort();
2630
2631        // Declared and not connected. Listed rather than left out: a reader who can see
2632        // `panza` in their config file and not on the page whose whole job is saying what is
2633        // served has been told nothing, and that is the silent failure under another name.
2634        let (trouble, stopped) = (self.trouble.read().await, self.stopped.read().await);
2635        let mut down: Vec<(String, String)> = self
2636            .declared
2637            .iter()
2638            .filter(|(name, _)| !sites.iter().any(|(open, ..)| open == *name))
2639            .map(|(name, d)| {
2640                let why = if stopped.contains(name) {
2641                    "stopped from the dashboard; a restart serves it again".to_string()
2642                } else {
2643                    trouble
2644                        .get(name)
2645                        .map(|t| t.why.clone())
2646                        .unwrap_or_else(|| format!("{} has not answered", d.host))
2647                };
2648                (name.clone(), why)
2649            })
2650            .collect();
2651        down.sort();
2652        drop((trouble, stopped));
2653
2654        let mut s = String::from(
2655            "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
2656             <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
2657             <title>ssh-browser</title><style>",
2658        );
2659        // The palette first, then the layout that reads it -- the same order a listing uses,
2660        // and now the same palette. This page and a directory on a host used to be two
2661        // different-looking products sharing a suffix.
2662        s.push_str(&theme::css_for(&self.theme.read().await));
2663        s.push_str(DASHBOARD_CSS);
2664        s.push_str("</style>");
2665
2666        // In the head, so nothing below is ever painted on the way past. The id is thirty-two
2667        // letters or it is not here at all, which is what makes putting it inside a script
2668        // safe without escaping -- see `extension_id`.
2669        if let Some(id) = dashboard {
2670            s.push_str("<script>location.replace(\"chrome-extension://");
2671            s.push_str(id);
2672            s.push_str("/dashboard.html\")</script>");
2673        }
2674        s.push_str("</head><body><h1>ssh-browser</h1><div id=\"view\">");
2675
2676        if let Some(id) = dashboard {
2677            // Read only by somebody the browser refused that navigation to, which it does
2678            // unless the extension lists this exact origin in `web_accessible_resources` --
2679            // so a custom suffix arrives here. Same address, for a reader to take by hand.
2680            s.push_str("<p class=\"note\">The dashboard is at <a href=\"chrome-extension://");
2681            s.push_str(id);
2682            s.push_str("/dashboard.html\">chrome-extension://");
2683            s.push_str(id);
2684            s.push_str("/dashboard.html</a>.</p>");
2685        }
2686
2687        s.push_str("<h2>sites</h2>");
2688
2689        if sites.is_empty() {
2690            s.push_str(
2691                "<p class=\"empty\">Nothing is being served yet. \
2692                 Open the ssh-browser dashboard to pick a host.</p>",
2693            );
2694        } else {
2695            s.push_str("<ul>");
2696            for (alias, host, base) in &sites {
2697                let href = escape(&self.site_url(alias));
2698                s.push_str("<li><a data-alias=\"");
2699                // The same attribute the dashboard's card carries, so one selector finds the
2700                // card on either page -- which is how the two are checked against each other.
2701                s.push_str(&escape(alias));
2702                s.push_str("\" href=\"");
2703                s.push_str(&href);
2704                s.push_str("\"><div class=\"name\">");
2705                s.push_str(&escape(alias));
2706                s.push_str("</div><div class=\"url\">");
2707                s.push_str(&href);
2708                s.push_str("</div><div class=\"where\">");
2709                s.push_str(&escape(&format!("{host}:{base}")));
2710                s.push_str("</div></a></li>");
2711            }
2712            s.push_str("</ul>");
2713        }
2714
2715        if !down.is_empty() {
2716            s.push_str("<h2>not connected</h2><ul>");
2717            for (alias, why) in &down {
2718                // Still a link. Opening it is the retry -- a request is what dials -- so the
2719                // thing to do about it is the thing that was already there.
2720                s.push_str("<li><a data-alias=\"");
2721                s.push_str(&escape(alias));
2722                s.push_str("\" href=\"");
2723                s.push_str(&escape(&self.site_url(alias)));
2724                s.push_str("\"><div class=\"name\">");
2725                s.push_str(&escape(alias));
2726                s.push_str("</div><div class=\"bad-host\">");
2727                s.push_str(&escape(why));
2728                s.push_str("</div></a></li>");
2729            }
2730            s.push_str("</ul>");
2731        }
2732
2733        s.push_str(
2734            "<p class=\"note\">Each of these is its own origin, which is what this page is a \
2735             list of. Opening one that is not connected is what retries it. Serving a host and \
2736             stopping one happen in the dashboard: those go through the daemon's control API, \
2737             and no page reaches that.</p>\
2738             </div></body></html>",
2739        );
2740        s
2741    }
2742}
2743
2744/// Every step from the alias base down to the file, as `(directory to list, name to
2745/// check inside it)`, base first.
2746///
2747/// The base is the first directory listed and is never itself a checked name: it is
2748/// operator configuration, not something a request reaches.
2749fn components(base: &str, file: &str) -> Vec<(String, String)> {
2750    let base = base.trim_end_matches('/');
2751    let relative = file
2752        .strip_prefix(base)
2753        .unwrap_or("")
2754        .trim_start_matches('/');
2755
2756    let mut out = Vec::new();
2757    let mut dir = base.to_string();
2758    for name in relative.split('/').filter(|s| !s.is_empty()) {
2759        out.push((dir.clone(), name.to_string()));
2760        dir = format!("{dir}/{name}");
2761    }
2762    out
2763}
2764
2765/// A control body is a host name or a theme name, not a file upload.
2766///
2767/// `Limited` errors once the cap is passed rather than truncating, so a body that was too
2768/// large cannot be quietly parsed as a shorter one.
2769const MAX_CONTROL_BODY: usize = 256 * 1024;
2770
2771async fn read_body<B>(body: B) -> Result<Bytes, String>
2772where
2773    B: hyper::body::Body,
2774    B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
2775{
2776    use http_body_util::{BodyExt, Limited};
2777    Limited::new(body, MAX_CONTROL_BODY)
2778        .collect()
2779        .await
2780        .map(|collected| collected.to_bytes())
2781        .map_err(|e| format!("reading the request body: {e}"))
2782}
2783
2784fn header<B>(req: &Request<B>, name: HeaderName) -> Option<String> {
2785    req.headers()
2786        .get(name)
2787        .and_then(|v| v.to_str().ok())
2788        .map(str::to_string)
2789}
2790
2791/// Serve a body already in hand, whole or sliced.
2792fn respond(
2793    file: &str,
2794    body: Bytes,
2795    tag: Option<&str>,
2796    wanted: &range::Resolved,
2797    size: u64,
2798) -> Response<Full<Bytes>> {
2799    match wanted {
2800        range::Resolved::Part { start, end } => {
2801            // Clamped against the body actually held rather than the advertised size,
2802            // so a listing that disagrees with the file cannot panic the slice.
2803            let lo = usize::try_from(*start)
2804                .unwrap_or(usize::MAX)
2805                .min(body.len());
2806            let hi = usize::try_from(end.saturating_add(1))
2807                .unwrap_or(usize::MAX)
2808                .min(body.len())
2809                .max(lo);
2810            partial(
2811                mime::guess(file),
2812                body.slice(lo..hi),
2813                tag,
2814                *start,
2815                *end,
2816                size,
2817            )
2818        }
2819        _ => served(mime::guess(file), body, tag),
2820    }
2821}
2822
2823fn partial(
2824    content_type: &str,
2825    body: Bytes,
2826    tag: Option<&str>,
2827    start: u64,
2828    end: u64,
2829    size: u64,
2830) -> Response<Full<Bytes>> {
2831    let mut b = Response::builder()
2832        .status(StatusCode::PARTIAL_CONTENT)
2833        .header(CONTENT_TYPE, content_type)
2834        .header(CACHE_CONTROL, "no-cache")
2835        .header(ACCEPT_RANGES, "bytes")
2836        .header(CONTENT_RANGE, format!("bytes {start}-{end}/{size}"));
2837    if let Some(tag) = tag {
2838        b = b.header(ETAG, tag);
2839    }
2840    b.body(Full::new(body))
2841        .unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR, "malformed 206"))
2842}
2843
2844/// A 416 has to carry the real size, or a client cannot work out what it should have
2845/// asked for instead.
2846fn unsatisfiable(size: u64) -> Response<Full<Bytes>> {
2847    Response::builder()
2848        .status(StatusCode::RANGE_NOT_SATISFIABLE)
2849        .header(CONTENT_TYPE, "text/plain; charset=utf-8")
2850        .header(CONTENT_RANGE, format!("bytes */{size}"))
2851        .body(Full::new(Bytes::from_static(b"range not satisfiable")))
2852        .unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR, "malformed 416"))
2853}
2854
2855fn host_of<B>(req: &Request<B>) -> Option<String> {
2856    // A proxied request has an absolute-form target; a direct one only has the
2857    // header. Prefer the header, since that is what the browser actually sent.
2858    req.headers()
2859        .get(HOST)
2860        .and_then(|v| v.to_str().ok())
2861        .map(str::to_string)
2862        .or_else(|| req.uri().host().map(str::to_string))
2863}
2864
2865fn served(content_type: &str, body: Bytes, tag: Option<&str>) -> Response<Full<Bytes>> {
2866    let mut b = Response::builder()
2867        .status(StatusCode::OK)
2868        .header(CONTENT_TYPE, content_type)
2869        // `no-cache` means revalidate, not "do not store". With an ETag attached
2870        // that revalidation is a 304 answered from the listing cache, so the
2871        // browser keeps its copy and the remote is never touched.
2872        .header(CACHE_CONTROL, "no-cache")
2873        // Advertised on every full response: a client that does not know ranges are
2874        // available will never try to seek.
2875        .header(ACCEPT_RANGES, "bytes");
2876    if let Some(tag) = tag {
2877        b = b.header(ETAG, tag);
2878    }
2879    b.body(Full::new(body))
2880        .unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR, "malformed response"))
2881}
2882
2883/// For responses with no validator to offer: the PAC, the alias index, a listing.
2884fn plain_ok(content_type: &str, body: Bytes) -> Response<Full<Bytes>> {
2885    served(content_type, body, None)
2886}
2887
2888/// One of the daemon's own pages, which no other page may frame.
2889///
2890/// Everything else served here is somebody's file, and whether it may be framed is their
2891/// business. This is ours, and it is the one page that says what is being served and where
2892/// it is rooted. A page on `evil.<suffix>` cannot read it — different origin, and no CORS
2893/// header is sent — but without this it could put it under a transparent overlay, which is
2894/// the attack that does not need to read anything.
2895fn own_page(body: String) -> Response<Full<Bytes>> {
2896    let mut res = plain_ok("text/html; charset=utf-8", Bytes::from(body));
2897    res.headers_mut().insert(
2898        CONTENT_SECURITY_POLICY,
2899        HeaderValue::from_static("frame-ancestors 'none'"),
2900    );
2901    res
2902}
2903
2904/// No `Last-Modified` anywhere, deliberately.
2905///
2906/// Emitting it would oblige us to honour `If-Modified-Since`, whose comparison is
2907/// second-resolution -- the same resolution SFTP reports mtime at, which is exactly
2908/// where it stops being able to tell two versions apart. The ETag carries the same
2909/// information without that ambiguity, so it is the only validator offered.
2910fn not_modified(tag: &str) -> Response<Full<Bytes>> {
2911    Response::builder()
2912        .status(StatusCode::NOT_MODIFIED)
2913        .header(ETAG, tag)
2914        .header(CACHE_CONTROL, "no-cache")
2915        .body(Full::new(Bytes::new()))
2916        .unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR, "malformed 304"))
2917}
2918
2919fn fail(status: StatusCode, detail: impl Into<String>) -> Response<Full<Bytes>> {
2920    Response::builder()
2921        .status(status)
2922        .header(CONTENT_TYPE, "text/plain; charset=utf-8")
2923        .body(Full::new(Bytes::from(detail.into())))
2924        .expect("a plain-text body with static headers always builds")
2925}
2926
2927fn redirect(to: &str) -> Response<Full<Bytes>> {
2928    Response::builder()
2929        .status(StatusCode::MOVED_PERMANENTLY)
2930        .header(LOCATION, to)
2931        .body(Full::new(Bytes::new()))
2932        .unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR, "bad redirect target"))
2933}
2934
2935/// Listing for a directory that has no index.html.
2936/// A name this daemon will not serve.
2937///
2938/// The ssh_config entry a remembered name refers to.
2939///
2940/// Matched on either spelling, because the two are not always the same thing: what is written
2941/// down is the URL *label*, which is lowercase because it becomes a hostname, and the
2942/// `Host` in ssh_config it came from may be `Panza` where the label is `panza`.
2943///
2944/// Getting this wrong is not a near miss. Dialling the label produced `Could not resolve
2945/// hostname panza` on every start, while turning the host on had worked a moment earlier —
2946/// because that path had the ssh_config entry in hand and startup had only the name. No test
2947/// against a fake remote could have found it; running it twice did.
2948fn entry_for<'a>(known: &'a [ssh_config::Host], name: &str) -> Option<&'a ssh_config::Host> {
2949    known
2950        .iter()
2951        .find(|h| h.alias == name || h.host.eq_ignore_ascii_case(name))
2952}
2953
2954/// Completed and failed TLS handshakes since this daemon started.
2955///
2956/// Two counters rather than a single "is it trusted" flag, because the two facts are different
2957/// and a reader needs both: a failure says the step was missed, and a success says it was taken.
2958/// A flag would have to pick one moment to believe.
2959#[derive(Debug, Default)]
2960struct Handshakes {
2961    completed: std::sync::atomic::AtomicU64,
2962    failed: std::sync::atomic::AtomicU64,
2963}
2964
2965/// A rustls configuration that mints a certificate for whatever name the handshake asks for.
2966///
2967/// Per name rather than one wildcard, because a wildcard does not work: `*.ssh-browser` is
2968/// refused by Chromium with `ERR_CERT_COMMON_NAME_INVALID`, since the suffix is not a known
2969/// registry and a wildcard directly beneath one reads as covering a whole top-level domain. A
2970/// certificate naming the alias outright is accepted, and the origin is then a real secure
2971/// context — service workers, `crypto.subtle` and all.
2972fn serving_config(suffix: &str) -> Result<(rustls::ServerConfig, Arc<PerName>, String)> {
2973    let (authority, found) = tls::load_or_create_reporting(suffix)?;
2974    let resolver = Arc::new(PerName {
2975        authority: Arc::new(authority),
2976        minted: Mutex::new(HashMap::new()),
2977    });
2978
2979    // Said on every https run, not only the first. Nothing portable can ask a trust store
2980    // whether this is still in it, and the failure when it is not — `ERR_CERT_AUTHORITY_INVALID`
2981    // on a URL the daemon itself just advertised — contains no hint that a command exists. A
2982    // returning reader can skim two lines; a first-time reader cannot recover without them.
2983    let path = tls::certificate_path()
2984        .map(|p| p.display().to_string())
2985        .unwrap_or_else(|| "the state directory".to_string());
2986    // A list of lines rather than one string with escapes in it. The last attempt at the latter
2987    // shipped a banner carrying the source file's own indentation into the middle of a sentence.
2988    let lines: Vec<String> = match found {
2989        tls::Found::Created => vec![
2990            "this run made a local certificate authority, and nothing trusts it yet.".to_string(),
2991            "Until it is, the browser will refuse every page here:".to_string(),
2992            String::new(),
2993            "  ssh-browser trust".to_string(),
2994            String::new(),
2995            "prints the command for your platform, and the one that undoes it.".to_string(),
2996            format!("The certificate is {path}"),
2997        ],
2998        tls::Found::Existing => vec![
2999            "https: if the browser refuses a page, the local authority is not trusted.".to_string(),
3000            "`ssh-browser trust` prints how.".to_string(),
3001            format!("The certificate is {path}"),
3002        ],
3003    };
3004    let advice = lines.join("\n");
3005
3006    // No client authentication: the browser is not asked to prove anything, because nothing here
3007    // decides what to serve based on who is asking. The token does that, on the control API.
3008    let mut config = rustls::ServerConfig::builder()
3009        .with_no_client_auth()
3010        .with_cert_resolver(Arc::clone(&resolver) as Arc<dyn rustls::server::ResolvesServerCert>);
3011    // The browser asked for https, so http/1.1 is what is spoken inside the tunnel. Advertised
3012    // rather than left to chance: without it a browser may negotiate h2, which nothing here
3013    // serves, and the failure is a dead connection rather than a refusal.
3014    config.alpn_protocols = vec![b"http/1.1".to_vec()];
3015    Ok((config, resolver, advice))
3016}
3017
3018/// Signs a certificate the first time each name is asked for, and remembers it.
3019///
3020/// Remembered because a handshake must not wait on key generation twice for the same site, and
3021/// because a browser opening a page and its subresources will handshake more than once.
3022///
3023/// The cache is also the *only* source of truth for what is being served, which
3024/// `/_control/certificate` reads. Minting a second certificate to answer that question is what the
3025/// first version did, and it produced a key pin for a certificate no browser would ever see: the
3026/// right length, the right shape, and never a match. The e2e caught it on the first run.
3027struct PerName {
3028    authority: Arc<tls::Authority>,
3029    minted: Mutex<HashMap<String, Minted>>,
3030}
3031
3032/// One name's certificate, in all three forms anything here needs.
3033#[derive(Clone)]
3034struct Minted {
3035    key: Arc<rustls::sign::CertifiedKey>,
3036    certificate_pem: String,
3037    /// Base64 of the SHA-256 of the public key info, which is what a browser takes as a pin.
3038    pin: String,
3039}
3040
3041impl PerName {
3042    /// The certificate for this name, signing one if there is not one yet.
3043    ///
3044    /// `None` for a name the authority may not vouch for, which is also what a handshake gets.
3045    fn certificate_for(&self, name: &str) -> Option<Minted> {
3046        if let Some(found) = self
3047            .minted
3048            .lock()
3049            .ok()
3050            .and_then(|held| held.get(name).cloned())
3051        {
3052            return Some(found);
3053        }
3054
3055        // `leaf_for` refuses anything that is not a single label under the suffix, so this is also
3056        // the point where a request for somebody else's name stops. The name constraint in the
3057        // authority would stop it again at the verifier, but failing here means never signing it.
3058        let leaf = self.authority.leaf_for(name).ok()?;
3059        let certs =
3060            rustls_pki_types::CertificateDer::pem_slice_iter(leaf.certificate_pem.as_bytes())
3061                .collect::<std::result::Result<Vec<_>, _>>()
3062                .ok()?;
3063        let key = rustls_pki_types::PrivateKeyDer::from_pem_slice(leaf.key_pem.as_bytes()).ok()?;
3064        let signing = rustls::crypto::ring::default_provider()
3065            .key_provider
3066            .load_private_key(key)
3067            .ok()?;
3068        let minted = Minted {
3069            key: Arc::new(rustls::sign::CertifiedKey::new(certs, signing)),
3070            pin: tls::spki_pin(&leaf.certificate_pem).ok()?,
3071            certificate_pem: leaf.certificate_pem,
3072        };
3073
3074        // Whoever got here first wins, and the loser's certificate is dropped rather than
3075        // replacing it. Two valid certificates for one name would both work, but only one of them
3076        // is the one `/_control/certificate` reported.
3077        if let Ok(mut held) = self.minted.lock() {
3078            return Some(held.entry(name.to_string()).or_insert(minted).clone());
3079        }
3080        Some(minted)
3081    }
3082}
3083
3084impl std::fmt::Debug for PerName {
3085    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3086        // Hand-written because `Authority` holds a private key and deriving this would put it one
3087        // `{:?}` away from a log line.
3088        f.debug_struct("PerName")
3089            .field("suffix", &self.authority.suffix())
3090            .finish()
3091    }
3092}
3093
3094impl rustls::server::ResolvesServerCert for PerName {
3095    fn resolve(
3096        &self,
3097        hello: rustls::server::ClientHello<'_>,
3098    ) -> Option<Arc<rustls::sign::CertifiedKey>> {
3099        // No SNI means no name to vouch for. Returning nothing fails the handshake, which is the
3100        // honest answer: a certificate for a name the client did not ask about would be rejected
3101        // by the client anyway, and more confusingly.
3102        let name = hello.server_name()?;
3103        Some(self.certificate_for(name)?.key)
3104    }
3105}
3106
3107/// Anything beginning with a dot. An alias base is one origin, so a page under it can read
3108/// everything else under it with `fetch` — the base is the blast radius. On a home directory
3109/// almost everything worth stealing sits behind a dot: `.ssh`, `.aws`, `.netrc`, a `.git`
3110/// whose remote URL carries a token. Refusing them costs a reader nearly nothing, and it is
3111/// what makes pointing an alias at a home directory a reasonable thing to do at all.
3112fn hidden(name: &str) -> bool {
3113    name.starts_with('.')
3114}
3115
3116/// One entry of a directory, ready to be written into a row.
3117struct Row {
3118    name: String,
3119    dir: bool,
3120    /// Holds an `index.html`, so it is a site rather than a folder.
3121    site: bool,
3122    /// Absent for a directory, whose own size is its bookkeeping rather than its contents'.
3123    size: Option<String>,
3124    modified: Option<String>,
3125    /// Which colour its marker takes.
3126    kind: &'static str,
3127}
3128
3129/// The rows of one directory, sorted and with the dot-names already gone.
3130fn rows_of(entries: &[Entry], sites: &HashSet<String>) -> Vec<Row> {
3131    let mut visible: Vec<&Entry> = entries
3132        .iter()
3133        // `.` and `..` are already gone by the time a path resolves; these are the real
3134        // dot-names. Listing what the next click would be refused is worse than silence.
3135        .filter(|e| e.name != "." && e.name != ".." && !hidden(&e.name))
3136        .collect();
3137    visible.sort_by(|a, b| (rank(a, sites), &a.name).cmp(&(rank(b, sites), &b.name)));
3138
3139    visible
3140        .into_iter()
3141        .map(|e| {
3142            let dir = e.attrs.is_dir();
3143            Row {
3144                name: e.name.clone(),
3145                dir,
3146                site: dir && sites.contains(&e.name),
3147                size: if dir {
3148                    None
3149                } else {
3150                    e.attrs.size.map(human_size)
3151                },
3152                modified: e.attrs.mtime.map(utc_stamp),
3153                kind: if dir { "dir" } else { family(&e.name) },
3154            }
3155        })
3156        .collect()
3157}
3158
3159/// Where an entry sorts, before its name is considered.
3160///
3161/// Directories first and no headings over them — souta's call, and it is how a file tree
3162/// has worked since long before anybody wrote one down. Within each half the thing you came
3163/// to open rises: a directory that *is* a page, and then an HTML file.
3164///
3165/// A Pinax board is `out/ft_demo/index.html`, so the directory holding it is what has to
3166/// rise. Sorting the HTML alone would never move anything, because the directory you are
3167/// standing in has no HTML in it at all.
3168fn rank(e: &Entry, sites: &HashSet<String>) -> (u8, u8) {
3169    if e.attrs.is_dir() {
3170        (0, u8::from(!sites.contains(&e.name)))
3171    } else {
3172        (1, u8::from(!is_page(&e.name)))
3173    }
3174}
3175
3176fn is_page(name: &str) -> bool {
3177    matches!(extension_of(name).as_deref(), Some("html" | "htm"))
3178}
3179
3180/// Which colour an entry's marker takes.
3181///
3182/// Families rather than extensions, because the point is to be readable without being read:
3183/// a `.toml` and a `.png` should not look the same, but `.toml` and `.json` may. This is
3184/// the one thing an editor's file tree does that a plain list does not.
3185fn family(name: &str) -> &'static str {
3186    match extension_of(name).as_deref() {
3187        Some("html" | "htm") => "k-page",
3188        Some("md" | "txt" | "rst" | "tex" | "bib" | "pdf" | "org" | "adoc") => "k-doc",
3189        Some("json" | "toml" | "yaml" | "yml" | "csv" | "tsv" | "xml" | "ini" | "lock") => "k-data",
3190        Some(
3191            "rs" | "jl" | "py" | "ts" | "js" | "mjs" | "sh" | "c" | "h" | "cpp" | "go" | "rb"
3192            | "lua" | "css" | "scss" | "lean" | "hs" | "java" | "kt" | "swift" | "sql",
3193        ) => "k-code",
3194        Some(
3195            "png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "avif" | "ico" | "mp4" | "webm"
3196            | "mov" | "mp3" | "wav",
3197        ) => "k-media",
3198        _ => "k-plain",
3199    }
3200}
3201
3202/// The lowercased extension, taken from the name.
3203fn extension_of(name: &str) -> Option<String> {
3204    let dot = name.rfind('.')?;
3205    // A leading dot is a hidden name rather than an extension, and a trailing one is not an
3206    // extension at all. Neither is served, but neither should be labelled as a type either.
3207    if dot == 0 || dot + 1 == name.len() {
3208        return None;
3209    }
3210    Some(name[dot + 1..].to_ascii_lowercase())
3211}
3212
3213/// Bytes, the way a file manager shows them.
3214///
3215/// Binary multiples with the labels that actually mean them. Calling 1024 bytes `kB` is the
3216/// lie everyone tells, and this is a tool for people who would notice.
3217fn human_size(n: u64) -> String {
3218    const UNITS: [&str; 5] = ["KiB", "MiB", "GiB", "TiB", "PiB"];
3219    if n < 1024 {
3220        return format!("{n} B");
3221    }
3222    let mut v = n as f64 / 1024.0;
3223    let mut unit = 0;
3224    while v >= 1024.0 && unit + 1 < UNITS.len() {
3225        v /= 1024.0;
3226        unit += 1;
3227    }
3228    // One decimal below ten and none above, so a column of sizes stays a column:
3229    // `9.4 MiB` and `312 MiB`, not `312.0 MiB`.
3230    if v < 10.0 {
3231        format!("{v:.1} {}", UNITS[unit])
3232    } else {
3233        format!("{v:.0} {}", UNITS[unit])
3234    }
3235}
3236
3237/// `2026-09-13 05:44`, in UTC.
3238///
3239/// UTC because it is the only thing that can be said truthfully. SFTP reports seconds since
3240/// the epoch and says nothing about a zone; the remote's zone is not something this
3241/// transport can ask for, and using *this* machine's would stamp a file with an offset
3242/// belonging to a different computer. The column says so once, in the footer.
3243fn utc_stamp(secs: u32) -> String {
3244    let secs = i64::from(secs);
3245    let (y, m, d) = civil_from_days(secs.div_euclid(86_400));
3246    let rest = secs.rem_euclid(86_400);
3247    let (hh, mm) = (rest / 3600, (rest % 3600) / 60);
3248    format!("{y:04}-{m:02}-{d:02} {hh:02}:{mm:02}")
3249}
3250
3251/// Howard Hinnant's `civil_from_days`: exact for every day this could be handed, and it
3252/// needs no calendar crate. Adding a dependency to print a date in a directory listing
3253/// would be a poor trade in a daemon that reads other people's filesystems.
3254fn civil_from_days(z: i64) -> (i64, u32, u32) {
3255    let z = z + 719_468;
3256    let era = if z >= 0 { z } else { z - 146_096 }.div_euclid(146_097);
3257    let doe = (z - era * 146_097) as u64;
3258    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
3259    let y = yoe as i64 + era * 400;
3260    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
3261    let mp = (5 * doy + 2) / 153;
3262    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
3263    let m = u32::try_from(if mp < 10 { mp + 3 } else { mp - 9 }).unwrap_or(1);
3264    (if m <= 2 { y + 1 } else { y }, m, d)
3265}
3266
3267/// The listing's layout.
3268///
3269/// Written entirely against the custom properties a theme supplies, so a new palette is a
3270/// new theme rather than a second copy of these rules. See `crate::theme`.
3271///
3272/// An editor's explorer: one line per entry, a twisty on the folders, an indent guide per
3273/// level, and a coloured chip for the type. souta asked for this twice — a flat list of one
3274/// directory is a listing, and what makes an explorer is the tree.
3275const LISTING_CSS: &str = "\
3276*{box-sizing:border-box}\
3277html{background:var(--bg)}\
3278body{color:var(--fg);font:13px/1.5 system-ui,-apple-system,Segoe UI,sans-serif;margin:0}\
3279header{align-items:baseline;background:var(--bg);border-bottom:1px solid var(--line);\
3280display:flex;gap:6px;padding:7px 12px;position:sticky;top:0;z-index:1}\
3281header b{font-size:12px;font-weight:600;letter-spacing:.04em}\
3282header .home{border-right:1px solid var(--line);color:var(--dim);font-size:11px;\
3283margin-right:6px;padding-right:8px;text-decoration:none;white-space:nowrap}\
3284header .home:hover{color:var(--accent)}\
3285header span{color:var(--dim);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;\
3286font-size:11px;overflow-wrap:anywhere}\
3287#tree{padding:4px 0 40px}\
3288ul{list-style:none;margin:0;padding:0}\
3289li ul{border-left:1px solid var(--line);margin-left:15px}\
3290li>ul{display:none}\
3291li.open>ul{display:block}\
3292.row{align-items:center;color:inherit;display:grid;gap:6px;\
3293grid-template-columns:14px 14px 1fr auto auto;line-height:22px;padding-right:12px;\
3294text-decoration:none;white-space:nowrap}\
3295.row:hover{background:var(--hover)}\
3296.row.here{background:var(--sel)}\
3297.row.here .size,.row.here .when{color:var(--dim)}\
3298.row:focus-visible{outline:1px solid var(--accent);outline-offset:-1px}\
3299.tw{color:var(--dim);font-size:11px;line-height:22px;text-align:center;\
3300transition:transform .1s linear}\
3301li.open>.row .tw{transform:rotate(90deg)}\
3302.ico{border-radius:2px;height:9px;justify-self:center;width:9px}\
3303.dir>.ico{background:var(--dim);border-radius:1px 3px 3px 3px}\
3304.site>.ico{background:var(--accent);border-radius:1px 3px 3px 3px}\
3305.site>.name{color:var(--accent)}\
3306.k-page>.ico{background:var(--k-page)}\
3307.k-page>.name{color:var(--k-page)}\
3308.k-doc>.ico{background:var(--k-doc)}\
3309.k-data>.ico{background:var(--k-data)}\
3310.k-code>.ico{background:var(--k-code)}\
3311.k-media>.ico{background:var(--k-media)}\
3312.k-plain>.ico{background:var(--k-plain)}\
3313.name{overflow:hidden;text-overflow:ellipsis}\
3314.size,.when{color:var(--faint);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;\
3315font-size:11px;font-variant-numeric:tabular-nums}\
3316.size{text-align:right}\
3317.row.busy .tw{opacity:.4}\
3318.row.failed .when{color:var(--k-page)}\
3319.empty{color:var(--faint);padding:10px 16px}\
3320@media(max-width:620px){.when{display:none}}";
3321
3322/// Expanding a folder, and nothing else.
3323///
3324/// The daemon's own page, so its script is the daemon's too — nothing is ever added to a
3325/// document the reader came for. It is small because the server renders the rows: this asks
3326/// for a level and puts it where it goes.
3327///
3328/// Without it every level costs a page load, and the tree still works that way: every row is
3329/// a real link to a real URL, so a browser with no script at all walks the tree one
3330/// directory at a time, exactly as the old listing did.
3331///
3332/// A site is the exception: a directory holding an `index.html` is somewhere to read, so
3333/// clicking its row follows the link and opens the page. Only its twisty looks inside. It used
3334/// to expand like any folder, and because a site answers with its page rather than a listing
3335/// the result was a rendered document spliced into the middle of a file tree — souta sent a
3336/// screenshot of their own gallery sitting under three rows of directory names.
3337const LISTING_JS: &str = "\
3338const tree=document.getElementById('tree');\
3339tree.addEventListener('click',async e=>{\
3340const row=e.target.closest('a.row');\
3341if(!row||row.dataset.dir!=='1')return;\
3342if(row.classList.contains('site')&&!e.target.closest('.tw'))return;\
3343e.preventDefault();\
3344const li=row.parentElement;\
3345if(li.querySelector(':scope>ul')){li.classList.toggle('open');mark(row);return;}\
3346row.classList.add('busy');\
3347try{\
3348const res=await fetch(row.getAttribute('href')+'?ls');\
3349if(!res.ok)throw new Error(res.status);\
3350li.insertAdjacentHTML('beforeend',await res.text());\
3351li.classList.add('open');mark(row);\
3352}catch(err){row.classList.add('failed');\
3353row.querySelector('.when').textContent='could not be listed: '+err.message;}\
3354finally{row.classList.remove('busy');}\
3355});\
3356function mark(row){\
3357for(const other of tree.querySelectorAll('a.row.here'))other.classList.remove('here');\
3358row.classList.add('here');\
3359history.replaceState(null,'',row.getAttribute('href'));\
3360document.querySelector('header span').textContent=\
3361decodeURIComponent(new URL(row.href).pathname);\
3362}";
3363
3364/// One level of the tree: a `<ul>` of rows, with the one on the path already expanded.
3365///
3366/// `open` is the rest of the path from here down, so a level knows which of its folders the
3367/// reader is inside. Empty means nothing below is expanded, which is what `?ls` hands back
3368/// for a folder somebody has just clicked.
3369fn render_level(out: &mut String, path: &str, rows: &[Row], open: &[(String, Vec<Row>)]) {
3370    out.push_str("<ul>");
3371    for row in rows {
3372        let here = format!("{path}/{}", row.name);
3373        let deeper = open.first().filter(|(next, _)| *next == here);
3374
3375        out.push_str(if deeper.is_some() {
3376            "<li class=\"open\">"
3377        } else {
3378            "<li>"
3379        });
3380        out.push_str("<a class=\"row ");
3381        out.push_str(match (row.dir, row.site) {
3382            // A directory holding an `index.html` is served *as* that page, so it is marked
3383            // as somewhere to read rather than somewhere to look.
3384            (true, true) => "site",
3385            (true, false) => "dir",
3386            (false, _) => row.kind,
3387        });
3388        // The deepest expanded folder is where the reader is, so the tree opens with it
3389        // selected the way an explorer shows the file you have open.
3390        if deeper.is_some() && open.len() == 1 {
3391            out.push_str(" here");
3392        }
3393        out.push_str("\" href=\"");
3394        out.push_str(path);
3395        out.push('/');
3396        out.push_str(&url_escape(&row.name));
3397        if row.dir {
3398            out.push('/');
3399        }
3400        // Read by the script to tell a folder from a file without picking through classes.
3401        out.push_str(if row.dir {
3402            "\" data-dir=\"1\"><span class=\"tw\">\u{25b8}</span>"
3403        } else {
3404            "\"><span class=\"tw\"></span>"
3405        });
3406        out.push_str("<span class=\"ico\"></span><span class=\"name\">");
3407        out.push_str(&escape(&row.name));
3408        out.push_str("</span><span class=\"size\">");
3409        out.push_str(row.size.as_deref().unwrap_or(""));
3410        out.push_str("</span><span class=\"when\">");
3411        out.push_str(row.modified.as_deref().unwrap_or(""));
3412        out.push_str("</span></a>");
3413
3414        if let Some((next, rows)) = deeper {
3415            render_level(out, next, rows, &open[1..]);
3416        }
3417        out.push_str("</li>");
3418    }
3419    out.push_str("</ul>");
3420}
3421
3422/// A directory, as a tree.
3423///
3424/// `levels` runs from the alias base down to where the reader is, each already sorted, so
3425/// the page opens with the whole path expanded and the rest of every level beside it. They
3426/// come out of the cache the path walk already filled, so the depth costs no round trips.
3427///
3428/// `home` is the root of the suffix, and the header links to it. A reader who has walked into
3429/// `panza.ssh-browser` has no way back to the list of sites: it is a different origin, so the
3430/// back button is the only route and only if they arrived by it. This is the daemon's own page
3431/// so a link on it costs nobody anything — and it stays off the pages that are somebody's
3432/// file, which get nothing added to them, ever.
3433fn autoindex(
3434    alias: &str,
3435    rel: &str,
3436    levels: &[(String, Vec<Row>)],
3437    theme: &str,
3438    home: &str,
3439) -> String {
3440    let shown = if rel.is_empty() { "/" } else { rel };
3441    let mut s = String::from("<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">");
3442    s.push_str("<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>");
3443    s.push_str(&escape(&format!("{shown} \u{b7} {alias}")));
3444    s.push_str("</title><style>");
3445    // The palette first, then the layout that reads it.
3446    s.push_str(&theme::css_for(theme));
3447    s.push_str(LISTING_CSS);
3448    s.push_str("</style></head><body><header><a class=\"home\" href=\"");
3449    s.push_str(&escape(home));
3450    // A word rather than a glyph. This header already carries an alias and a path in small
3451    // type; a house drawn in it would be one more thing to decode, and "all sites" says both
3452    // where it goes and what is there.
3453    s.push_str("\" title=\"every site this daemon serves\">all sites</a><b>");
3454    s.push_str(&escape(alias));
3455    s.push_str("</b><span>");
3456    s.push_str(&escape(shown));
3457    s.push_str("</span></header><div id=\"tree\">");
3458
3459    match levels.split_first() {
3460        Some(((path, rows), rest)) if !rows.is_empty() => render_level(&mut s, path, rows, rest),
3461        // An alias whose base holds nothing. Saying so beats a blank page, which reads as
3462        // something having gone wrong.
3463        _ => s.push_str("<p class=\"empty\">This directory is empty.</p>"),
3464    }
3465
3466    s.push_str("</div><script>");
3467    s.push_str(LISTING_JS);
3468    s.push_str("</script></body></html>");
3469    s
3470}
3471
3472/// Remote filenames are untrusted input that lands inside our own origin, so the
3473/// listing escapes them. Skipping this would be self-inflicted XSS.
3474fn escape(s: &str) -> String {
3475    s.replace('&', "&amp;")
3476        .replace('<', "&lt;")
3477        .replace('>', "&gt;")
3478        .replace('"', "&quot;")
3479}
3480
3481/// HTML-escaping is not enough inside an href: a space or a hash in a filename
3482/// would still produce a broken or a wrong link.
3483fn url_escape(s: &str) -> String {
3484    let mut out = String::with_capacity(s.len());
3485    for b in s.bytes() {
3486        match b {
3487            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
3488                out.push(b as char);
3489            }
3490            _ => out.push_str(&format!("%{b:02X}")),
3491        }
3492    }
3493    out
3494}
3495
3496#[cfg(test)]
3497mod tests {
3498    use super::*;
3499    use crate::sftp::wire::Attrs;
3500    use crate::testing::{FakeRemote, dir_attrs, file_attrs, symlink_attrs};
3501    use http_body_util::{BodyExt, Empty};
3502
3503    const TEST_TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
3504
3505    /// A real one, as Chrome derives them: thirty-two letters, none past `p`.
3506    const AN_EXTENSION: &str = "ndilofdepphikcodahklfjggbacbbhcc";
3507
3508    #[test]
3509    fn an_extension_names_itself() {
3510        assert_eq!(
3511            extension_id(Some(&format!("dashboard={AN_EXTENSION}"))).as_deref(),
3512            Some(AN_EXTENSION),
3513        );
3514        assert_eq!(
3515            extension_id(Some(&format!("x=1&dashboard={AN_EXTENSION}"))).as_deref(),
3516            Some(AN_EXTENSION),
3517        );
3518    }
3519
3520    #[test]
3521    fn nothing_named_is_nothing_stored() {
3522        assert_eq!(extension_id(None), None);
3523        assert_eq!(extension_id(Some("")), None);
3524        assert_eq!(extension_id(Some("dashboard=")), None);
3525    }
3526
3527    /// The letters `q` to `z`, digits, and capitals are not in the alphabet Chrome uses, so a
3528    /// string containing them is not an id. Kept as a test because the alphabet is the whole
3529    /// reason the value needs no escaping where it is used.
3530    #[test]
3531    fn a_string_outside_the_alphabet_is_not_an_id() {
3532        for bad in [
3533            "ndilofdepphikcodahklfjggbacbbhcz", // z
3534            "ndilofdepphikcodahklfjggbacbbhc1", // a digit
3535            "ndilofdepphikcodahklfjggbacbbhcC", // a capital
3536        ] {
3537            assert_eq!(
3538                extension_id(Some(&format!("dashboard={bad}"))),
3539                None,
3540                "{bad}"
3541            );
3542        }
3543    }
3544
3545    #[test]
3546    fn a_wrong_length_is_not_an_id() {
3547        for bad in [&AN_EXTENSION[..31], &format!("{AN_EXTENSION}a")[..]] {
3548            assert_eq!(
3549                extension_id(Some(&format!("dashboard={bad}"))),
3550                None,
3551                "{bad}"
3552            );
3553        }
3554    }
3555
3556    /// The point of the whole check. This lands inside a `<script>` on the daemon's own page,
3557    /// so anything that could close the string and keep going has to be refused rather than
3558    /// escaped -- escaping is a thing one can forget, and a fixed alphabet is not.
3559    #[test]
3560    fn nothing_that_could_end_the_script_gets_through() {
3561        for bad in [
3562            "a\");alert(1);//aaaaaaaaaaaaaaaa",
3563            "aaaaaaaaaaaaaaaa</script><script>",
3564            "../../../../../../../../../etc/p",
3565        ] {
3566            assert_eq!(
3567                extension_id(Some(&format!("dashboard={bad}"))),
3568                None,
3569                "{bad}"
3570            );
3571        }
3572    }
3573
3574    async fn body_of(res: Response<Full<Bytes>>) -> Bytes {
3575        res.into_body()
3576            .collect()
3577            .await
3578            .expect("a Full body always collects")
3579            .to_bytes()
3580    }
3581
3582    /// A request arriving by address rather than through the PAC.
3583    fn loopback(path: &str, token: Option<&str>) -> Request<Empty<Bytes>> {
3584        let mut b = Request::builder().uri(path).header(HOST, "127.0.0.1:7391");
3585        if let Some(t) = token {
3586            b = b.header(control::TOKEN_HEADER, t);
3587        }
3588        b.body(Empty::<Bytes>::new()).expect("request builds")
3589    }
3590
3591    /// A loopback request carrying no token, and whatever the browser would have said
3592    /// about who started it.
3593    fn from_site(path: &str, site: Option<&str>) -> Request<Empty<Bytes>> {
3594        let mut b = Request::builder().uri(path).header(HOST, "127.0.0.1:7391");
3595        if let Some(site) = site {
3596            b = b.header(control::FETCH_SITE_HEADER, site);
3597        }
3598        b.body(Empty::<Bytes>::new()).expect("request builds")
3599    }
3600
3601    fn control_post(path: &str, token: Option<&str>, body: &str) -> Request<Full<Bytes>> {
3602        let mut b = Request::builder()
3603            .method(Method::POST)
3604            .uri(path)
3605            .header(HOST, "127.0.0.1:7391");
3606        if let Some(t) = token {
3607            b = b.header(control::TOKEN_HEADER, t);
3608        }
3609        b.body(Full::new(Bytes::from(body.to_string())))
3610            .expect("request builds")
3611    }
3612
3613    fn ranged(path: &str, range: &str) -> Request<Empty<Bytes>> {
3614        Request::builder()
3615            .uri(format!("http://docs.ssh-browser{path}"))
3616            .header(HOST, "docs.ssh-browser")
3617            .header(RANGE, range)
3618            .body(Empty::new())
3619            .expect("request builds")
3620    }
3621
3622    /// Build an origin over an in-memory remote. The session is a real `SftpFs`, so
3623    /// the round trips counted below are the same ones production would pay.
3624    async fn origin_with(remote: FakeRemote) -> Origin {
3625        origin_with_cache(remote, Cache::default()).await
3626    }
3627
3628    async fn origin_with_cache(remote: FakeRemote, cache: Cache) -> Origin {
3629        origin_over(remote.spawn().await, cache)
3630    }
3631
3632    /// The same origin, over a connection the caller made -- so it can also end it.
3633    fn origin_over(fs: SftpFs, cache: Cache) -> Origin {
3634        let mut sessions = HashMap::new();
3635        sessions.insert(
3636            "docs".to_string(),
3637            Arc::new(Session {
3638                host: "nowhere".to_string(),
3639                base: "/srv".to_string(),
3640                fs,
3641            }),
3642        );
3643        Origin {
3644            suffix: "ssh-browser".to_string(),
3645            // http, because these tests drive `handle` directly and never open a socket. An
3646            // https origin here would only change the URLs printed in a listing.
3647            scheme: "http".to_string(),
3648            certificates: None,
3649            tls: None,
3650            port: 7391,
3651            sessions: RwLock::new(sessions),
3652            cache,
3653            theme: RwLock::new(theme::DEFAULT.to_string()),
3654            token: Token::from_hex(TEST_TOKEN),
3655            // Empty on purpose: these tests build their session map directly, so nothing here
3656            // should be opening anything behind their backs.
3657            reachable: RwLock::new(reachable::Set::default()),
3658            handshakes: Handshakes::default(),
3659            dashboard: RwLock::new(None),
3660            // Declared as well as connected, because that is what production looks like and
3661            // because the reconnect path reads it: an alias whose session dies has to be
3662            // re-dialled from somewhere, and a test whose `docs` was connected but never
3663            // declared would exercise the half of `session_for` that cannot retry.
3664            declared: HashMap::from([(
3665                "docs".to_string(),
3666                Declared {
3667                    host: "nowhere".to_string(),
3668                    base: Some("/srv".to_string()),
3669                    named: Named::InTheFile,
3670                    dialling: tokio::sync::Mutex::new(()),
3671                },
3672            )]),
3673            trouble: RwLock::new(HashMap::new()),
3674            stopped: RwLock::new(HashSet::new()),
3675            cooldown: DIAL_COOLDOWN,
3676        }
3677    }
3678
3679    fn get(path: &str, if_none_match: Option<&str>) -> Request<Empty<Bytes>> {
3680        let mut b = Request::builder()
3681            .uri(format!("http://docs.ssh-browser{path}"))
3682            .header(HOST, "docs.ssh-browser");
3683        if let Some(tag) = if_none_match {
3684            b = b.header(IF_NONE_MATCH, tag);
3685        }
3686        b.body(Empty::new()).expect("request builds")
3687    }
3688
3689    fn get_on(alias: &str, path: &str) -> Request<Empty<Bytes>> {
3690        Request::builder()
3691            .uri(format!("http://{alias}.ssh-browser{path}"))
3692            .header(HOST, format!("{alias}.ssh-browser"))
3693            .body(Empty::new())
3694            .expect("request builds")
3695    }
3696
3697    async fn trips(origin: &Origin) -> u64 {
3698        origin
3699            .sessions
3700            .read()
3701            .await
3702            .values()
3703            .map(|s| s.fs.round_trips())
3704            .sum()
3705    }
3706
3707    fn one_page() -> FakeRemote {
3708        FakeRemote::new()
3709            .dir("/srv", vec![("a.html", file_attrs(5, 100))])
3710            .file("/srv/a.html", b"hello")
3711    }
3712
3713    /// A page with subresources in a sibling directory, which is the shape a generated
3714    /// report has: one HTML file and an `assets/` beside it.
3715    fn page_with_subresources(n: usize) -> FakeRemote {
3716        let mut html = String::from(
3717            "<!doctype html><html><head><link rel=\"stylesheet\" href=\"assets/style.css\"><script src=\"assets/app.js\"></script></head><body>",
3718        );
3719        for i in 0..n {
3720            html.push_str(&format!("<img src=\"assets/{i}.png\">"));
3721        }
3722        html.push_str("</body></html>");
3723
3724        let mut assets = vec!["style.css".to_string(), "app.js".to_string()];
3725        assets.extend((0..n).map(|i| format!("{i}.png")));
3726
3727        let mut remote = FakeRemote::new()
3728            .dir(
3729                "/srv",
3730                vec![
3731                    ("index.html", file_attrs(html.len() as u64, 100)),
3732                    ("assets", dir_attrs()),
3733                ],
3734            )
3735            .dir(
3736                "/srv/assets",
3737                assets
3738                    .iter()
3739                    .map(|name| (name.as_str(), file_attrs(3, 1)))
3740                    .collect(),
3741            )
3742            .file("/srv/index.html", html.as_bytes());
3743        for name in &assets {
3744            remote = remote.file(&format!("/srv/assets/{name}"), b"xxx");
3745        }
3746        remote
3747    }
3748
3749    /// The subresource half of invariant 1, which is about the browser rather than the
3750    /// remote. HTTP/1.1 allows six connections per origin, so forty subresources are seven
3751    /// waves of requests and each wave the browser has to discover is a round trip.
3752    ///
3753    /// Asking for them one at a time is the worst case any browser can produce. If that
3754    /// costs nothing, no arrangement of waves can cost anything either.
3755    #[tokio::test]
3756    async fn a_pages_subresources_are_already_held_when_the_browser_asks_for_them() {
3757        const N: usize = 40;
3758        let origin = origin_with(page_with_subresources(N)).await;
3759
3760        let res = origin.handle(get("/index.html", None)).await;
3761        assert_eq!(res.status(), StatusCode::OK);
3762
3763        let before = trips(&origin).await;
3764        for i in 0..N {
3765            let path = format!("/assets/{i}.png");
3766            let res = origin.handle(get(&path, None)).await;
3767            assert_eq!(res.status(), StatusCode::OK, "{path}");
3768            assert_eq!(&body_of(res).await[..], b"xxx", "{path}");
3769        }
3770        for name in ["style.css", "app.js"] {
3771            let res = origin.handle(get(&format!("/assets/{name}"), None)).await;
3772            assert_eq!(res.status(), StatusCode::OK, "{name}");
3773        }
3774
3775        assert_eq!(
3776            trips(&origin).await - before,
3777            0,
3778            "reading the page's own references is what makes these free"
3779        );
3780    }
3781
3782    /// And the page itself does not get more expensive as it gains subresources: the
3783    /// listings are one batch and the reads are another, whatever the count.
3784    #[tokio::test]
3785    async fn serving_a_page_costs_the_same_however_many_subresources_it_has() {
3786        async fn cost(n: usize) -> u64 {
3787            let origin = origin_with(page_with_subresources(n)).await;
3788            let before = trips(&origin).await;
3789            let res = origin.handle(get("/index.html", None)).await;
3790            assert_eq!(res.status(), StatusCode::OK);
3791            trips(&origin).await - before
3792        }
3793        assert_eq!(cost(4).await, cost(40).await);
3794    }
3795
3796    /// One HTML page naming whatever it likes, for the two tests below. The page is
3797    /// untrusted input, and prefetching is the first thing in this daemon that acts on what
3798    /// a page says rather than on what the reader asked for.
3799    fn page_referring_to(refs: &[&str], extra: Vec<(&'static str, Attrs)>) -> FakeRemote {
3800        let mut html = String::from("<!doctype html><html><body>");
3801        for r in refs {
3802            html.push_str(&format!("<img src=\"{r}\">"));
3803        }
3804        html.push_str("</body></html>");
3805
3806        let mut entries = vec![("index.html", file_attrs(html.len() as u64, 100))];
3807        entries.extend(extra);
3808        FakeRemote::new()
3809            .dir("/srv", entries)
3810            .file("/srv/index.html", html.as_bytes())
3811    }
3812
3813    async fn cost_of_serving(refs: &[&str], extra: Vec<(&'static str, Attrs)>) -> u64 {
3814        let origin = origin_with(page_referring_to(refs, extra)).await;
3815        let before = trips(&origin).await;
3816        let res = origin.handle(get("/index.html", None)).await;
3817        assert_eq!(res.status(), StatusCode::OK);
3818        trips(&origin).await - before
3819    }
3820
3821    /// A reference that climbs out of the alias base must not be read. The check is the
3822    /// same `resolve` the request path uses, not a second copy that could drift from it.
3823    #[tokio::test]
3824    async fn a_page_cannot_prefetch_its_way_out_of_the_alias_base() {
3825        let baseline = cost_of_serving(&[], vec![]).await;
3826        assert_eq!(
3827            cost_of_serving(&["../../../etc/passwd", "/../../etc/shadow"], vec![]).await,
3828            baseline,
3829            "an escaping reference is gone before anything is listed or read"
3830        );
3831    }
3832
3833    /// Nor through a symlink — and not even as far as listing it. A page that could get the
3834    /// directory a symlink points at listed would have defeated the rule by naming it.
3835    #[tokio::test]
3836    async fn a_page_cannot_prefetch_through_a_symlink() {
3837        let link = || vec![("link", symlink_attrs())];
3838        let baseline = cost_of_serving(&[], link()).await;
3839        assert_eq!(
3840            cost_of_serving(&["link/inside.png"], link()).await,
3841            baseline,
3842            "the symlink is known from the listing the page itself needed"
3843        );
3844
3845        // And the ordinary request for it is still refused, which is the guarantee the
3846        // prefetcher is being held to rather than a separate one.
3847        let origin = origin_with(page_referring_to(&["link/inside.png"], link())).await;
3848        assert_eq!(
3849            origin.handle(get("/index.html", None)).await.status(),
3850            StatusCode::OK
3851        );
3852        assert_eq!(
3853            origin.handle(get("/link/inside.png", None)).await.status(),
3854            StatusCode::FORBIDDEN
3855        );
3856    }
3857
3858    /// The hole the shallow symlink test did not cover: a symlink one level below the
3859    /// deepest listing the cache holds.
3860    ///
3861    /// `first_symlink` can only see what is cached, so at the moment the batch is assembled
3862    /// it has no opinion about `assets/link` — and the batch that would tell it includes the
3863    /// symlink's own path. SFTP v3 `OPENDIR` has no `O_NOFOLLOW`, so the remote resolves it
3864    /// and hands back a listing of wherever it points. Nothing is ever served through it,
3865    /// but the daemon has already read it, which is the act the alias base exists to forbid.
3866    ///
3867    /// Round trips cannot detect this — `list_dirs` is one flush however many directories
3868    /// are in it — so the assertion is on what the cache ends up holding.
3869    #[tokio::test]
3870    async fn a_page_cannot_get_a_symlink_below_an_unlisted_directory_opened() {
3871        let html = "<!doctype html><html><body><img src=\"assets/link/secret.txt\"></body></html>";
3872        let origin = origin_with(
3873            FakeRemote::new()
3874                .dir(
3875                    "/srv",
3876                    vec![
3877                        ("index.html", file_attrs(html.len() as u64, 100)),
3878                        ("assets", dir_attrs()),
3879                    ],
3880                )
3881                .dir("/srv/assets", vec![("link", symlink_attrs())])
3882                // What the remote returns once it has followed the symlink for us.
3883                .dir("/srv/assets/link", vec![("secret.txt", file_attrs(9, 1))])
3884                .file("/srv/index.html", html.as_bytes())
3885                .file("/srv/assets/link/secret.txt", b"elsewhere"),
3886        )
3887        .await;
3888
3889        assert_eq!(
3890            origin.handle(get("/index.html", None)).await.status(),
3891            StatusCode::OK
3892        );
3893        assert!(
3894            !origin.cache.has_listing("/srv/assets/link"),
3895            "the daemon listed the directory a symlink points at"
3896        );
3897
3898        // And the ordinary request for it is still refused, so closing the prefetch route
3899        // did not quietly become the only thing stopping it.
3900        assert_eq!(
3901            origin
3902                .handle(get("/assets/link/secret.txt", None))
3903                .await
3904                .status(),
3905            StatusCode::FORBIDDEN
3906        );
3907    }
3908
3909    /// The other half: a reference one level down is still prefetched, because the listing
3910    /// the page's own request already fetched proves that step is a real directory. Closing
3911    /// the hole above must not turn prefetching off for the ordinary `assets/` layout.
3912    #[tokio::test]
3913    async fn a_reference_in_a_real_subdirectory_is_still_prefetched() {
3914        let html = "<!doctype html><html><body><img src=\"assets/x.png\"></body></html>";
3915        let origin = origin_with(
3916            FakeRemote::new()
3917                .dir(
3918                    "/srv",
3919                    vec![
3920                        ("index.html", file_attrs(html.len() as u64, 100)),
3921                        ("assets", dir_attrs()),
3922                    ],
3923                )
3924                .dir("/srv/assets", vec![("x.png", file_attrs(3, 1))])
3925                .file("/srv/index.html", html.as_bytes())
3926                .file("/srv/assets/x.png", b"xxx"),
3927        )
3928        .await;
3929
3930        assert_eq!(
3931            origin.handle(get("/index.html", None)).await.status(),
3932            StatusCode::OK
3933        );
3934        let before = trips(&origin).await;
3935        let res = origin.handle(get("/assets/x.png", None)).await;
3936        assert_eq!(res.status(), StatusCode::OK);
3937        assert_eq!(&body_of(res).await[..], b"xxx");
3938        assert_eq!(
3939            trips(&origin).await - before,
3940            0,
3941            "a subdirectory one level down must still be warmed"
3942        );
3943    }
3944
3945    /// A subresource larger than one read chunk costs the same as a small one.
3946    ///
3947    /// This is what `read_ranges` buys over `read_batch` here: a read whose length is known
3948    /// can have all its chunks issued together, and a read whose length is not has to poll.
3949    /// Before, a bundle of any real size cost one round trip per 32 KiB — invisible to every
3950    /// other test, because they all use three-byte fixtures.
3951    #[tokio::test]
3952    async fn a_large_subresource_costs_what_a_small_one_costs() {
3953        async fn cost(bytes: usize) -> u64 {
3954            let html = "<!doctype html><html><body><img src=\"assets/big.bin\"></body></html>";
3955            let origin = origin_with(
3956                FakeRemote::new()
3957                    .dir(
3958                        "/srv",
3959                        vec![
3960                            ("index.html", file_attrs(html.len() as u64, 100)),
3961                            ("assets", dir_attrs()),
3962                        ],
3963                    )
3964                    .dir(
3965                        "/srv/assets",
3966                        vec![("big.bin", file_attrs(bytes as u64, 1))],
3967                    )
3968                    .file("/srv/index.html", html.as_bytes())
3969                    .file("/srv/assets/big.bin", &vec![b'x'; bytes]),
3970            )
3971            .await;
3972
3973            let before = trips(&origin).await;
3974            assert_eq!(
3975                origin.handle(get("/index.html", None)).await.status(),
3976                StatusCode::OK
3977            );
3978            let spent = trips(&origin).await - before;
3979
3980            // And it really was warmed, so the comparison is between two prefetches rather
3981            // than between a prefetch and a skip.
3982            let at = trips(&origin).await;
3983            let res = origin.handle(get("/assets/big.bin", None)).await;
3984            assert_eq!(res.status(), StatusCode::OK);
3985            assert_eq!(body_of(res).await.len(), bytes);
3986            assert_eq!(
3987                trips(&origin).await - at,
3988                0,
3989                "{bytes} bytes should have been held"
3990            );
3991
3992            spent
3993        }
3994
3995        // Either side of the 32 KiB chunk, and well past it.
3996        assert_eq!(cost(1024).await, cost(200 * 1024).await);
3997    }
3998
3999    /// And so does a file asked for directly, which is the one a reader waits on.
4000    ///
4001    /// The test above covers the prefetcher. The direct path had the same defect and no test,
4002    /// and it is the path that matters more: the files that reach it are exactly the ones a
4003    /// prefetch could not have warmed — the page itself, which has to be read before it can
4004    /// be scanned, and anything a script fetches at runtime.
4005    ///
4006    /// Found by pointing `e2e/probe.mjs` at real Documenter output, where a 700 KB
4007    /// `index.html` and a 2 MB `search_index.js` between them took the page to 95 remote
4008    /// round trips, and 26 once this was fixed. No unit test here could have found it,
4009    /// because every fixture in this file is three bytes long.
4010    #[tokio::test]
4011    async fn a_large_file_asked_for_directly_costs_what_a_small_one_costs() {
4012        async fn cost(bytes: usize) -> u64 {
4013            let origin = origin_with(
4014                FakeRemote::new()
4015                    .dir("/srv", vec![("big.bin", file_attrs(bytes as u64, 1))])
4016                    .file("/srv/big.bin", &vec![b'x'; bytes]),
4017            )
4018            .await;
4019
4020            let before = trips(&origin).await;
4021            let res = origin.handle(get("/big.bin", None)).await;
4022            assert_eq!(res.status(), StatusCode::OK);
4023            // Every byte, not merely a successful status: a range read that stopped early
4024            // would otherwise pass this as a cheap request.
4025            assert_eq!(body_of(res).await.len(), bytes);
4026            trips(&origin).await - before
4027        }
4028
4029        assert_eq!(cost(1024).await, cost(500 * 1024).await);
4030    }
4031
4032    /// A listing that understates a file's length must not turn into an empty `200`.
4033    ///
4034    /// The prefetch reads a range, and a range is exactly as long as it was told to be. A
4035    /// listing reporting zero bytes for a file that has some would therefore cache an empty
4036    /// body — and the reader would be served it, because the cache is consulted first. This
4037    /// is the failure mode `CONTRIBUTING.md` names, arriving through a new door.
4038    ///
4039    /// Caught by the fake reporting a size of zero where a size was not set, which is what a
4040    /// real listing does when it is wrong rather than silent.
4041    #[tokio::test]
4042    async fn a_subresource_the_listing_calls_empty_is_not_prefetched() {
4043        let html = "<!doctype html><html><body><img src=\"assets/x.png\"></body></html>";
4044        let sizeless = Attrs {
4045            permissions: Some(0o100644),
4046            mtime: Some(1),
4047            ..Attrs::default()
4048        };
4049        let origin = origin_with(
4050            FakeRemote::new()
4051                .dir(
4052                    "/srv",
4053                    vec![
4054                        ("index.html", file_attrs(html.len() as u64, 100)),
4055                        ("assets", dir_attrs()),
4056                    ],
4057                )
4058                .dir("/srv/assets", vec![("x.png", sizeless)])
4059                .file("/srv/index.html", html.as_bytes())
4060                .file("/srv/assets/x.png", b"xxx"),
4061        )
4062        .await;
4063
4064        assert_eq!(
4065            origin.handle(get("/index.html", None)).await.status(),
4066            StatusCode::OK
4067        );
4068        let res = origin.handle(get("/assets/x.png", None)).await;
4069        assert_eq!(res.status(), StatusCode::OK);
4070        assert_eq!(
4071            &body_of(res).await[..],
4072            b"xxx",
4073            "the real request must still serve the whole file"
4074        );
4075    }
4076
4077    /// A subresource over the hold-whole limit is skipped rather than read and discarded.
4078    #[tokio::test]
4079    async fn an_oversized_subresource_is_not_prefetched() {
4080        async fn cost(size: u64) -> u64 {
4081            let html =
4082                "<!doctype html><html><body><video src=\"assets/film.mp4\"></video></body></html>";
4083            let origin = origin_with(
4084                FakeRemote::new()
4085                    .dir(
4086                        "/srv",
4087                        vec![
4088                            ("index.html", file_attrs(html.len() as u64, 100)),
4089                            ("assets", dir_attrs()),
4090                        ],
4091                    )
4092                    .dir("/srv/assets", vec![("film.mp4", file_attrs(size, 1))])
4093                    .file("/srv/index.html", html.as_bytes())
4094                    .file("/srv/assets/film.mp4", b"xxx"),
4095            )
4096            .await;
4097            let before = trips(&origin).await;
4098            assert_eq!(
4099                origin.handle(get("/index.html", None)).await.status(),
4100                StatusCode::OK
4101            );
4102            trips(&origin).await - before
4103        }
4104
4105        // The listing is fetched either way; only the read differs. A film the cache would
4106        // decline must not be pulled across the network first to find that out.
4107        let read_it = cost(3).await;
4108        let skipped = cost(CACHE_WHOLE_MAX + 1).await;
4109        assert!(
4110            skipped < read_it,
4111            "an oversized subresource cost {skipped} against {read_it} for a small one"
4112        );
4113    }
4114
4115    /// The port is taken before any host is connected.
4116    ///
4117    /// This ordering is the whole of what a previous change set out to fix, and nothing
4118    /// tested it: every other test here builds an `Origin` directly and never goes through
4119    /// `bind` at all. A regression that put the ssh handshakes first would pass the entire
4120    /// suite, and would cost a full set of connections before reporting the one failure an
4121    /// operator can actually act on.
4122    ///
4123    /// Cheap to check without any ssh infrastructure, precisely because the port failing
4124    /// first means the host is never reached: the error naming the bind and *not* naming the
4125    /// host is the evidence.
4126    #[tokio::test]
4127    async fn the_port_is_taken_before_any_host_is_connected() {
4128        let held = TcpListener::bind(("127.0.0.1", 0))
4129            .await
4130            .expect("a free port");
4131        let port = held.local_addr().expect("its address").port();
4132
4133        const NOWHERE: &str = "a-host-that-cannot-resolve.invalid";
4134        let result = Origin::bind(
4135            vec![Alias::new("docs", NOWHERE, Some("/srv")).expect("a valid alias")],
4136            reachable::Set::default(),
4137            "ssh-browser".to_string(),
4138            "http".to_string(),
4139            port,
4140            Token::from_hex(TEST_TOKEN),
4141            theme::DEFAULT.to_string(),
4142        )
4143        .await;
4144
4145        let Err(e) = result else {
4146            panic!("binding a port that is already held must fail");
4147        };
4148        let text = format!("{e:#}");
4149        assert!(
4150            text.contains(&format!("bind 127.0.0.1:{port}")),
4151            "the error should name the port, got: {text}"
4152        );
4153        assert!(
4154            !text.contains(NOWHERE),
4155            "the ssh host was reached before the port was taken: {text}"
4156        );
4157    }
4158
4159    /// What makes a home directory a reasonable base: the things worth stealing there are
4160    /// behind a dot, and a dot is refused at any depth.
4161    #[tokio::test]
4162    async fn a_dot_name_is_never_served() {
4163        let origin = origin_with(
4164            FakeRemote::new()
4165                .dir(
4166                    "/srv",
4167                    vec![
4168                        ("Vault", dir_attrs()),
4169                        (".ssh", dir_attrs()),
4170                        (".netrc", file_attrs(9, 1)),
4171                    ],
4172                )
4173                .dir("/srv/.ssh", vec![("id_ed25519", file_attrs(9, 1))])
4174                .dir("/srv/Vault", vec![(".git", dir_attrs())])
4175                .dir("/srv/Vault/.git", vec![("config", file_attrs(9, 1))])
4176                .file("/srv/.ssh/id_ed25519", b"a-secret-")
4177                .file("/srv/.netrc", b"a-secret-")
4178                .file("/srv/Vault/.git/config", b"a-secret-"),
4179        )
4180        .await;
4181
4182        for path in [
4183            "/.ssh/id_ed25519",
4184            "/.netrc",
4185            // At depth, and behind a directory that is itself perfectly ordinary.
4186            "/Vault/.git/config",
4187            // The directory itself, not only what is under it.
4188            "/.ssh/",
4189        ] {
4190            assert_eq!(
4191                origin.handle(get(path, None)).await.status(),
4192                StatusCode::FORBIDDEN,
4193                "{path}"
4194            );
4195        }
4196    }
4197
4198    /// And they are not advertised either. Listing what the next click would refuse is worse
4199    /// than not listing it.
4200    #[tokio::test]
4201    async fn a_listing_does_not_mention_dot_names() {
4202        let origin = origin_with(FakeRemote::new().dir(
4203            "/srv",
4204            vec![
4205                ("Vault", dir_attrs()),
4206                (".ssh", dir_attrs()),
4207                (".obsidian", dir_attrs()),
4208            ],
4209        ))
4210        .await;
4211
4212        let body = body_of(origin.handle(get("/", None)).await).await;
4213        let listing = String::from_utf8_lossy(&body);
4214        assert!(listing.contains("Vault"), "the ordinary entry is listed");
4215        assert!(!listing.contains(".ssh"), "got: {listing}");
4216        assert!(!listing.contains(".obsidian"), "got: {listing}");
4217    }
4218
4219    /// The prefetcher must not become the way around it. A page is untrusted input, and this
4220    /// is the one part of the daemon that acts on what a page says.
4221    #[tokio::test]
4222    async fn a_page_cannot_prefetch_a_dot_name() {
4223        let html = "<!doctype html><html><body><img src=\".ssh/id_ed25519\"></body></html>";
4224        let origin = origin_with(
4225            FakeRemote::new()
4226                .dir(
4227                    "/srv",
4228                    vec![
4229                        ("index.html", file_attrs(html.len() as u64, 100)),
4230                        (".ssh", dir_attrs()),
4231                    ],
4232                )
4233                .dir("/srv/.ssh", vec![("id_ed25519", file_attrs(9, 1))])
4234                .file("/srv/index.html", html.as_bytes())
4235                .file("/srv/.ssh/id_ed25519", b"a-secret-"),
4236        )
4237        .await;
4238
4239        assert_eq!(
4240            origin.handle(get("/index.html", None)).await.status(),
4241            StatusCode::OK
4242        );
4243        assert!(
4244            !origin.cache.has_listing("/srv/.ssh"),
4245            "the page got the daemon to list a directory it will not serve"
4246        );
4247        assert_eq!(
4248            origin.handle(get("/.ssh/id_ed25519", None)).await.status(),
4249            StatusCode::FORBIDDEN
4250        );
4251    }
4252
4253    /// The same single file, four directories down.
4254    fn deep_tree() -> FakeRemote {
4255        FakeRemote::new()
4256            .dir("/srv", vec![("a", dir_attrs())])
4257            .dir("/srv/a", vec![("b", dir_attrs())])
4258            .dir("/srv/a/b", vec![("c", dir_attrs())])
4259            .dir("/srv/a/b/c", vec![("d.html", file_attrs(5, 100))])
4260            .file("/srv/a/b/c/d.html", b"deep!")
4261    }
4262
4263    fn entry(name: &str, dir: bool) -> Entry {
4264        Entry {
4265            name: name.to_string(),
4266            attrs: Attrs {
4267                permissions: Some(if dir { 0o040755 } else { 0o100644 }),
4268                ..Attrs::default()
4269            },
4270        }
4271    }
4272
4273    /// One directory as a tree with nothing above it, which is every test that is not about
4274    /// the ancestors or the site scan. Both of those need a remote; these do not.
4275    fn listing(alias: &str, rel: &str, entries: &[Entry]) -> String {
4276        let levels = vec![(rel.to_string(), rows_of(entries, &HashSet::new()))];
4277        autoindex(alias, rel, &levels, theme::DEFAULT, "http://ssh-browser/")
4278    }
4279
4280    #[test]
4281    fn a_hostile_filename_cannot_inject_script_into_our_origin() {
4282        let page = listing("docs", "", &[entry("<script>alert(1)</script>", false)]);
4283        assert!(!page.contains("<script>alert"));
4284        assert!(page.contains("&lt;script&gt;"));
4285    }
4286
4287    /// Directories first and no headings, which is souta's call. Within the files the HTML
4288    /// rises, which is the other half of what they asked for.
4289    #[test]
4290    fn directories_come_first_and_pages_lead_the_files() {
4291        let page = listing(
4292            "docs",
4293            "",
4294            &[
4295                entry("b.txt", false),
4296                entry("z-dir", true),
4297                entry("a.txt", false),
4298                entry("report.html", false),
4299            ],
4300        );
4301        let dir = page.find("z-dir").expect("dir listed");
4302        let html = page.find("report.html").expect("page listed");
4303        let a = page.find("a.txt").expect("a listed");
4304        let b = page.find("b.txt").expect("b listed");
4305        assert!(
4306            dir < html,
4307            "directories come first, whatever they are called"
4308        );
4309        assert!(html < a, "then the pages, ahead of the other files");
4310        assert!(a < b, "and the rest by name");
4311        // No headings at all. They are what souta called 「みずらい」.
4312        assert!(!page.contains("<h2"), "{page}");
4313    }
4314
4315    /// A page is decided by what the name *is*, so a file whose extension merely contains
4316    /// `html` is an ordinary file. `.htm` is the one other spelling worth accepting.
4317    #[test]
4318    fn only_html_counts_as_a_page() {
4319        let page = listing(
4320            "docs",
4321            "",
4322            &[
4323                entry("a.htm", false),
4324                entry("b.html.bak", false),
4325                entry("c.xhtml", false),
4326            ],
4327        );
4328        let htm = page.find("a.htm").expect("htm listed");
4329        let bak = page.find("b.html.bak").expect("bak listed");
4330        let xhtml = page.find("c.xhtml").expect("xhtml listed");
4331        assert!(htm < bak && htm < xhtml, "only the .htm leads: {page}");
4332        // And it is coloured as one, which is the only signal left now that the headings
4333        // are gone.
4334        assert!(
4335            page.contains("class=\"row k-page\" href=\"/a.htm\""),
4336            "{page}"
4337        );
4338    }
4339
4340    #[test]
4341    fn hrefs_are_url_escaped() {
4342        let page = listing("docs", "", &[entry("a b#c.html", false)]);
4343        assert!(page.contains("href=\"/a%20b%23c.html\""));
4344    }
4345
4346    /// The header says where you are. An explorer does not make you read the address bar
4347    /// to know which folder you are looking at.
4348    #[test]
4349    fn the_header_names_the_alias_and_where_you_are() {
4350        let page = listing("panza", "/Vault/infra", &[]);
4351        assert!(page.contains("<b>panza</b>"), "{page}");
4352        assert!(page.contains("<span>/Vault/infra</span>"), "{page}");
4353    }
4354
4355    /// The point of a tree rather than a listing: every level of the path is open at once,
4356    /// with the rest of each level beside it, and the deepest is the one selected.
4357    ///
4358    /// It costs no round trips beyond the listing it replaces, because the walk that
4359    /// resolved the path warmed every ancestor to check it for symlinks — see
4360    /// `the_tree_costs_what_one_directory_cost`.
4361    #[tokio::test]
4362    async fn the_whole_path_is_expanded_and_the_deepest_is_selected() {
4363        let origin = origin_with(
4364            FakeRemote::new()
4365                .dir("/srv", vec![("a", dir_attrs()), ("elsewhere", dir_attrs())])
4366                .dir("/srv/a", vec![("b", dir_attrs()), ("sibling", dir_attrs())])
4367                .dir("/srv/a/b", vec![("leaf.txt", file_attrs(3, 1))])
4368                .dir("/srv/elsewhere", vec![])
4369                .dir("/srv/a/sibling", vec![]),
4370        )
4371        .await;
4372
4373        let body = String::from_utf8(
4374            body_of(origin.handle(get("/a/b/", None)).await)
4375                .await
4376                .to_vec(),
4377        )
4378        .expect("utf-8");
4379
4380        // Both levels of the path are open...
4381        assert!(body.contains("<li class=\"open\">"), "{body}");
4382        assert!(body.contains("href=\"/a/\""), "{body}");
4383        // ...the deepest is the one marked as where the reader is...
4384        assert!(body.contains("row dir here\" href=\"/a/b/\""), "{body}");
4385        // ...what is inside it is rendered...
4386        assert!(body.contains("leaf.txt"), "{body}");
4387        // ...and so is everything beside it on the way down, which is what makes this a
4388        // tree rather than one directory at a time.
4389        assert!(body.contains("elsewhere"), "{body}");
4390        assert!(body.contains("sibling"), "{body}");
4391    }
4392
4393    /// The tree is four levels of listing, and it must cost what one level cost. Every
4394    /// ancestor was already fetched to check it for symlinks, so showing them is free; a
4395    /// version that went and asked again would pay for the depth twice.
4396    #[tokio::test]
4397    async fn the_tree_costs_what_one_directory_cost() {
4398        let deep = origin_with(deep_tree()).await;
4399        let before = trips(&deep).await;
4400        assert_eq!(
4401            deep.handle(get("/a/b/c/", None)).await.status(),
4402            StatusCode::OK
4403        );
4404        let four = trips(&deep).await - before;
4405
4406        let shallow = origin_with(one_page()).await;
4407        let before = trips(&shallow).await;
4408        assert_eq!(
4409            shallow.handle(get("/", None)).await.status(),
4410            StatusCode::OK
4411        );
4412        let one = trips(&shallow).await - before;
4413
4414        // The slack absorbs a flush of fire-and-forget CLOSE requests landing on either
4415        // side of the measurement. Asking per level would cost about four times as many.
4416        assert!(
4417            four <= one + 2,
4418            "a tree four deep cost {four} round trips against {one} for one directory"
4419        );
4420    }
4421
4422    /// Including when the folder holds an `index.html`.
4423    ///
4424    /// It did not. A directory with an index is served *as* that page, and that resolution ran
4425    /// before the query was looked at — so the tree asked for one level, received a whole
4426    /// document, and spliced it into itself. souta sent a screenshot of their own figure
4427    /// gallery rendered inside a file list, under three rows of directory names.
4428    ///
4429    /// The body is what this asserts, not the status: both answers are `200 text/html`, and a
4430    /// check on the status would have passed throughout.
4431    #[tokio::test]
4432    async fn asking_for_one_level_of_a_site_is_still_a_level() {
4433        let origin = origin_with(
4434            FakeRemote::new()
4435                .dir("/srv", vec![("site", dir_attrs())])
4436                .dir(
4437                    "/srv/site",
4438                    vec![
4439                        ("index.html", file_attrs(9, 1)),
4440                        ("notes.md", file_attrs(4, 1)),
4441                    ],
4442                )
4443                .file("/srv/site/index.html", b"<h1>hi</h1>"),
4444        )
4445        .await;
4446
4447        let req = Request::builder()
4448            .uri("http://docs.ssh-browser/site/?ls")
4449            .header(HOST, "docs.ssh-browser")
4450            .body(Empty::<Bytes>::new())
4451            .expect("request builds");
4452        let body =
4453            String::from_utf8(body_of(origin.handle(req).await).await.to_vec()).expect("utf-8");
4454
4455        assert!(body.starts_with("<ul>"), "{body}");
4456        assert!(
4457            !body.contains("<html"),
4458            "a document arrived where a fragment belongs: {body}"
4459        );
4460        assert!(
4461            !body.contains("<h1>hi</h1>"),
4462            "the page was served instead: {body}"
4463        );
4464        assert!(body.contains("href=\"/site/notes.md\""), "{body}");
4465    }
4466
4467    /// And without `?ls` the same directory is still its page. The fix must not cost that.
4468    #[tokio::test]
4469    async fn a_site_without_the_query_is_still_its_page() {
4470        let origin = origin_with(
4471            FakeRemote::new()
4472                .dir("/srv", vec![("site", dir_attrs())])
4473                .dir("/srv/site", vec![("index.html", file_attrs(11, 1))])
4474                .file("/srv/site/index.html", b"<h1>hi</h1>"),
4475        )
4476        .await;
4477
4478        let req = Request::builder()
4479            .uri("http://docs.ssh-browser/site/")
4480            .header(HOST, "docs.ssh-browser")
4481            .body(Empty::<Bytes>::new())
4482            .expect("request builds");
4483        let body =
4484            String::from_utf8(body_of(origin.handle(req).await).await.to_vec()).expect("utf-8");
4485        assert_eq!(body, "<h1>hi</h1>");
4486    }
4487
4488    /// What the script asks for when a folder is expanded: the same level, as the fragment
4489    /// that goes inside it. One renderer, so the two cannot disagree about what a row is.
4490    #[tokio::test]
4491    async fn asking_for_one_level_answers_with_its_rows() {
4492        let origin = origin_with(
4493            FakeRemote::new()
4494                .dir("/srv", vec![("sub", dir_attrs())])
4495                .dir("/srv/sub", vec![("inner.md", file_attrs(4, 1))]),
4496        )
4497        .await;
4498
4499        let req = Request::builder()
4500            .uri("http://docs.ssh-browser/sub/?ls")
4501            .header(HOST, "docs.ssh-browser")
4502            .body(Empty::<Bytes>::new())
4503            .expect("request builds");
4504        let res = origin.handle(req).await;
4505        assert_eq!(res.status(), StatusCode::OK);
4506
4507        let body = String::from_utf8(body_of(res).await.to_vec()).expect("utf-8");
4508        // A fragment, so it can be inserted where it belongs rather than replacing a page.
4509        assert!(body.starts_with("<ul>"), "{body}");
4510        assert!(!body.contains("<html"), "{body}");
4511        // Built against the level it was asked about, so the href works from anywhere.
4512        assert!(body.contains("href=\"/sub/inner.md\""), "{body}");
4513    }
4514
4515    /// It adds no capability. Everything `?ls` says is already in the page it belongs to,
4516    /// and a dot-name is refused here exactly as it is everywhere else.
4517    #[tokio::test]
4518    async fn asking_for_one_level_does_not_mention_dot_names() {
4519        let origin = origin_with(
4520            FakeRemote::new()
4521                .dir("/srv", vec![("sub", dir_attrs())])
4522                .dir(
4523                    "/srv/sub",
4524                    vec![("shown.md", file_attrs(4, 1)), (".hidden", dir_attrs())],
4525                ),
4526        )
4527        .await;
4528
4529        let req = Request::builder()
4530            .uri("http://docs.ssh-browser/sub/?ls")
4531            .header(HOST, "docs.ssh-browser")
4532            .body(Empty::<Bytes>::new())
4533            .expect("request builds");
4534        let body =
4535            String::from_utf8(body_of(origin.handle(req).await).await.to_vec()).expect("utf-8");
4536        assert!(body.contains("shown.md"), "{body}");
4537        assert!(!body.contains(".hidden"), "{body}");
4538    }
4539
4540    #[test]
4541    fn sizes_read_the_way_a_file_manager_shows_them() {
4542        assert_eq!(human_size(0), "0 B");
4543        assert_eq!(human_size(999), "999 B");
4544        assert_eq!(human_size(1024), "1.0 KiB");
4545        assert_eq!(human_size(1536), "1.5 KiB");
4546        // One decimal below ten and none above, so a column of them stays a column.
4547        assert_eq!(human_size(10 * 1024 * 1024), "10 MiB");
4548        assert_eq!(human_size(9_961_472), "9.5 MiB");
4549        assert_eq!(human_size(3 * 1024 * 1024 * 1024), "3.0 GiB");
4550    }
4551
4552    /// Checked against dates that are known independently of the algorithm, including the
4553    /// epoch itself and a leap day, which is where a calendar implementation goes wrong.
4554    #[test]
4555    fn timestamps_are_the_utc_civil_date() {
4556        assert_eq!(utc_stamp(0), "1970-01-01 00:00");
4557        assert_eq!(utc_stamp(86_399), "1970-01-01 23:59");
4558        assert_eq!(utc_stamp(86_400), "1970-01-02 00:00");
4559        // 2000-02-29, a leap day in a century year that is a leap year.
4560        assert_eq!(utc_stamp(951_782_400), "2000-02-29 00:00");
4561        // 2100 is divisible by 4 and by 100 but not by 400, so it is *not* a leap year and
4562        // the day after 2100-02-28 is 2100-03-01. Getting this wrong is the classic way a
4563        // hand-rolled calendar fails, and the two constants below are one day apart.
4564        assert_eq!(utc_stamp(4_107_456_000), "2100-02-28 00:00");
4565        assert_eq!(utc_stamp(4_107_542_400), "2100-03-01 00:00");
4566        assert_eq!(utc_stamp(1_757_745_840), "2025-09-13 06:44");
4567    }
4568
4569    /// A listing of nothing says so. An empty page with a heading over it reads as a
4570    /// failure rather than as an empty directory.
4571    #[test]
4572    fn an_empty_directory_says_it_is_empty() {
4573        let page = listing("docs", "/nothing", &[]);
4574        assert!(page.contains("This directory is empty"), "{page}");
4575    }
4576
4577    #[test]
4578    fn the_component_chain_walks_from_the_base_down() {
4579        assert_eq!(
4580            components("/srv", "/srv/a/b/c.html"),
4581            vec![
4582                ("/srv".to_string(), "a".to_string()),
4583                ("/srv/a".to_string(), "b".to_string()),
4584                ("/srv/a/b".to_string(), "c.html".to_string()),
4585            ]
4586        );
4587        assert_eq!(
4588            components("/srv", "/srv/index.html"),
4589            vec![("/srv".to_string(), "index.html".to_string())]
4590        );
4591        // A trailing slash on the base must not produce an empty first component.
4592        assert_eq!(
4593            components("/srv/", "/srv/a.html"),
4594            vec![("/srv".to_string(), "a.html".to_string())]
4595        );
4596        // The file *is* the base: nothing between them to check.
4597        assert!(components("/srv", "/srv").is_empty());
4598    }
4599
4600    /// Invariant 2. The listing and the body are both held, so the second request
4601    /// has nothing left to ask the remote.
4602    #[tokio::test]
4603    async fn a_revisit_costs_no_remote_round_trips() {
4604        let origin = origin_with(one_page()).await;
4605
4606        let first = origin.handle(get("/a.html", None)).await;
4607        assert_eq!(first.status(), StatusCode::OK);
4608        let after_first = trips(&origin).await;
4609        assert!(after_first > 0, "the first request has to fetch something");
4610
4611        let second = origin.handle(get("/a.html", None)).await;
4612        assert_eq!(second.status(), StatusCode::OK);
4613        assert_eq!(
4614            trips(&origin).await,
4615            after_first,
4616            "a revisit must be answered entirely from cache"
4617        );
4618    }
4619
4620    /// Invariant 2 through the browser's own validator: the ETag came from the
4621    /// cached listing, so the 304 is decided inside this process.
4622    #[tokio::test]
4623    async fn a_conditional_get_is_answered_without_the_remote() {
4624        let origin = origin_with(one_page()).await;
4625
4626        let first = origin.handle(get("/a.html", None)).await;
4627        let tag = first
4628            .headers()
4629            .get(ETAG)
4630            .expect("a validator is offered")
4631            .to_str()
4632            .expect("ascii")
4633            .to_string();
4634        let after_first = trips(&origin).await;
4635
4636        let second = origin.handle(get("/a.html", Some(&tag))).await;
4637        assert_eq!(second.status(), StatusCode::NOT_MODIFIED);
4638        assert_eq!(
4639            trips(&origin).await,
4640            after_first,
4641            "a 304 must not touch the remote"
4642        );
4643    }
4644
4645    /// A name the listing does not contain needs no fetch to answer.
4646    #[tokio::test]
4647    async fn a_missing_file_is_a_404_from_the_cached_listing() {
4648        let origin = origin_with(one_page()).await;
4649
4650        // Warm the listing.
4651        origin.handle(get("/a.html", None)).await;
4652        let warm = trips(&origin).await;
4653
4654        let missing = origin.handle(get("/nope.html", None)).await;
4655        assert_eq!(missing.status(), StatusCode::NOT_FOUND);
4656        assert_eq!(
4657            trips(&origin).await,
4658            warm,
4659            "a 404 for a listed-but-absent name must cost nothing"
4660        );
4661    }
4662
4663    /// The guard SECURITY.md promises, decided from the listing rather than from a
4664    /// REALPATH per request.
4665    #[tokio::test]
4666    async fn a_symlink_is_refused() {
4667        let origin = origin_with(
4668            FakeRemote::new()
4669                .dir("/srv", vec![("link.html", symlink_attrs())])
4670                .file("/srv/link.html", b"whatever the target is"),
4671        )
4672        .await;
4673
4674        let res = origin.handle(get("/link.html", None)).await;
4675        assert_eq!(res.status(), StatusCode::FORBIDDEN);
4676    }
4677
4678    /// The listing knows it is a directory, so this costs no failed open first.
4679    #[tokio::test]
4680    async fn a_directory_without_a_trailing_slash_redirects() {
4681        let origin = origin_with(
4682            FakeRemote::new()
4683                .dir("/srv", vec![("sub", dir_attrs())])
4684                .dir("/srv/sub", vec![("b.html", file_attrs(1, 1))]),
4685        )
4686        .await;
4687
4688        let res = origin.handle(get("/sub", None)).await;
4689        assert_eq!(res.status(), StatusCode::MOVED_PERMANENTLY);
4690        assert_eq!(
4691            res.headers().get(LOCATION).and_then(|v| v.to_str().ok()),
4692            Some("/sub/")
4693        );
4694    }
4695
4696    /// A listing that promises a file the remote then refuses must not be kept, or
4697    /// the same wrong answer is served for a whole TTL.
4698    #[tokio::test]
4699    async fn a_listing_proven_wrong_is_forgotten() {
4700        // Listed, but no body declared: the open fails.
4701        let origin =
4702            origin_with(FakeRemote::new().dir("/srv", vec![("ghost.html", file_attrs(5, 100))]))
4703                .await;
4704
4705        let res = origin.handle(get("/ghost.html", None)).await;
4706        assert_eq!(res.status(), StatusCode::NOT_FOUND);
4707        assert!(
4708            !origin.cache.has_listing("/srv"),
4709            "a listing contradicted by the remote must be dropped"
4710        );
4711    }
4712
4713    /// A directory with no index.html is listed rather than 404'd.
4714    #[tokio::test]
4715    async fn a_directory_without_an_index_is_listed() {
4716        let origin =
4717            origin_with(FakeRemote::new().dir("/srv", vec![("only.txt", file_attrs(2, 1))])).await;
4718
4719        let res = origin.handle(get("/", None)).await;
4720        assert_eq!(res.status(), StatusCode::OK);
4721        assert_eq!(
4722            res.headers()
4723                .get(CONTENT_TYPE)
4724                .and_then(|v| v.to_str().ok()),
4725            Some("text/html; charset=utf-8")
4726        );
4727    }
4728
4729    /// The hole SECURITY.md used to describe. `/link/inside.html` names a file that
4730    /// exists and is not itself a symlink, but every route to it passes through one.
4731    #[tokio::test]
4732    async fn a_symlinked_directory_higher_up_the_path_is_refused() {
4733        let origin = origin_with(
4734            FakeRemote::new()
4735                .dir("/srv", vec![("link", symlink_attrs())])
4736                .dir("/srv/link", vec![("inside.html", file_attrs(2, 1))])
4737                .file("/srv/link/inside.html", b"hi"),
4738        )
4739        .await;
4740
4741        let res = origin.handle(get("/link/inside.html", None)).await;
4742        assert_eq!(res.status(), StatusCode::FORBIDDEN);
4743    }
4744
4745    /// Depth must not buy itself round trips. Every ancestor listing is issued
4746    /// together, so a path four deep costs what a path one deep costs.
4747    #[tokio::test]
4748    async fn a_deep_path_costs_what_a_shallow_one_costs() {
4749        let deep = origin_with(deep_tree()).await;
4750        assert_eq!(
4751            deep.handle(get("/a/b/c/d.html", None)).await.status(),
4752            StatusCode::OK
4753        );
4754
4755        let shallow = origin_with(one_page()).await;
4756        assert_eq!(
4757            shallow.handle(get("/a.html", None)).await.status(),
4758            StatusCode::OK
4759        );
4760
4761        let (d, sh) = (trips(&deep).await, trips(&shallow).await);
4762        // The slack absorbs one flush of fire-and-forget CLOSE requests landing on
4763        // either side of the measurement. A walk that listed one ancestor at a time
4764        // would cost about three times as many at this depth, and worse deeper.
4765        assert!(
4766            d <= sh + 2,
4767            "depth 4 cost {d} round trips against depth 1's {sh}"
4768        );
4769    }
4770
4771    /// A component that exists but is not a directory.
4772    #[tokio::test]
4773    async fn a_file_used_as_a_directory_is_a_404() {
4774        let origin = origin_with(one_page()).await;
4775        let res = origin.handle(get("/a.html/b.html", None)).await;
4776        assert_eq!(res.status(), StatusCode::NOT_FOUND);
4777    }
4778
4779    /// A deep path is served, not merely checked: the walk must not lose the file it
4780    /// was walking towards.
4781    #[tokio::test]
4782    async fn a_deep_path_serves_its_body() {
4783        let origin = origin_with(deep_tree()).await;
4784        let res = origin.handle(get("/a/b/c/d.html", None)).await;
4785        assert_eq!(res.status(), StatusCode::OK);
4786        assert_eq!(
4787            res.headers()
4788                .get(CONTENT_TYPE)
4789                .and_then(|v| v.to_str().ok()),
4790            Some("text/html; charset=utf-8")
4791        );
4792    }
4793
4794    /// A range out of a body already held costs nothing: the slice happens here.
4795    #[tokio::test]
4796    async fn a_range_is_sliced_out_of_the_cached_body() {
4797        let origin = origin_with(one_page()).await;
4798        assert_eq!(
4799            origin.handle(get("/a.html", None)).await.status(),
4800            StatusCode::OK
4801        );
4802        let warm = trips(&origin).await;
4803
4804        let res = origin.handle(ranged("/a.html", "bytes=1-3")).await;
4805        assert_eq!(res.status(), StatusCode::PARTIAL_CONTENT);
4806        assert_eq!(
4807            res.headers()
4808                .get(CONTENT_RANGE)
4809                .and_then(|v| v.to_str().ok()),
4810            Some("bytes 1-3/5")
4811        );
4812        assert_eq!(&body_of(res).await[..], b"ell");
4813        assert_eq!(
4814            trips(&origin).await,
4815            warm,
4816            "slicing a held body must cost no round trip"
4817        );
4818    }
4819
4820    /// A range on a file not yet held still works, and the file ends up held.
4821    #[tokio::test]
4822    async fn a_range_on_a_cold_small_file_works_and_warms_the_cache() {
4823        let origin = origin_with(one_page()).await;
4824
4825        let res = origin.handle(ranged("/a.html", "bytes=0-1")).await;
4826        assert_eq!(res.status(), StatusCode::PARTIAL_CONTENT);
4827        assert_eq!(&body_of(res).await[..], b"he");
4828
4829        let warm = trips(&origin).await;
4830        let again = origin.handle(ranged("/a.html", "bytes=2-4")).await;
4831        assert_eq!(&body_of(again).await[..], b"llo");
4832        assert_eq!(
4833            trips(&origin).await,
4834            warm,
4835            "a small file fetched for a range should be held whole"
4836        );
4837    }
4838
4839    /// The 416 has to name the real size, or a client cannot correct itself.
4840    #[tokio::test]
4841    async fn a_range_past_the_end_is_a_416_carrying_the_real_size() {
4842        let origin = origin_with(one_page()).await;
4843        let res = origin.handle(ranged("/a.html", "bytes=99-")).await;
4844        assert_eq!(res.status(), StatusCode::RANGE_NOT_SATISFIABLE);
4845        assert_eq!(
4846            res.headers()
4847                .get(CONTENT_RANGE)
4848                .and_then(|v| v.to_str().ok()),
4849            Some("bytes */5")
4850        );
4851    }
4852
4853    /// A client that is not told ranges exist will never seek.
4854    #[tokio::test]
4855    async fn a_full_response_advertises_ranges() {
4856        let origin = origin_with(one_page()).await;
4857        let res = origin.handle(get("/a.html", None)).await;
4858        assert_eq!(
4859            res.headers()
4860                .get(ACCEPT_RANGES)
4861                .and_then(|v| v.to_str().ok()),
4862            Some("bytes")
4863        );
4864    }
4865
4866    /// The validator on offer is weak, so `If-Range` cannot be honoured. The whole
4867    /// representation is the specified answer, not a 412 and not a 206.
4868    #[tokio::test]
4869    async fn if_range_yields_the_whole_file() {
4870        let origin = origin_with(one_page()).await;
4871        let req = Request::builder()
4872            .uri("http://docs.ssh-browser/a.html")
4873            .header(HOST, "docs.ssh-browser")
4874            .header(RANGE, "bytes=1-3")
4875            .header(IF_RANGE, "W/\"64-5\"")
4876            .body(Empty::<Bytes>::new())
4877            .expect("request builds");
4878
4879        let res = origin.handle(req).await;
4880        assert_eq!(res.status(), StatusCode::OK);
4881        assert_eq!(&body_of(res).await[..], b"hello");
4882    }
4883
4884    /// The branch that makes a video seekable: a file too big to hold is fetched by
4885    /// range and not cached, so a seek does not pull the whole thing.
4886    #[tokio::test]
4887    async fn a_large_file_is_served_by_range_and_not_held() {
4888        let body: Vec<u8> = (0..64u8).collect();
4889        let origin = origin_with(
4890            FakeRemote::new()
4891                // Declared far larger than the cache threshold; the body behind it is
4892                // small because what is under test is the branch, not the bytes.
4893                .dir("/srv", vec![("big.bin", file_attrs(9 * 1024 * 1024, 7))])
4894                .file("/srv/big.bin", &body),
4895        )
4896        .await;
4897
4898        let res = origin.handle(ranged("/big.bin", "bytes=0-9")).await;
4899        assert_eq!(res.status(), StatusCode::PARTIAL_CONTENT);
4900        assert_eq!(&body_of(res).await[..], &body[0..10]);
4901
4902        let after = trips(&origin).await;
4903        let second = origin.handle(ranged("/big.bin", "bytes=10-19")).await;
4904        assert_eq!(&body_of(second).await[..], &body[10..20]);
4905        assert!(
4906            trips(&origin).await > after,
4907            "a file over the threshold must not be held"
4908        );
4909    }
4910
4911    /// The boundary, from the side that matters. A page served under an alias origin
4912    /// names the control path and gets a file lookup, not the control router: the 404
4913    /// proves it was never routed there. A 401 would mean the router saw it.
4914    #[tokio::test]
4915    async fn an_alias_origin_has_no_control_api_on_it() {
4916        let origin = origin_with(one_page()).await;
4917        let res = origin.handle(get("/_control/hello", None)).await;
4918        assert_eq!(res.status(), StatusCode::NOT_FOUND);
4919        assert_ne!(
4920            res.status(),
4921            StatusCode::UNAUTHORIZED,
4922            "a 401 would mean the control router was reached from an alias origin"
4923        );
4924    }
4925
4926    /// Even with the right token in hand, an alias origin must not route to control.
4927    /// This is the case a compromised page would actually try.
4928    #[tokio::test]
4929    async fn an_alias_origin_with_a_valid_token_still_has_no_control_api() {
4930        let origin = origin_with(one_page()).await;
4931        let req = Request::builder()
4932            .uri("http://docs.ssh-browser/_control/hello")
4933            .header(HOST, "docs.ssh-browser")
4934            .header(control::TOKEN_HEADER, TEST_TOKEN)
4935            .body(Empty::<Bytes>::new())
4936            .expect("request builds");
4937        assert_eq!(origin.handle(req).await.status(), StatusCode::NOT_FOUND);
4938    }
4939
4940    /// There is no write path on the read side, and a POST is told so rather than being
4941    /// quietly served as a GET.
4942    #[tokio::test]
4943    async fn the_alias_origin_refuses_writes() {
4944        let origin = origin_with(one_page()).await;
4945        for method in [Method::POST, Method::PUT, Method::DELETE, Method::PATCH] {
4946            let req = Request::builder()
4947                .method(method.clone())
4948                .uri("http://docs.ssh-browser/a.html")
4949                .header(HOST, "docs.ssh-browser")
4950                .body(Empty::<Bytes>::new())
4951                .expect("request builds");
4952            assert_eq!(
4953                origin.handle(req).await.status(),
4954                StatusCode::METHOD_NOT_ALLOWED,
4955                "{method} should be refused on the read-only origin"
4956            );
4957        }
4958    }
4959
4960    #[tokio::test]
4961    async fn the_control_api_answers_on_loopback_with_the_token() {
4962        let origin = origin_with(one_page()).await;
4963        let res = origin
4964            .handle(loopback("/_control/hello", Some(TEST_TOKEN)))
4965            .await;
4966        assert_eq!(res.status(), StatusCode::OK);
4967        let body = body_of(res).await;
4968        let text = String::from_utf8_lossy(&body);
4969        assert!(
4970            text.contains("\"protocol\""),
4971            "hello must negotiate: {text}"
4972        );
4973        assert!(text.contains("\"docs\""), "hello must list aliases: {text}");
4974    }
4975
4976    #[tokio::test]
4977    async fn the_control_api_refuses_loopback_without_the_token() {
4978        let origin = origin_with(one_page()).await;
4979        assert_eq!(
4980            origin
4981                .handle(loopback("/_control/hello", None))
4982                .await
4983                .status(),
4984            StatusCode::UNAUTHORIZED
4985        );
4986        assert_eq!(
4987            origin
4988                .handle(loopback("/_control/hello", Some("wrong")))
4989                .await
4990                .status(),
4991            StatusCode::UNAUTHORIZED
4992        );
4993    }
4994
4995    /// The direct browsing path still works alongside the control prefix.
4996    #[tokio::test]
4997    async fn the_loopback_path_still_serves_files() {
4998        let origin = origin_with(one_page()).await;
4999        let res = origin.handle(loopback("/docs/a.html", None)).await;
5000        assert_eq!(res.status(), StatusCode::OK);
5001        assert_eq!(&body_of(res).await[..], b"hello");
5002    }
5003
5004    /// The form souta asked for: bring the home directory into the config rather than
5005    /// writing out another machine's account layout by hand.
5006    #[tokio::test]
5007    async fn a_base_may_be_written_relative_to_the_home_directory() {
5008        let fs = FakeRemote::new().home("/home/souta").spawn().await;
5009        assert_eq!(
5010            resolve_base(Some("~/work"), &fs).await.expect("resolves"),
5011            "/home/souta/work"
5012        );
5013    }
5014
5015    /// Three spellings of the same thing, and they had better agree.
5016    #[tokio::test]
5017    async fn a_bare_tilde_and_no_base_are_both_the_home_directory() {
5018        let fs = FakeRemote::new().home("/home/souta").spawn().await;
5019        assert_eq!(
5020            resolve_base(None, &fs).await.expect("resolves"),
5021            "/home/souta"
5022        );
5023        assert_eq!(
5024            resolve_base(Some("~"), &fs).await.expect("resolves"),
5025            "/home/souta"
5026        );
5027    }
5028
5029    /// An absolute base is already the answer, so asking the remote would be a round trip
5030    /// spent to be told something already written down.
5031    #[tokio::test]
5032    async fn an_absolute_base_costs_no_round_trip() {
5033        let fs = FakeRemote::new().home("/home/souta").spawn().await;
5034        let before = fs.round_trips();
5035        assert_eq!(
5036            resolve_base(Some("/srv/docs"), &fs)
5037                .await
5038                .expect("resolves"),
5039            "/srv/docs"
5040        );
5041        assert_eq!(fs.round_trips(), before, "an absolute base must not ask");
5042    }
5043
5044    /// The base is the blast radius of every page served under it, so a base that quietly
5045    /// meant somewhere other than where it reads is the worst place for a surprise.
5046    #[test]
5047    fn a_base_that_could_climb_out_of_the_home_directory_is_refused() {
5048        for bad in [
5049            "~/..",
5050            "~/../.ssh",
5051            "~/work/../..",
5052            "~/./x",
5053            "~work",
5054            "work",
5055            "",
5056        ] {
5057            assert!(!is_base(bad), "should have been refused: {bad:?}");
5058            assert!(
5059                Alias::new("docs", "h", Some(bad)).is_err(),
5060                "should have been refused: {bad:?}"
5061            );
5062        }
5063        for good in ["/", "/srv", "~", "~/work", "~/a/b/c"] {
5064            assert!(is_base(good), "should have been accepted: {good:?}");
5065        }
5066    }
5067
5068    /// A home of `/` is unusual and not impossible, and `//work` is not portably the same
5069    /// path as `/work`: POSIX leaves a leading double slash implementation-defined.
5070    #[tokio::test]
5071    async fn a_root_home_does_not_produce_a_doubled_slash() {
5072        let fs = FakeRemote::new().home("/").spawn().await;
5073        assert_eq!(resolve_base(None, &fs).await.expect("resolves"), "/");
5074        assert_eq!(
5075            resolve_base(Some("~/work"), &fs).await.expect("resolves"),
5076            "/work"
5077        );
5078    }
5079
5080    /// Deliberately asserts nothing about which hosts come back: the answer is whatever
5081    /// this machine's ssh_config says, and a test that pinned it would pass on one
5082    /// machine and fail on every other. What it does catch is the route not being wired
5083    /// up, which is otherwise only visible by hand.
5084    ///
5085    /// The token is not checked here because it cannot be reached without one: the gate
5086    /// runs in `handle` before any route is dispatched, so no control route can have its
5087    /// own answer to that question.
5088    #[tokio::test]
5089    async fn the_host_list_is_a_control_route() {
5090        let origin = origin_with(one_page()).await;
5091        let res = origin
5092            .handle(loopback("/_control/hosts", Some(TEST_TOKEN)))
5093            .await;
5094        assert_eq!(res.status(), StatusCode::OK);
5095        let text = String::from_utf8(body_of(res).await.to_vec()).expect("utf-8");
5096        let parsed: serde_json::Value = serde_json::from_str(&text).expect("json");
5097        assert!(parsed.get("hosts").is_some_and(|h| h.is_array()), "{text}");
5098        assert!(
5099            parsed.get("unusable").is_some_and(|u| u.is_array()),
5100            "{text}"
5101        );
5102    }
5103
5104    /// The round-trip count is reachable at runtime, and moves.
5105    ///
5106    /// The claim this daemon is built on is a round-trip count, and the counter behind it
5107    /// used to be visible only to unit tests holding a `FakeRemote` — which makes the claim
5108    /// checkable against the fake and nowhere else. Reporting it lets `e2e/probe.mjs` read
5109    /// it for a real page on a real host, which is the only place it can be wrong in a way
5110    /// a reader would notice.
5111    ///
5112    /// Asserted as a strict increase rather than as a number. The number is the subject of
5113    /// other tests, and pinning it here would make this fail for every unrelated change to
5114    /// how a page is fetched. What must not pass is a field wired to a constant, which
5115    /// would report the invariant as perfect forever.
5116    #[tokio::test]
5117    async fn the_host_list_reports_round_trips_and_they_grow() {
5118        let origin = origin_with(one_page()).await;
5119
5120        async fn trips_now(origin: &Origin) -> u64 {
5121            let res = origin
5122                .handle(loopback("/_control/hosts", Some(TEST_TOKEN)))
5123                .await;
5124            assert_eq!(res.status(), StatusCode::OK);
5125            let text = String::from_utf8(body_of(res).await.to_vec()).expect("utf-8");
5126            let parsed: serde_json::Value = serde_json::from_str(&text).expect("json");
5127            let open = parsed["open"].as_array().expect("open is an array");
5128            assert_eq!(open.len(), 1, "{text}");
5129            open[0]["trips"].as_u64().expect("trips is a number")
5130        }
5131
5132        let before = trips_now(&origin).await;
5133        let res = origin.handle(get("/a.html", None)).await;
5134        assert_eq!(res.status(), StatusCode::OK);
5135        let after = trips_now(&origin).await;
5136
5137        assert!(
5138            after > before,
5139            "serving a page reported no round trips ({before} -> {after})"
5140        );
5141    }
5142
5143    /// A remembered label finds the ssh_config `Host` it came from, whatever its case.
5144    ///
5145    /// The regression this exists for: startup dialled the *label* rather than the `Host`, so a
5146    /// config saying `Host Panza` and a remembered `panza` produced `Could not resolve hostname
5147    /// panza` on every single start — while turning the host on a moment earlier had worked,
5148    /// because that path had the entry in hand. Two runs found it; no fake remote could have.
5149    #[test]
5150    fn a_remembered_label_finds_the_host_it_came_from() {
5151        let known = vec![
5152            ssh_config::Host {
5153                host: "Panza".to_string(),
5154                alias: "panza".to_string(),
5155            },
5156            ssh_config::Host {
5157                host: "issp-ohtaka".to_string(),
5158                alias: "issp-ohtaka".to_string(),
5159            },
5160        ];
5161
5162        // What is actually written down is the label, and it has to reach `Panza`.
5163        assert_eq!(
5164            entry_for(&known, "panza").map(|h| h.host.as_str()),
5165            Some("Panza")
5166        );
5167        // And the other spelling, for a name typed rather than clicked.
5168        assert_eq!(
5169            entry_for(&known, "Panza").map(|h| h.host.as_str()),
5170            Some("Panza")
5171        );
5172        assert_eq!(
5173            entry_for(&known, "issp-ohtaka").map(|h| h.host.as_str()),
5174            Some("issp-ohtaka")
5175        );
5176    }
5177
5178    /// A name ssh has never heard of is not dialled.
5179    ///
5180    /// Startup is the one path that reads hosts out of a file rather than from a request, so
5181    /// without this it is a looser door into "make this daemon ssh somewhere" than the two that
5182    /// are guarded — and one that fires again at every start.
5183    #[test]
5184    fn a_name_ssh_config_does_not_know_resolves_to_nothing() {
5185        let known = vec![ssh_config::Host {
5186            host: "Panza".to_string(),
5187            alias: "panza".to_string(),
5188        }];
5189        assert!(entry_for(&known, "not-a-host-anywhere").is_none());
5190        assert!(entry_for(&known, "").is_none());
5191        // Not a prefix or substring match either: `panz` is somebody else's name.
5192        assert!(entry_for(&known, "panz").is_none());
5193    }
5194
5195    /// Enabling a host is held to the same gate opening one is.
5196    ///
5197    /// This is a second door into "make the daemon ssh somewhere", and a worse one if it is
5198    /// looser, because what it writes down is retried at every start from then on. The name is
5199    /// nonsense on purpose, so this asserts the same thing whether or not the machine running
5200    /// it has an ssh_config at all.
5201    #[tokio::test]
5202    async fn enabling_a_host_ssh_does_not_know_is_refused() {
5203        let origin = origin_with(one_page()).await;
5204        let res = origin
5205            .handle(control_post(
5206                "/_control/enabled",
5207                Some(TEST_TOKEN),
5208                r#"{"host":"not-a-host-in-anyones-ssh-config.invalid","enabled":true}"#,
5209            ))
5210            .await;
5211        assert_eq!(res.status(), StatusCode::NOT_FOUND);
5212    }
5213
5214    /// And it needs the token, like everything else on this API.
5215    ///
5216    /// Worth its own check rather than trusting the gate: this route writes a file that
5217    /// survives the process, so "anyone on loopback can make this daemon ssh somewhere every
5218    /// morning" is the failure it would be.
5219    #[tokio::test]
5220    async fn enabling_a_host_without_the_token_is_refused() {
5221        let origin = origin_with(one_page()).await;
5222        let res = origin
5223            .handle(control_post(
5224                "/_control/enabled",
5225                None,
5226                r#"{"host":"anything","enabled":true}"#,
5227            ))
5228            .await;
5229        assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
5230    }
5231
5232    /// A body that does not say what to do is refused rather than defaulted.
5233    ///
5234    /// Defaulting `enabled` would make a malformed request turn a host on, or off, and the
5235    /// caller would have no way to tell which had happened.
5236    #[tokio::test]
5237    async fn enabling_needs_to_say_which_way() {
5238        let origin = origin_with(one_page()).await;
5239        for body in [
5240            r#"{"host":"anything"}"#,
5241            r#"{"enabled":true}"#,
5242            "{}",
5243            "not json",
5244        ] {
5245            let res = origin
5246                .handle(control_post("/_control/enabled", Some(TEST_TOKEN), body))
5247                .await;
5248            assert_eq!(
5249                res.status(),
5250                StatusCode::BAD_REQUEST,
5251                "{body} should not have been accepted"
5252            );
5253        }
5254    }
5255
5256    /// Turning a host off closes it now, not only next time.
5257    ///
5258    /// A setting that took effect at the next restart is indistinguishable from one that did
5259    /// not work, and in this direction it is worse than confusing: a host still answering after
5260    /// you switched it off is an ssh session you believe you have given back.
5261    /// The failure this whole path exists to end.
5262    ///
5263    /// A declared alias whose ssh will not come up used to be fatal at startup and, once past
5264    /// that, indistinguishable from a name nobody had written down. Now it is a gateway that
5265    /// did not answer, said in the status code, and the next request asks again.
5266    #[tokio::test]
5267    async fn an_alias_whose_host_is_down_is_still_an_alias() {
5268        let mut origin = origin_with(one_page()).await;
5269        // No cooldown, so the second request below reaches the dial rather than the memory of
5270        // the first. The cooldown itself is the next test.
5271        origin.cooldown = Duration::ZERO;
5272        // The declared host is `nowhere`, so re-dialling it fails without a network.
5273        origin.sessions.write().await.remove("docs");
5274
5275        let res = origin.handle(get("/a.html", None)).await;
5276        assert_eq!(
5277            res.status(),
5278            StatusCode::BAD_GATEWAY,
5279            "a declared alias that will not connect is 502, not 404"
5280        );
5281        assert!(
5282            origin.trouble.read().await.contains_key("docs"),
5283            "the reason has to outlive the request that found it"
5284        );
5285
5286        // Not latched. Asked again, the dial happens again -- a daemon that took the first
5287        // failure as final would pass every check above and still need restarting.
5288        //
5289        // `at` is the evidence and the status is not: a second 502 is what a re-dial and a
5290        // remembered failure both look like from outside, so a test that only read the status
5291        // would pass whichever this did.
5292        let first = origin.trouble.read().await["docs"].at;
5293        assert_eq!(
5294            origin.handle(get("/a.html", None)).await.status(),
5295            StatusCode::BAD_GATEWAY
5296        );
5297        assert!(
5298            origin.trouble.read().await["docs"].at > first,
5299            "the second request did not reach the dial"
5300        );
5301    }
5302
5303    /// But not once per request, because a page decides how often those arrive.
5304    ///
5305    /// `<img src="http://docs.ssh-browser/x">` in a loop is a remote document choosing how
5306    /// often this machine opens an ssh. It cannot name a host that was not declared -- the set
5307    /// is fixed at startup and is not `ssh_config` -- so the reach is a host the reader already
5308    /// asked to have served. That makes it cheap, not free, and the rate is the part somebody
5309    /// else was choosing.
5310    #[tokio::test]
5311    async fn a_page_cannot_choose_how_often_an_ssh_is_opened() {
5312        let mut origin = origin_with(one_page()).await;
5313        origin.cooldown = Duration::from_secs(300);
5314        origin.sessions.write().await.remove("docs");
5315
5316        assert_eq!(
5317            origin.handle(get("/a.html", None)).await.status(),
5318            StatusCode::BAD_GATEWAY
5319        );
5320        let first = origin.trouble.read().await["docs"].at;
5321
5322        for _ in 0..5 {
5323            assert_eq!(
5324                origin.handle(get("/a.html", None)).await.status(),
5325                StatusCode::BAD_GATEWAY,
5326                "the answer is still the truth, it just costs nothing to give"
5327            );
5328        }
5329        assert_eq!(
5330            origin.trouble.read().await["docs"].at,
5331            first,
5332            "a loop of requests dialled more than once inside the cooldown"
5333        );
5334    }
5335
5336    /// A connection that dies is dropped, not held.
5337    ///
5338    /// Without this the daemon answers `sftp session is gone` for as long as it runs. It is
5339    /// the one failure mode a reconnecting daemon must not have, and the only way to see it is
5340    /// to kill a real connection under a live origin.
5341    #[tokio::test]
5342    async fn a_dead_connection_is_not_held() {
5343        let (fs, stop) = one_page().spawn_stoppable().await;
5344        let origin = origin_over(fs, Cache::default());
5345        assert_eq!(
5346            origin.handle(get("/a.html", None)).await.status(),
5347            StatusCode::OK
5348        );
5349
5350        stop.abort();
5351        // The driver task notices when its reader fails, which is a scheduling hop away rather
5352        // than a duration. Bounded so a change that stops it noticing fails here instead of
5353        // hanging the suite.
5354        for _ in 0..1000 {
5355            if origin.live("docs").await.is_none() {
5356                break;
5357            }
5358            tokio::task::yield_now().await;
5359        }
5360        assert!(
5361            origin.live("docs").await.is_none(),
5362            "a connection whose remote is gone must not stay in the map"
5363        );
5364
5365        // And what follows is a re-dial rather than a corpse. Both are 502, so the status is
5366        // not the evidence -- what it says is. `nowhere` is not a host, so a request that
5367        // re-dialled reports ssh failing to reach it; one that went down the dead connection
5368        // instead would report the pipe, and only the request after that would try again.
5369        let res = origin.handle(get("/a.html", None)).await;
5370        assert_eq!(res.status(), StatusCode::BAD_GATEWAY);
5371        let said = String::from_utf8_lossy(&body_of(res).await).to_string();
5372        assert!(said.contains("is not connected"), "{said}");
5373        assert!(
5374            !said.contains("session closed"),
5375            "the dead connection was used rather than replaced: {said}"
5376        );
5377
5378        // The window this cannot reach: a connection that is gone but whose driver task has
5379        // not noticed yet, because the notice arrives when its own read fails and a request
5380        // can be handed down the pipe before that. An in-memory duplex closes synchronously,
5381        // so there is no window here to open. It was measured instead against a real host --
5382        // kill the ssh under a live daemon, reload once -- three times for three reloads, all
5383        // 200. Before `alias` checked liveness *after* the attempt, that reload was a 502 and
5384        // only the one after it worked.
5385    }
5386
5387    /// A name nobody declared stays a 404.
5388    ///
5389    /// The three answers have to stay three. If everything unreachable became 502 then a typo
5390    /// in a URL would read as somebody's host being down.
5391    #[tokio::test]
5392    async fn an_undeclared_name_is_not_a_gateway_failure() {
5393        let origin = origin_with(one_page()).await;
5394        let res = origin.handle(get_on("typo", "/a.html")).await;
5395        assert_eq!(res.status(), StatusCode::NOT_FOUND);
5396    }
5397
5398    #[tokio::test]
5399    async fn disabling_a_host_closes_it_now() {
5400        let origin = origin_with(one_page()).await;
5401        // A host opened from the dashboard, not a declared alias. The two are closed on
5402        // different terms now and this is the one disabling is about: it was never in
5403        // `declared`, so removing the session is the whole of stopping it and the name goes
5404        // back to being one this daemon does not serve. Sharing the `Arc` rather than opening
5405        // a second remote means dropping this one leaves `docs` connected, which is the point
5406        // -- disabling one host must not disturb another.
5407        let session = Arc::clone(&origin.sessions.read().await["docs"]);
5408        origin
5409            .sessions
5410            .write()
5411            .await
5412            .insert("opened".to_string(), session);
5413        assert!(origin.live("opened").await.is_some());
5414
5415        origin.sessions.write().await.remove("opened");
5416        assert!(origin.live("opened").await.is_none());
5417        let res = origin.handle(get_on("opened", "/a.html")).await;
5418        assert_eq!(res.status(), StatusCode::NOT_FOUND);
5419        assert_eq!(
5420            origin.handle(get("/a.html", None)).await.status(),
5421            StatusCode::OK,
5422            "closing one must not disturb another"
5423        );
5424    }
5425
5426    /// Nothing about how to reach a host is reported to anything but the dashboard.
5427    ///
5428    /// The point of the whole arrangement: `ssh_config` keeps the account, the port and the
5429    /// jump host, and an alias origin never learns any of it. A page that could read this off
5430    /// its own origin would be reading the machine's ssh setup.
5431    #[tokio::test]
5432    async fn an_alias_origin_cannot_read_the_host_list() {
5433        let origin = origin_with(one_page()).await;
5434        for path in ["/_control/hosts", "/_control/enabled", "/_control/hello"] {
5435            let res = origin.handle(get(path, None)).await;
5436            assert_ne!(
5437                res.status(),
5438                StatusCode::OK,
5439                "{path} answered a request from an alias origin"
5440            );
5441        }
5442    }
5443
5444    /// The check that keeps `open` from being "ssh to anything on request". The list the
5445    /// extension offers is the menu, and a host that is not on it is a config change,
5446    /// which is a deliberate act rather than one request.
5447    ///
5448    /// The name is nonsense on purpose, so this asserts the same thing on a machine with
5449    /// an ssh_config and on one without.
5450    #[tokio::test]
5451    async fn opening_a_host_ssh_does_not_know_is_refused() {
5452        let origin = origin_with(one_page()).await;
5453        let res = origin
5454            .handle(control_post(
5455                "/_control/open",
5456                Some(TEST_TOKEN),
5457                r#"{"host":"not-a-host-in-anyones-ssh-config.invalid"}"#,
5458            ))
5459            .await;
5460        assert_eq!(res.status(), StatusCode::NOT_FOUND);
5461    }
5462
5463    /// A body that does not name a host, and one that names a field this does not have.
5464    /// The second matters for the same reason the config file refuses unknown keys: a
5465    /// quietly dropped `base_path` opens an alias at somewhere nobody chose.
5466    #[tokio::test]
5467    async fn an_open_request_that_is_not_one_is_refused() {
5468        let origin = origin_with(one_page()).await;
5469        for body in [
5470            "",
5471            "{}",
5472            r#"{"base":"/srv"}"#,
5473            r#"{"host":"docs","base_path":"/srv"}"#,
5474        ] {
5475            let res = origin
5476                .handle(control_post("/_control/open", Some(TEST_TOKEN), body))
5477                .await;
5478            assert_eq!(
5479                res.status(),
5480                StatusCode::BAD_REQUEST,
5481                "should have been refused: {body}"
5482            );
5483        }
5484    }
5485
5486    /// `open` has a side effect, so it is the route where the token matters most: a page
5487    /// can send a simple POST without a preflight, and could not read the answer but
5488    /// would still have caused the thing to happen.
5489    #[tokio::test]
5490    async fn opening_a_host_needs_the_token() {
5491        let origin = origin_with(one_page()).await;
5492        for token in [None, Some("wrong")] {
5493            let res = origin
5494                .handle(control_post("/_control/open", token, r#"{"host":"docs"}"#))
5495                .await;
5496            assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "token {token:?}");
5497        }
5498    }
5499
5500    /// What removes the paste, and the check that makes it safe to.
5501    ///
5502    /// The header values are the measured ones: an extension's `fetch` arrives with no
5503    /// `Sec-Fetch-Site` value this daemon would call a page, and a page the daemon itself
5504    /// serves in fallback mode arrives as `same-origin` -- the hardest case, because it
5505    /// shares an origin with the control API.
5506    #[tokio::test]
5507    async fn the_token_is_handed_over_to_something_that_is_not_a_page() {
5508        let origin = origin_with(one_page()).await;
5509        for site in [None, Some("none")] {
5510            let res = origin.handle(from_site("/_control/token", site)).await;
5511            assert_eq!(res.status(), StatusCode::OK, "site {site:?}");
5512            let body = String::from_utf8(body_of(res).await.to_vec()).expect("utf-8");
5513            assert_eq!(body.trim(), TEST_TOKEN, "site {site:?}");
5514        }
5515    }
5516
5517    #[tokio::test]
5518    async fn a_page_is_not_handed_the_token() {
5519        let origin = origin_with(one_page()).await;
5520        for site in ["same-origin", "same-site", "cross-site"] {
5521            let res = origin
5522                .handle(from_site("/_control/token", Some(site)))
5523                .await;
5524            assert_eq!(res.status(), StatusCode::FORBIDDEN, "site {site}");
5525            let body = String::from_utf8(body_of(res).await.to_vec()).expect("utf-8");
5526            assert!(!body.contains(TEST_TOKEN), "the refusal leaked it: {body}");
5527        }
5528    }
5529
5530    /// The case the token alone could not refuse: a page in the no-proxy fallback mode is
5531    /// same-origin with the control API, so a leaked token would have been enough.
5532    #[tokio::test]
5533    async fn a_page_with_the_token_still_cannot_use_the_control_api() {
5534        let origin = origin_with(one_page()).await;
5535        let req = Request::builder()
5536            .uri("http://127.0.0.1:7391/_control/hello")
5537            .header(HOST, "127.0.0.1:7391")
5538            .header(control::TOKEN_HEADER, TEST_TOKEN)
5539            .header(control::FETCH_SITE_HEADER, "same-origin")
5540            .body(Full::new(Bytes::new()))
5541            .expect("request builds");
5542        assert_eq!(origin.handle(req).await.status(), StatusCode::FORBIDDEN);
5543    }
5544
5545    /// The other half of `open`, and the way a base gets changed: close, then reopen.
5546    #[tokio::test]
5547    async fn an_alias_can_be_closed_and_is_then_gone() {
5548        let origin = origin_with(one_page()).await;
5549        assert_eq!(
5550            origin.handle(get("/a.html", None)).await.status(),
5551            StatusCode::OK
5552        );
5553
5554        let res = origin
5555            .handle(control_post(
5556                "/_control/close",
5557                Some(TEST_TOKEN),
5558                r#"{"alias":"docs"}"#,
5559            ))
5560            .await;
5561        assert_eq!(res.status(), StatusCode::OK);
5562
5563        // The origin stops answering, rather than answering with stale bytes out of the
5564        // cache. An alias that is closed but still serving would be the worst of both.
5565        //
5566        // 503 and not 404, and the difference is the whole of this change: `docs` is still an
5567        // alias this daemon serves, it has been switched off. A 404 here would say the name
5568        // was never known, which is what the daemon used to say about every host that was
5569        // merely asleep -- and the reader would go looking in the config file for a name that
5570        // is sitting right there in it.
5571        let res = origin.handle(get("/a.html", None)).await;
5572        assert_eq!(res.status(), StatusCode::SERVICE_UNAVAILABLE);
5573
5574        // And a reload does not undo it. A stop that the next request reversed would not be a
5575        // stop, and this is the exact path that would reverse it.
5576        assert_eq!(
5577            origin.handle(get("/a.html", None)).await.status(),
5578            StatusCode::SERVICE_UNAVAILABLE
5579        );
5580    }
5581
5582    /// Kept apart from success. Told neither, a caller cannot tell "closed it" from
5583    /// "there was nothing there", and the second usually means a typo.
5584    #[tokio::test]
5585    async fn closing_an_alias_that_is_not_open_says_so() {
5586        let origin = origin_with(one_page()).await;
5587        let res = origin
5588            .handle(control_post(
5589                "/_control/close",
5590                Some(TEST_TOKEN),
5591                r#"{"alias":"nope"}"#,
5592            ))
5593            .await;
5594        assert_eq!(res.status(), StatusCode::NOT_FOUND);
5595    }
5596
5597    #[tokio::test]
5598    async fn closing_an_alias_needs_the_token() {
5599        let origin = origin_with(one_page()).await;
5600        let res = origin
5601            .handle(control_post("/_control/close", None, r#"{"alias":"docs"}"#))
5602            .await;
5603        assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
5604        // And it must not have happened anyway.
5605        assert_eq!(
5606            origin.handle(get("/a.html", None)).await.status(),
5607            StatusCode::OK
5608        );
5609    }
5610
5611    /// souta's actual problem, in miniature. `out/` contains no HTML of its own; the board
5612    /// is `out/ft_demo/index.html`. Grouping the HTML in one listing would never surface
5613    /// it, so a directory that *is* a page has to say so.
5614    #[tokio::test]
5615    async fn a_directory_holding_an_index_is_listed_as_a_site() {
5616        let origin = origin_with(
5617            FakeRemote::new()
5618                .dir("/srv", vec![("ft-demo", dir_attrs()), ("src", dir_attrs())])
5619                .dir("/srv/ft-demo", vec![("index.html", file_attrs(5, 1))])
5620                .dir("/srv/src", vec![("main.jl", file_attrs(5, 1))])
5621                .file("/srv/ft-demo/index.html", b"board"),
5622        )
5623        .await;
5624
5625        let body = String::from_utf8(body_of(origin.handle(get("/", None)).await).await.to_vec())
5626            .expect("utf-8");
5627        // Marked, so it reads as somewhere to open rather than somewhere to look. With no
5628        // headings left, the class and its colour are the whole signal.
5629        assert!(
5630            body.contains("class=\"row site\" href=\"/ft-demo/\""),
5631            "{body}"
5632        );
5633        let demo = body.find("ft-demo/").expect("the site listed");
5634        let src = body.find("src/").expect("the folder listed");
5635        assert!(demo < src, "a site leads the other directories: {body}");
5636    }
5637
5638    /// The invariant, on the one page that pays for the scan. One listing for the directory
5639    /// and one batch for all of its subdirectories, whether there are two or twenty -- not
5640    /// one round trip each, which is what a loop would cost and what would make browsing a
5641    /// deep tree unusable over a real link.
5642    #[tokio::test]
5643    async fn the_site_scan_costs_the_same_however_many_subdirectories() {
5644        async fn trips_for(n: usize) -> u64 {
5645            let names: Vec<String> = (0..n).map(|i| format!("d{i:02}")).collect();
5646            let mut remote = FakeRemote::new().dir(
5647                "/srv",
5648                names.iter().map(|s| (s.as_str(), dir_attrs())).collect(),
5649            );
5650            for name in &names {
5651                remote = remote.dir(&format!("/srv/{name}"), vec![("a.txt", file_attrs(1, 1))]);
5652            }
5653            let origin = origin_with(remote).await;
5654            let before = trips(&origin).await;
5655            assert_eq!(origin.handle(get("/", None)).await.status(), StatusCode::OK);
5656            trips(&origin).await - before
5657        }
5658
5659        let few = trips_for(2).await;
5660        let many = trips_for(20).await;
5661        assert_eq!(
5662            few, many,
5663            "{many} round trips for twenty subdirectories against {few} for two"
5664        );
5665    }
5666
5667    /// And stepping into one of them is free afterwards, because the scan already fetched
5668    /// exactly the listing that click needs. The extra round trip is not purely a cost.
5669    #[tokio::test]
5670    async fn the_scan_leaves_the_next_click_paid_for() {
5671        let origin = origin_with(
5672            FakeRemote::new()
5673                .dir("/srv", vec![("sub", dir_attrs())])
5674                .dir("/srv/sub", vec![("a.txt", file_attrs(1, 1))]),
5675        )
5676        .await;
5677        assert_eq!(origin.handle(get("/", None)).await.status(), StatusCode::OK);
5678
5679        let before = trips(&origin).await;
5680        assert_eq!(
5681            origin.handle(get("/sub/", None)).await.status(),
5682            StatusCode::OK
5683        );
5684        assert_eq!(
5685            trips(&origin).await,
5686            before,
5687            "the listing the scan fetched should still be the one that answers"
5688        );
5689    }
5690
5691    /// The race a two-second TTL made possible, forced to happen every time.
5692    ///
5693    /// The walk used to ask the cache whether a listing was there and then ask it for the
5694    /// listing. Those are two questions with a gap between them, and a request landing on
5695    /// the expiry boundary got yes and then no -- a 404 reading "cannot list" about a
5696    /// directory that plainly existed, on about one e2e run in six. With a TTL of zero
5697    /// every read misses, so the gap is guaranteed rather than occasional.
5698    ///
5699    /// It passes because the listings are taken once and held for the request. Nothing here
5700    /// can make them expire, because nothing re-reads them.
5701    #[tokio::test]
5702    async fn a_listing_that_expires_mid_request_does_not_lose_the_path() {
5703        let origin =
5704            origin_with_cache(deep_tree(), Cache::new(std::time::Duration::ZERO, 1 << 20)).await;
5705        assert_eq!(
5706            origin.handle(get("/a/b/c/d.html", None)).await.status(),
5707            StatusCode::OK,
5708            "a path four deep must survive its own listings expiring"
5709        );
5710        // And a directory too, which is the one that builds a tree out of them.
5711        assert_eq!(
5712            origin.handle(get("/a/b/c/", None)).await.status(),
5713            StatusCode::OK
5714        );
5715    }
5716
5717    /// The other half: a refusal that is not absence carries ssh's own words out, rather
5718    /// than this daemon's word for not knowing.
5719    #[tokio::test]
5720    async fn a_directory_the_remote_refuses_says_why() {
5721        let origin = origin_with(
5722            FakeRemote::new()
5723                .dir("/srv", vec![("locked", dir_attrs())])
5724                // 3 is SSH_FX_PERMISSION_DENIED: refused, and not for being absent.
5725                .refuses_listing("/srv/locked", 3),
5726        )
5727        .await;
5728
5729        let res = origin.handle(get("/locked/x.html", None)).await;
5730        assert_eq!(res.status(), StatusCode::BAD_GATEWAY);
5731        let said = String::from_utf8(body_of(res).await.to_vec()).expect("utf-8");
5732        assert!(said.contains("/srv/locked"), "{said}");
5733        assert!(
5734            !said.contains("cannot list"),
5735            "the old wording said nothing the reader could act on: {said}"
5736        );
5737    }
5738}