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