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