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