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