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