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;
20use std::net::SocketAddr;
21use std::sync::Arc;
22
23use anyhow::{Context, Result, ensure};
24use bytes::Bytes;
25use http_body_util::Full;
26use hyper::header::{
27    ACCEPT_RANGES, CACHE_CONTROL, CONTENT_RANGE, CONTENT_TYPE, ETAG, HOST, HeaderName,
28    IF_NONE_MATCH, IF_RANGE, LOCATION, RANGE,
29};
30use hyper::server::conn::http1;
31use hyper::service::service_fn;
32use hyper::{Method, Request, Response, StatusCode};
33use hyper_util::rt::TokioIo;
34use serde::{Deserialize, Serialize};
35use tokio::net::TcpListener;
36
37use crate::annot;
38use crate::cache::{self, Cache};
39use crate::control::{self, Token};
40use crate::fs::sftp::SftpFs;
41use crate::fs::{Entry, RangeReq, RemoteFs};
42use crate::prefetch;
43use crate::sftp::wire::Attrs;
44
45/// A file worth holding whole. Anything larger is served by range and not cached: a
46/// seek into a video must not pull the entire file, and holding one would evict every
47/// page body that makes a revisit free.
48const CACHE_WHOLE_MAX: u64 = 8 * 1024 * 1024;
49
50/// The request headers that change what is served rather than what is found.
51struct Conditions {
52    if_none_match: Option<String>,
53    range: Option<String>,
54    if_range: Option<String>,
55    /// Only ever consulted on the control path, which only a loopback request reaches.
56    control_token: Option<String>,
57}
58
59/// One alias, checked.
60///
61/// The fields are private and [`Alias::new`] is the only way to make one, so there is no
62/// route into the daemon that skips these checks. That matters now that aliases can come
63/// from a configuration file as well as from the command line: two entry points and one
64/// validating constructor is fine, two entry points and two copies of the rules is how the
65/// looser copy becomes the one that gets used.
66#[derive(Debug)]
67pub struct Alias {
68    name: String,
69    host: String,
70    base: String,
71}
72
73impl Alias {
74    pub fn new(name: &str, host: &str, base: &str) -> Result<Self> {
75        ensure!(!host.is_empty(), "alias {name:?} has no ssh host");
76        // The alias becomes a hostname label, and this is the very function that decides
77        // whether an arriving request's label is acceptable. Asking it, rather than writing
78        // the rule out again, is what stops the two from disagreeing — and they already had:
79        // `-docs` satisfied the copy here and was then refused by `classify` on every single
80        // request, after the daemon had paid for the ssh connection and advertised the route.
81        ensure!(
82            guard::is_label(name),
83            "alias {name:?} must be lowercase letters, digits and hyphens, and may not start or end with a hyphen: it becomes a hostname label"
84        );
85        ensure!(
86            base.starts_with('/'),
87            "alias {name:?} needs an absolute base path, got {base:?}"
88        );
89        Ok(Self {
90            name: name.to_string(),
91            host: host.to_string(),
92            base: base.to_string(),
93        })
94    }
95
96    pub fn name(&self) -> &str {
97        &self.name
98    }
99
100    pub fn host(&self) -> &str {
101        &self.host
102    }
103
104    pub fn base(&self) -> &str {
105        &self.base
106    }
107}
108
109struct Session {
110    base: String,
111    fs: SftpFs,
112}
113
114pub struct Origin {
115    suffix: String,
116    port: u16,
117    sessions: HashMap<String, Session>,
118    cache: Cache,
119    token: Token,
120    /// Whose annotations this daemon writes.
121    ///
122    /// Configured rather than discovered. The SFTP transport never runs a shell, so the
123    /// remote account name is not something this process can ask for; guessing it from a
124    /// home directory path would be a guess presented as a fact. What it is checked
125    /// against is the owner a listing reports, which catches a configured name the remote
126    /// does not actually write as — see `annot::Attribution`.
127    author: String,
128}
129
130/// A listening socket and the origin that will answer on it.
131///
132/// Separate from [`Origin`] so that "the port is ours" is a thing the caller holds rather
133/// than something it hopes for. A caller cannot announce that the daemon is up before it
134/// is, because it has nothing to announce until this exists.
135pub struct Bound {
136    origin: Arc<Origin>,
137    listener: TcpListener,
138}
139
140impl Origin {
141    /// Take the port, then connect every alias.
142    ///
143    /// The port first, deliberately. It is the thing that fails immediately and for a
144    /// reason the operator can do something about — another daemon already has it — and a
145    /// handful of ssh handshakes paid before discovering that is time spent to learn
146    /// nothing.
147    ///
148    /// The aliases are connected here rather than on first use so that the first page
149    /// request does not also pay for an ssh handshake.
150    pub async fn bind(
151        aliases: Vec<Alias>,
152        suffix: String,
153        port: u16,
154        token: Token,
155        author: String,
156    ) -> Result<Bound> {
157        let addr = SocketAddr::from(([127, 0, 0, 1], port));
158        let listener = TcpListener::bind(addr)
159            .await
160            .with_context(|| format!("bind {addr}"))?;
161
162        // Held to the same rule the PAC is, and here rather than only there: a suffix the
163        // PAC would refuse is one no alias URL can ever match, so starting with it produces a
164        // daemon that listens and serves nothing.
165        ensure!(
166            pac::is_suffix(&suffix),
167            "suffix {suffix:?} must be lowercase letters, digits, hyphens and dots"
168        );
169        // The author becomes a filename, and the only check on it used to live inside the
170        // write path. A typo therefore started a daemon that read pages perfectly well and
171        // then answered the reader's first note with a 500. It is configuration, so it is
172        // refused where the rest of the configuration is.
173        ensure!(
174            annot::is_safe_name(&author),
175            "author {author:?} must be letters, digits, dots, dashes or underscores: it becomes a filename"
176        );
177
178        let mut sessions = HashMap::new();
179        for a in aliases {
180            let fs = SftpFs::connect(&a.host)
181                .await
182                .with_context(|| format!("alias {} -> ssh host {}", a.name, a.host))?;
183            // Checked where the map is built, so there is no way to reach a session map with
184            // a name silently missing from it. A caller may have checked earlier and should;
185            // `insert` returning the displaced value is the check that cannot be skipped.
186            ensure!(
187                sessions
188                    .insert(a.name.clone(), Session { base: a.base, fs })
189                    .is_none(),
190                "alias {:?} is defined twice",
191                a.name
192            );
193        }
194        Ok(Bound {
195            origin: Arc::new(Self {
196                suffix,
197                port,
198                sessions,
199                cache: Cache::default(),
200                token,
201                author,
202            }),
203            listener,
204        })
205    }
206}
207
208impl Bound {
209    pub async fn serve(self) -> Result<()> {
210        let Bound { origin, listener } = self;
211        let self_ = origin;
212
213        loop {
214            let (stream, _) = listener.accept().await?;
215            let me = Arc::clone(&self_);
216            tokio::spawn(async move {
217                let service = service_fn(move |req| {
218                    let me = Arc::clone(&me);
219                    async move { Ok::<_, std::convert::Infallible>(me.handle(req).await) }
220                });
221                // Keep-alive is not a nicety here: a page pulls many subresources
222                // and a fresh connection each time would add a local handshake per
223                // request on top of the remote cost.
224                let _ = http1::Builder::new()
225                    .serve_connection(TokioIo::new(stream), service)
226                    .await;
227            });
228        }
229    }
230}
231
232impl Origin {
233    /// Generic over the body type so a test can drive it without constructing
234    /// hyper's `Incoming`, which only a real connection can produce.
235    pub async fn handle<B>(&self, req: Request<B>) -> Response<Full<Bytes>>
236    where
237        B: hyper::body::Body,
238        B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
239    {
240        let Some(host) = host_of(&req) else {
241            return fail(StatusCode::BAD_REQUEST, "request carries no Host");
242        };
243        let path = req.uri().path().to_string();
244        let cond = Conditions {
245            if_none_match: header(&req, IF_NONE_MATCH),
246            range: header(&req, RANGE),
247            if_range: header(&req, IF_RANGE),
248            control_token: req
249                .headers()
250                .get(control::TOKEN_HEADER)
251                .and_then(|v| v.to_str().ok())
252                .map(str::to_string),
253        };
254        let method = req.method().clone();
255        let query = req.uri().query().map(str::to_string);
256
257        // The body is read for the control prefix and nowhere else. Reading it on every
258        // request would let any caller make the daemon hold memory it has no use for.
259        let control_body = if path.starts_with(control::PATH_PREFIX) {
260            match read_body(req.into_body()).await {
261                Ok(b) => b,
262                Err(e) => return fail(StatusCode::BAD_REQUEST, e),
263            }
264        } else {
265            Bytes::new()
266        };
267
268        match guard::classify(&host, &path, &self.suffix, self.port) {
269            // Refusing by Host is the DNS-rebinding defence, not a malfunction, so
270            // it says why rather than failing blankly.
271            Err(e) => fail(StatusCode::FORBIDDEN, format!("{e:#}")),
272            Ok(guard::Target::Direct { path }) => {
273                self.direct(&method, path, &cond, query.as_deref(), &control_body)
274                    .await
275            }
276            Ok(guard::Target::Alias { alias, path }) => {
277                self.alias(&method, alias, path, &cond).await
278            }
279        }
280    }
281
282    async fn direct(
283        &self,
284        method: &Method,
285        path: &str,
286        cond: &Conditions,
287        query: Option<&str>,
288        body: &[u8],
289    ) -> Response<Full<Bytes>> {
290        // Reachable only from a loopback Host, which `guard::classify` has already
291        // separated from alias requests. An alias page cannot arrive here.
292        if path.starts_with(control::PATH_PREFIX) {
293            // Every control route goes through the gate, and there is no way past it.
294            if let Some(refusal) = control::gate(method, cond.control_token.as_deref(), &self.token)
295            {
296                return refusal;
297            }
298            return self.control(method, path, query, body).await;
299        }
300
301        if path == "/proxy.pac" {
302            return match pac::script(&self.suffix, self.port) {
303                Ok(body) => plain_ok("application/x-ns-proxy-autoconfig", Bytes::from(body)),
304                Err(e) => fail(StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")),
305            };
306        }
307
308        let rest = path.trim_start_matches('/');
309        if rest.is_empty() {
310            return plain_ok("text/html; charset=utf-8", Bytes::from(self.alias_index()));
311        }
312
313        let (alias, sub) = rest.split_once('/').unwrap_or((rest, ""));
314        self.alias(method, alias, &format!("/{sub}"), cond).await
315    }
316
317    async fn alias(
318        &self,
319        method: &Method,
320        alias: &str,
321        path: &str,
322        cond: &Conditions,
323    ) -> Response<Full<Bytes>> {
324        // The alias origin is read-only, and says so rather than quietly serving a POST
325        // as if it were a GET. The shape of this answer is part of the boundary: there
326        // is no write path on this origin and there will not be one. Writes go through
327        // the control API, which a page served from here cannot reach.
328        if !matches!(*method, Method::GET | Method::HEAD) {
329            return fail(
330                StatusCode::METHOD_NOT_ALLOWED,
331                format!("{method} is not allowed: this origin is read-only"),
332            );
333        }
334
335        let Some(session) = self.sessions.get(alias) else {
336            return fail(StatusCode::NOT_FOUND, format!("no alias named {alias:?}"));
337        };
338        let resolved = match guard::resolve(&session.base, path) {
339            Ok(p) => p,
340            Err(e) => return fail(StatusCode::FORBIDDEN, format!("{e:#}")),
341        };
342
343        let wants_dir = path.ends_with('/');
344        let file = if wants_dir {
345            format!("{resolved}/index.html")
346        } else {
347            resolved.clone()
348        };
349
350        // Every component between the alias base and the file, base first. The base
351        // itself is not checked: it is what the operator configured, and no request
352        // can change it.
353        let chain = components(&session.base, &file);
354        if chain.is_empty() {
355            return self.autoindex_of(session, path, &resolved).await;
356        }
357        let last = chain.len() - 1;
358
359        self.warm_ancestor_listings(session, &chain).await;
360
361        // Symlinks are settled before anything else, so the answer cannot depend on
362        // whether the target happens to exist: a symlink is refused either way, and
363        // checking it separately is what lets the write path share exactly this rule.
364        if let Some(at) = self.first_symlink(&chain) {
365            return fail(
366                StatusCode::FORBIDDEN,
367                format!("refusing symlink at {at} (its target is not checked)"),
368            );
369        }
370
371        let mut found_last = None;
372        for (i, (dir, name)) in chain.iter().enumerate() {
373            if !self.cache.has_listing(dir) {
374                return fail(StatusCode::NOT_FOUND, format!("{path}: cannot list {dir}"));
375            }
376            let Some(attrs) = self.cache.attrs_of(dir, name) else {
377                // Absent. For a directory request that only means there is no
378                // index.html, so fall through to a listing of the directory itself.
379                if i == last && wants_dir {
380                    return self.autoindex_of(session, path, &resolved).await;
381                }
382                return fail(StatusCode::NOT_FOUND, format!("not found: {path}"));
383            };
384
385            if i < last && !attrs.is_dir() {
386                return fail(
387                    StatusCode::NOT_FOUND,
388                    format!("{path}: {dir}/{name} is not a directory"),
389                );
390            }
391            if i == last {
392                found_last = Some(attrs);
393            }
394        }
395        let attrs = found_last.expect("the walk assigns on its final iteration");
396
397        if attrs.is_dir() {
398            if wants_dir {
399                // `<dir>/index.html` is itself a directory. Fall back to a listing.
400                return self.autoindex_of(session, path, &resolved).await;
401            }
402            // Without the trailing slash every relative link on the page below
403            // would resolve one level too high.
404            return redirect(&format!("{path}/"));
405        }
406
407        let tag = cache::etag(&attrs);
408
409        // The conditional GET never leaves this process: the validator came from the
410        // cached listing, so a browser already holding the current copy is answered
411        // with zero remote round trips. That is invariant 2.
412        //
413        // Nested rather than written as a let-chain: those stabilised in 1.88 and the
414        // declared MSRV here is 1.85.
415        if let (Some(tag), Some(header)) = (tag.as_deref(), cond.if_none_match.as_deref()) {
416            if cache::etag_matches(header, tag) {
417                return not_modified(tag);
418            }
419        }
420
421        // Size comes from the listing, which is what makes a range answerable without
422        // first fetching the file to discover how long it is.
423        let size = attrs.size.unwrap_or(0);
424        let wanted = match cond.range.as_deref() {
425            Some(header) => range::resolve(header, cond.if_range.as_deref(), size),
426            None => range::Resolved::Whole,
427        };
428        if wanted == range::Resolved::Unsatisfiable {
429            return unsatisfiable(size);
430        }
431
432        // A body already held answers a range by slicing, with no round trip at all.
433        if let Some(body) = self.cache.body(&file, &attrs) {
434            return respond(&file, body, tag.as_deref(), &wanted, size);
435        }
436
437        // Too large to hold: fetch only what was asked for. This branch is what makes
438        // seeking in a video possible. Without it a seek pulls the whole file, and
439        // holding that file would evict every page body that makes a revisit free.
440        if let range::Resolved::Part { start, end } = wanted {
441            if size > CACHE_WHOLE_MAX {
442                let req = RangeReq {
443                    path: file.clone(),
444                    offset: start,
445                    len: end - start + 1,
446                };
447                let mut got = session.fs.read_ranges(std::slice::from_ref(&req)).await;
448                return match got.pop() {
449                    Some(Ok(body)) => partial(
450                        mime::guess(&file),
451                        Bytes::from(body),
452                        tag.as_deref(),
453                        start,
454                        end,
455                        size,
456                    ),
457                    Some(Err(e)) => {
458                        self.cache.forget_listing(&chain[last].0);
459                        fail(StatusCode::NOT_FOUND, format!("{path}: {e:#}"))
460                    }
461                    None => fail(
462                        StatusCode::INTERNAL_SERVER_ERROR,
463                        "read_ranges returned no result",
464                    ),
465                };
466            }
467        }
468
469        let mut got = session.fs.read_batch(std::slice::from_ref(&file)).await;
470        match got.pop() {
471            Some(Ok(body)) => {
472                let body = Bytes::from(body);
473                self.cache.put_body(&file, &attrs, body.clone());
474                // Before answering, not after. The browser will ask for this page's
475                // subresources six at a time, and each wave it has to discover is a round
476                // trip; fetching them here costs one and makes the waves cache hits. Waiting
477                // also makes the invariant a guarantee rather than a race with the browser.
478                if mime::guess(&file).starts_with("text/html") {
479                    self.warm_subresources(session, path, &body).await;
480                }
481                respond(&file, body, tag.as_deref(), &wanted, size)
482            }
483            // The listing promised this file and the remote refused it, so the listing
484            // is wrong. Holding it for the rest of its TTL would repeat the same wrong
485            // answer.
486            Some(Err(e)) => {
487                self.cache.forget_listing(&chain[last].0);
488                fail(StatusCode::NOT_FOUND, format!("{path}: {e:#}"))
489            }
490            None => fail(
491                StatusCode::INTERNAL_SERVER_ERROR,
492                "read_batch returned no result",
493            ),
494        }
495    }
496
497    async fn control(
498        &self,
499        method: &Method,
500        path: &str,
501        query: Option<&str>,
502        body: &[u8],
503    ) -> Response<Full<Bytes>> {
504        match (method, control::route_of(path)) {
505            (&Method::GET, "hello") => {
506                let mut aliases: Vec<String> = self.sessions.keys().cloned().collect();
507                aliases.sort();
508                control::hello(&aliases, &self.suffix)
509            }
510            (&Method::GET, "annotations") => self.list_annotations(query).await,
511            (&Method::POST, "annotations") => self.add_annotation(body).await,
512            (&Method::GET, route) => {
513                control::text(StatusCode::NOT_FOUND, format!("no control route {route:?}"))
514            }
515            (_, route) => control::text(
516                StatusCode::METHOD_NOT_ALLOWED,
517                format!("{method} is not allowed on {route:?}"),
518            ),
519        }
520    }
521
522    /// Turn `<alias>/<path>` into a session and an absolute path.
523    ///
524    /// Runs the same guards the read path runs, and the symlink one matters more here: a
525    /// write that reached through a symlinked directory could place a file outside the
526    /// alias base entirely.
527    async fn resolve_doc(&self, doc: &str) -> Result<(&Session, String), (StatusCode, String)> {
528        let (alias, rest) = doc.split_once('/').unwrap_or((doc, ""));
529        let Some(session) = self.sessions.get(alias) else {
530            return Err((StatusCode::NOT_FOUND, format!("no alias named {alias:?}")));
531        };
532        let resolved = match guard::resolve(&session.base, &format!("/{rest}")) {
533            Ok(p) => p,
534            Err(e) => return Err((StatusCode::FORBIDDEN, format!("{e:#}"))),
535        };
536
537        let chain = components(&session.base, &resolved);
538        self.warm_ancestor_listings(session, &chain).await;
539        if let Some(at) = self.first_symlink(&chain) {
540            return Err((StatusCode::FORBIDDEN, format!("refusing symlink at {at}")));
541        }
542        Ok((session, resolved))
543    }
544
545    /// `GET /_control/annotations?doc=<alias>/<path>`
546    async fn list_annotations(&self, query: Option<&str>) -> Response<Full<Bytes>> {
547        let Some(doc) = param(query, "doc") else {
548            return control::text(
549                StatusCode::BAD_REQUEST,
550                "annotations needs a doc parameter, e.g. ?doc=docs/index.html",
551            );
552        };
553        let (session, resolved) = match self.resolve_doc(doc).await {
554            Ok(v) => v,
555            Err((status, detail)) => return control::text(status, detail),
556        };
557
558        match annot::Store::new(&session.fs).load(&resolved).await {
559            Ok(loaded) => control::json(&AnnotationsBody {
560                doc: resolved,
561                annotations: loaded.annotations,
562                skipped: loaded.skipped,
563            }),
564            Err(e) => control::text(StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")),
565        }
566    }
567
568    /// `POST /_control/annotations`
569    ///
570    /// The request carries no author. The author is this daemon's, so a caller cannot
571    /// write as somebody else however it words the request.
572    async fn add_annotation(&self, body: &[u8]) -> Response<Full<Bytes>> {
573        let request: AddBody = match serde_json::from_slice(body) {
574            Ok(r) => r,
575            Err(e) => {
576                return control::text(StatusCode::BAD_REQUEST, format!("malformed request: {e}"));
577            }
578        };
579
580        let (session, resolved) = match self.resolve_doc(&request.doc).await {
581            Ok(v) => v,
582            Err((status, detail)) => return control::text(status, detail),
583        };
584
585        // The id is minted here when adding, rather than accepted, so it cannot name an
586        // author other than the one doing the writing. For an update or a delete the
587        // caller has to name the record, and the store checks that the name belongs to
588        // this author before anything is written.
589        let id = match (request.op, request.id) {
590            (annot::Op::Add, None) => match annot::new_id(&self.author) {
591                Ok(id) => id,
592                Err(e) => {
593                    return control::text(StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}"));
594                }
595            },
596            (annot::Op::Add, Some(_)) => {
597                return control::text(
598                    StatusCode::BAD_REQUEST,
599                    "an id is minted by the daemon; do not send one when adding",
600                );
601            }
602            (_, Some(id)) => id,
603            (_, None) => {
604                return control::text(StatusCode::BAD_REQUEST, "an update or a delete needs an id");
605            }
606        };
607
608        // The timestamp is the daemon's too. A caller that could choose it could reorder
609        // someone's log, and position in the file is what actually decides anything.
610        let at = std::time::SystemTime::now()
611            .duration_since(std::time::UNIX_EPOCH)
612            .map_or(0, |d| d.as_secs());
613
614        let record = annot::Record {
615            op: request.op,
616            id: id.clone(),
617            at,
618            body: request.body,
619            selectors: request.selectors,
620            reply_to: request.reply_to,
621        };
622
623        match annot::Store::new(&session.fs)
624            .append(&resolved, &self.author, &record)
625            .await
626        {
627            Ok(()) => control::json(&AddedBody {
628                id,
629                at,
630                author: &self.author,
631            }),
632            Err(e) => control::text(StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")),
633        }
634    }
635
636    async fn autoindex_of(
637        &self,
638        session: &Session,
639        path: &str,
640        resolved: &str,
641    ) -> Response<Full<Bytes>> {
642        if let Some(entries) = self.cache.listing_entries(resolved) {
643            return plain_ok(
644                "text/html; charset=utf-8",
645                Bytes::from(autoindex(path, &entries)),
646            );
647        }
648        match session.fs.list_dir(resolved).await {
649            Ok(entries) => {
650                self.cache.put_listing(resolved, &entries);
651                plain_ok(
652                    "text/html; charset=utf-8",
653                    Bytes::from(autoindex(path, &entries)),
654                )
655            }
656            Err(e) => fail(StatusCode::NOT_FOUND, format!("{path}: {e:#}")),
657        }
658    }
659
660    /// Read what an HTML page is about to ask for, in one batch.
661    ///
662    /// One round trip to list the directories they live in, then one batch of reads — and
663    /// neither grows with the number of subresources. When they sit beside the document, which
664    /// is what a generated report looks like, the listing is already held and the listing round
665    /// disappears.
666    ///
667    /// Two at most, and it really is two. The reads go through `read_ranges` rather than
668    /// `read_batch` precisely so that this holds: `read_batch` has to poll in 32 KiB chunks
669    /// because it does not know how long a file is, which made a one-megabyte bundle
670    /// thirty-two round trips here. The listing already says how long each one is.
671    ///
672    /// Every reference goes through the same resolution and the same symlink rule as a real
673    /// request, on purpose. A page is untrusted input, and a prefetcher that skipped those
674    /// checks could be told to read a file the operator's configuration says is out of
675    /// bounds. Serving it would still be refused, but reading it is already the wrong act.
676    ///
677    /// Failures are dropped in silence here, which is the one place in this codebase that is
678    /// right: a reference that cannot be read is about to be requested for real, and that
679    /// request reports the failure properly. Saying anything now would be guessing at whether
680    /// the reader was going to care.
681    async fn warm_subresources(&self, session: &Session, doc_path: &str, html: &[u8]) {
682        let refs = prefetch::scan(html, prefetch::MAX_SUBRESOURCES);
683        if refs.is_empty() {
684            return;
685        }
686        // The directory the document is in, in URL terms, which is what a relative reference
687        // on the page is relative to.
688        let dir_of_doc = match doc_path.rsplit_once('/') {
689            Some((head, _)) => head,
690            None => "",
691        };
692
693        // Resolved first, so that a reference climbing out of the base is gone before it can
694        // contribute a directory to list.
695        let mut wanted: Vec<(String, Vec<(String, String)>)> = Vec::new();
696        for r in &refs {
697            let url = if r.starts_with('/') {
698                r.clone()
699            } else {
700                format!("{dir_of_doc}/{r}")
701            };
702            let Ok(resolved) = guard::resolve(&session.base, &url) else {
703                continue;
704            };
705            let chain = components(&session.base, &resolved);
706            if chain.is_empty() {
707                continue;
708            }
709            // Checked against what is already known before anything new is listed. Without
710            // this a page could get a directory behind a symlink listed purely by naming it,
711            // and the symlink rule exists precisely so that the daemon does not go there.
712            // The check runs again after the listings, for components not yet known.
713            if self.first_symlink(&chain).is_some() {
714                continue;
715            }
716            // And every directory this reference would cause to be listed has to be one the
717            // cache can already prove is not behind a symlink. `first_symlink` alone is not
718            // enough: it sees only what is cached, so a symlink one level below the deepest
719            // listing held is invisible to it and would be opened by the very batch meant to
720            // discover it.
721            if !chain
722                .iter()
723                .all(|(dir, _)| self.listable(&session.base, dir))
724            {
725                continue;
726            }
727            wanted.push((resolved, chain));
728        }
729
730        let all: Vec<(String, String)> = wanted.iter().flat_map(|(_, c)| c.clone()).collect();
731        self.warm_ancestor_listings(session, &all).await;
732
733        let mut to_read = Vec::new();
734        for (resolved, chain) in &wanted {
735            if self.first_symlink(chain).is_some() {
736                continue;
737            }
738            let (dir, name) = &chain[chain.len() - 1];
739            let Some(attrs) = self.cache.attrs_of(dir, name) else {
740                continue;
741            };
742            if attrs.is_dir() {
743                continue;
744            }
745            // The size has to be known, and not merely defaulted to zero, because it is what
746            // the read below asks for. A listing that did not report one leaves nothing to
747            // ask for, and requesting zero bytes would cache an empty body for a file that
748            // has contents.
749            let Some(size) = attrs.size else {
750                continue;
751            };
752            // Nothing to warm at zero, and warming it is where a listing that lies about the
753            // size does damage: a ranged read asks for exactly what it was told, so a file
754            // reported as empty is fetched as empty and then served that way. A real empty
755            // file loses nothing by being read on request.
756            //
757            // A file too large to hold, at the other end, would be read only to be declined
758            // by the cache and read again by the real request anyway.
759            if size == 0 || size > CACHE_WHOLE_MAX {
760                continue;
761            }
762            if self.cache.body(resolved, &attrs).is_some() {
763                continue;
764            }
765            to_read.push((resolved.clone(), attrs, size));
766        }
767        if to_read.is_empty() {
768            return;
769        }
770
771        // `read_ranges` rather than `read_batch`, because the size is already known.
772        //
773        // `read_batch` cannot know how long a file is, so it polls in 32 KiB chunks until it
774        // sees a short read: one round trip per chunk index, which makes a one-megabyte
775        // bundle thirty-two of them. `read_ranges` is handed the length, so it computes every
776        // chunk before issuing any and the whole file costs one. The listing this function
777        // already depends on is what supplies the length, so nothing extra is asked for.
778        let reqs: Vec<RangeReq> = to_read
779            .iter()
780            .map(|(path, _, size)| RangeReq {
781                path: path.clone(),
782                offset: 0,
783                len: *size,
784            })
785            .collect();
786
787        for ((path, attrs, size), got) in to_read.iter().zip(session.fs.read_ranges(&reqs).await) {
788            let Ok(body) = got else {
789                continue;
790            };
791            // Short of what the listing promised means the file changed underneath us. The
792            // cache key records the old size, so holding a body that no longer matches it
793            // would serve the next reader a length the bytes do not have. Leaving it out
794            // costs one prefetch; the real request reads it afresh.
795            if body.len() as u64 != *size {
796                continue;
797            }
798            self.cache.put_body(path, attrs, Bytes::from(body));
799        }
800    }
801
802    /// Fetch every ancestor listing not already held, in one batch.
803    ///
804    /// One round trip regardless of depth, which is the whole reason `list_dirs` is a batch
805    /// rather than a loop. A directory that cannot be listed is simply left absent from the
806    /// cache; the caller diagnoses that against the path the request actually named.
807    async fn warm_ancestor_listings(&self, session: &Session, chain: &[(String, String)]) {
808        let mut missing: Vec<String> = chain
809            .iter()
810            .map(|(dir, _)| dir.clone())
811            .filter(|dir| !self.cache.has_listing(dir))
812            .collect();
813        // Deduplicated because the prefetcher passes the chains of many files at once, and
814        // several of them normally share a directory. Listing one twice in a batch costs no
815        // extra round trip but it does cost the remote the work.
816        missing.sort();
817        missing.dedup();
818        if missing.is_empty() {
819            return;
820        }
821        for (dir, result) in missing.iter().zip(session.fs.list_dirs(&missing).await) {
822            if let Ok(entries) = result {
823                self.cache.put_listing(dir, &entries);
824            }
825        }
826    }
827
828    /// Can this directory be listed without asking the remote to walk through a symlink?
829    ///
830    /// True only when every step from the alias base down to it is already known — from a
831    /// listing already held — to be a real directory. A step that is not known yet is not
832    /// assumed safe, because SFTP v3 `OPENDIR` has no `O_NOFOLLOW`: asking the remote to
833    /// list a path *is* asking it to follow whatever symlinks are in that path, and the
834    /// answer arrives too late to un-ask. The base itself is operator configuration, not
835    /// something a request reaches, so it is the one directory taken on trust.
836    fn listable(&self, base: &str, dir: &str) -> bool {
837        if dir.trim_end_matches('/') == base.trim_end_matches('/') {
838            return true;
839        }
840        components(base, dir).iter().all(|(parent, name)| {
841            self.cache
842                .attrs_of(parent, name)
843                .is_some_and(|a| a.is_dir() && !a.is_symlink())
844        })
845    }
846
847    /// The first component of a chain that is a symlink, if any.
848    ///
849    /// Shared between reading and writing deliberately. A write that reached through a
850    /// symlinked directory could place a file outside the alias base entirely, which is
851    /// strictly worse than reading through one, so the two must not be able to drift apart.
852    fn first_symlink(&self, chain: &[(String, String)]) -> Option<String> {
853        chain.iter().find_map(|(dir, name)| {
854            self.cache
855                .attrs_of(dir, name)
856                .filter(Attrs::is_symlink)
857                .map(|_| format!("{dir}/{name}"))
858        })
859    }
860
861    fn alias_index(&self) -> String {
862        let mut names: Vec<&String> = self.sessions.keys().collect();
863        names.sort();
864        let mut s = String::from(
865            "<!doctype html><html><head><meta charset=\"utf-8\"><title>ssh-browser</title></head><body><h1>ssh-browser</h1><ul>",
866        );
867        for name in names {
868            let href = format!("http://{name}.{}/", self.suffix);
869            s.push_str("<li><a href=\"");
870            s.push_str(&escape(&href));
871            s.push_str("\">");
872            s.push_str(&escape(&href));
873            s.push_str("</a></li>");
874        }
875        s.push_str("</ul></body></html>");
876        s
877    }
878}
879
880#[derive(Serialize)]
881struct AnnotationsBody {
882    doc: String,
883    annotations: Vec<annot::Annotation>,
884    /// Lines that could not be parsed, reported rather than hidden.
885    skipped: usize,
886}
887
888#[derive(Serialize)]
889struct AddedBody<'a> {
890    id: String,
891    at: u64,
892    author: &'a str,
893}
894
895/// No author field, deliberately: see `add_annotation`.
896#[derive(Deserialize)]
897struct AddBody {
898    doc: String,
899    op: annot::Op,
900    /// Absent when adding — the daemon mints it. Required when updating or deleting.
901    #[serde(default)]
902    id: Option<String>,
903    #[serde(default)]
904    body: Option<String>,
905    #[serde(default)]
906    selectors: Option<serde_json::Value>,
907    #[serde(default)]
908    reply_to: Option<String>,
909}
910
911/// One raw value out of a query string.
912///
913/// Deliberately not percent-decoded here: `guard::resolve` decodes the path it is handed,
914/// and decoding twice would turn a literal `%2e%2e` in a filename into a traversal.
915fn param<'q>(query: Option<&'q str>, want: &str) -> Option<&'q str> {
916    query?.split('&').find_map(|pair| {
917        let (key, value) = pair.split_once('=')?;
918        (key == want).then_some(value)
919    })
920}
921
922/// Every step from the alias base down to the file, as `(directory to list, name to
923/// check inside it)`, base first.
924///
925/// The base is the first directory listed and is never itself a checked name: it is
926/// operator configuration, not something a request reaches.
927fn components(base: &str, file: &str) -> Vec<(String, String)> {
928    let base = base.trim_end_matches('/');
929    let relative = file
930        .strip_prefix(base)
931        .unwrap_or("")
932        .trim_start_matches('/');
933
934    let mut out = Vec::new();
935    let mut dir = base.to_string();
936    for name in relative.split('/').filter(|s| !s.is_empty()) {
937        out.push((dir.clone(), name.to_string()));
938        dir = format!("{dir}/{name}");
939    }
940    out
941}
942
943/// An annotation is a note, not a file upload.
944///
945/// `Limited` errors once the cap is passed rather than truncating, so a body that was too
946/// large cannot be quietly parsed as a shorter one.
947const MAX_CONTROL_BODY: usize = 256 * 1024;
948
949async fn read_body<B>(body: B) -> Result<Bytes, String>
950where
951    B: hyper::body::Body,
952    B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
953{
954    use http_body_util::{BodyExt, Limited};
955    Limited::new(body, MAX_CONTROL_BODY)
956        .collect()
957        .await
958        .map(|collected| collected.to_bytes())
959        .map_err(|e| format!("reading the request body: {e}"))
960}
961
962fn header<B>(req: &Request<B>, name: HeaderName) -> Option<String> {
963    req.headers()
964        .get(name)
965        .and_then(|v| v.to_str().ok())
966        .map(str::to_string)
967}
968
969/// Serve a body already in hand, whole or sliced.
970fn respond(
971    file: &str,
972    body: Bytes,
973    tag: Option<&str>,
974    wanted: &range::Resolved,
975    size: u64,
976) -> Response<Full<Bytes>> {
977    match wanted {
978        range::Resolved::Part { start, end } => {
979            // Clamped against the body actually held rather than the advertised size,
980            // so a listing that disagrees with the file cannot panic the slice.
981            let lo = usize::try_from(*start)
982                .unwrap_or(usize::MAX)
983                .min(body.len());
984            let hi = usize::try_from(end.saturating_add(1))
985                .unwrap_or(usize::MAX)
986                .min(body.len())
987                .max(lo);
988            partial(
989                mime::guess(file),
990                body.slice(lo..hi),
991                tag,
992                *start,
993                *end,
994                size,
995            )
996        }
997        _ => served(mime::guess(file), body, tag),
998    }
999}
1000
1001fn partial(
1002    content_type: &str,
1003    body: Bytes,
1004    tag: Option<&str>,
1005    start: u64,
1006    end: u64,
1007    size: u64,
1008) -> Response<Full<Bytes>> {
1009    let mut b = Response::builder()
1010        .status(StatusCode::PARTIAL_CONTENT)
1011        .header(CONTENT_TYPE, content_type)
1012        .header(CACHE_CONTROL, "no-cache")
1013        .header(ACCEPT_RANGES, "bytes")
1014        .header(CONTENT_RANGE, format!("bytes {start}-{end}/{size}"));
1015    if let Some(tag) = tag {
1016        b = b.header(ETAG, tag);
1017    }
1018    b.body(Full::new(body))
1019        .unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR, "malformed 206"))
1020}
1021
1022/// A 416 has to carry the real size, or a client cannot work out what it should have
1023/// asked for instead.
1024fn unsatisfiable(size: u64) -> Response<Full<Bytes>> {
1025    Response::builder()
1026        .status(StatusCode::RANGE_NOT_SATISFIABLE)
1027        .header(CONTENT_TYPE, "text/plain; charset=utf-8")
1028        .header(CONTENT_RANGE, format!("bytes */{size}"))
1029        .body(Full::new(Bytes::from_static(b"range not satisfiable")))
1030        .unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR, "malformed 416"))
1031}
1032
1033fn host_of<B>(req: &Request<B>) -> Option<String> {
1034    // A proxied request has an absolute-form target; a direct one only has the
1035    // header. Prefer the header, since that is what the browser actually sent.
1036    req.headers()
1037        .get(HOST)
1038        .and_then(|v| v.to_str().ok())
1039        .map(str::to_string)
1040        .or_else(|| req.uri().host().map(str::to_string))
1041}
1042
1043fn served(content_type: &str, body: Bytes, tag: Option<&str>) -> Response<Full<Bytes>> {
1044    let mut b = Response::builder()
1045        .status(StatusCode::OK)
1046        .header(CONTENT_TYPE, content_type)
1047        // `no-cache` means revalidate, not "do not store". With an ETag attached
1048        // that revalidation is a 304 answered from the listing cache, so the
1049        // browser keeps its copy and the remote is never touched.
1050        .header(CACHE_CONTROL, "no-cache")
1051        // Advertised on every full response: a client that does not know ranges are
1052        // available will never try to seek.
1053        .header(ACCEPT_RANGES, "bytes");
1054    if let Some(tag) = tag {
1055        b = b.header(ETAG, tag);
1056    }
1057    b.body(Full::new(body))
1058        .unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR, "malformed response"))
1059}
1060
1061/// For responses with no validator to offer: the PAC, the alias index, a listing.
1062fn plain_ok(content_type: &str, body: Bytes) -> Response<Full<Bytes>> {
1063    served(content_type, body, None)
1064}
1065
1066/// No `Last-Modified` anywhere, deliberately.
1067///
1068/// Emitting it would oblige us to honour `If-Modified-Since`, whose comparison is
1069/// second-resolution -- the same resolution SFTP reports mtime at, which is exactly
1070/// where it stops being able to tell two versions apart. The ETag carries the same
1071/// information without that ambiguity, so it is the only validator offered.
1072fn not_modified(tag: &str) -> Response<Full<Bytes>> {
1073    Response::builder()
1074        .status(StatusCode::NOT_MODIFIED)
1075        .header(ETAG, tag)
1076        .header(CACHE_CONTROL, "no-cache")
1077        .body(Full::new(Bytes::new()))
1078        .unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR, "malformed 304"))
1079}
1080
1081fn fail(status: StatusCode, detail: impl Into<String>) -> Response<Full<Bytes>> {
1082    Response::builder()
1083        .status(status)
1084        .header(CONTENT_TYPE, "text/plain; charset=utf-8")
1085        .body(Full::new(Bytes::from(detail.into())))
1086        .expect("a plain-text body with static headers always builds")
1087}
1088
1089fn redirect(to: &str) -> Response<Full<Bytes>> {
1090    Response::builder()
1091        .status(StatusCode::MOVED_PERMANENTLY)
1092        .header(LOCATION, to)
1093        .body(Full::new(Bytes::new()))
1094        .unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR, "bad redirect target"))
1095}
1096
1097/// Listing for a directory that has no index.html.
1098fn autoindex(path: &str, entries: &[Entry]) -> String {
1099    let mut visible: Vec<&Entry> = entries
1100        .iter()
1101        .filter(|e| e.name != "." && e.name != "..")
1102        .collect();
1103    visible.sort_by(|a, b| (!a.attrs.is_dir(), &a.name).cmp(&(!b.attrs.is_dir(), &b.name)));
1104
1105    let mut s = String::from("<!doctype html><html><head><meta charset=\"utf-8\"><title>");
1106    s.push_str(&escape(path));
1107    s.push_str("</title></head><body><h1>");
1108    s.push_str(&escape(path));
1109    s.push_str("</h1><ul><li><a href=\"../\">../</a></li>");
1110    for e in visible {
1111        let slash = if e.attrs.is_dir() { "/" } else { "" };
1112        s.push_str("<li><a href=\"");
1113        s.push_str(&url_escape(&e.name));
1114        s.push_str(slash);
1115        s.push_str("\">");
1116        s.push_str(&escape(&e.name));
1117        s.push_str(slash);
1118        s.push_str("</a></li>");
1119    }
1120    s.push_str("</ul></body></html>");
1121    s
1122}
1123
1124/// Remote filenames are untrusted input that lands inside our own origin, so the
1125/// listing escapes them. Skipping this would be self-inflicted XSS.
1126fn escape(s: &str) -> String {
1127    s.replace('&', "&amp;")
1128        .replace('<', "&lt;")
1129        .replace('>', "&gt;")
1130        .replace('"', "&quot;")
1131}
1132
1133/// HTML-escaping is not enough inside an href: a space or a hash in a filename
1134/// would still produce a broken or a wrong link.
1135fn url_escape(s: &str) -> String {
1136    let mut out = String::with_capacity(s.len());
1137    for b in s.bytes() {
1138        match b {
1139            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1140                out.push(b as char);
1141            }
1142            _ => out.push_str(&format!("%{b:02X}")),
1143        }
1144    }
1145    out
1146}
1147
1148#[cfg(test)]
1149mod tests {
1150    use super::*;
1151    use crate::sftp::wire::Attrs;
1152    use crate::testing::{FakeRemote, dir_attrs, file_attrs, symlink_attrs};
1153    use http_body_util::{BodyExt, Empty};
1154
1155    const TEST_TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
1156
1157    async fn body_of(res: Response<Full<Bytes>>) -> Bytes {
1158        res.into_body()
1159            .collect()
1160            .await
1161            .expect("a Full body always collects")
1162            .to_bytes()
1163    }
1164
1165    /// A request arriving by address rather than through the PAC.
1166    fn loopback(path: &str, token: Option<&str>) -> Request<Empty<Bytes>> {
1167        let mut b = Request::builder().uri(path).header(HOST, "127.0.0.1:7391");
1168        if let Some(t) = token {
1169            b = b.header(control::TOKEN_HEADER, t);
1170        }
1171        b.body(Empty::<Bytes>::new()).expect("request builds")
1172    }
1173
1174    fn control_post(path: &str, token: Option<&str>, body: &str) -> Request<Full<Bytes>> {
1175        let mut b = Request::builder()
1176            .method(Method::POST)
1177            .uri(path)
1178            .header(HOST, "127.0.0.1:7391");
1179        if let Some(t) = token {
1180            b = b.header(control::TOKEN_HEADER, t);
1181        }
1182        b.body(Full::new(Bytes::from(body.to_string())))
1183            .expect("request builds")
1184    }
1185
1186    async fn json_of(res: Response<Full<Bytes>>) -> serde_json::Value {
1187        let bytes = body_of(res).await;
1188        serde_json::from_slice(&bytes).expect("a control response is json")
1189    }
1190
1191    fn ranged(path: &str, range: &str) -> Request<Empty<Bytes>> {
1192        Request::builder()
1193            .uri(format!("http://docs.ssh-browser{path}"))
1194            .header(HOST, "docs.ssh-browser")
1195            .header(RANGE, range)
1196            .body(Empty::new())
1197            .expect("request builds")
1198    }
1199
1200    /// Build an origin over an in-memory remote. The session is a real `SftpFs`, so
1201    /// the round trips counted below are the same ones production would pay.
1202    async fn origin_with(remote: FakeRemote) -> Origin {
1203        let fs = remote.spawn().await;
1204        let mut sessions = HashMap::new();
1205        sessions.insert(
1206            "docs".to_string(),
1207            Session {
1208                base: "/srv".to_string(),
1209                fs,
1210            },
1211        );
1212        Origin {
1213            suffix: "ssh-browser".to_string(),
1214            port: 7391,
1215            sessions,
1216            cache: Cache::default(),
1217            token: Token::from_hex(TEST_TOKEN),
1218            author: "souta".to_string(),
1219        }
1220    }
1221
1222    fn get(path: &str, if_none_match: Option<&str>) -> Request<Empty<Bytes>> {
1223        let mut b = Request::builder()
1224            .uri(format!("http://docs.ssh-browser{path}"))
1225            .header(HOST, "docs.ssh-browser");
1226        if let Some(tag) = if_none_match {
1227            b = b.header(IF_NONE_MATCH, tag);
1228        }
1229        b.body(Empty::new()).expect("request builds")
1230    }
1231
1232    fn trips(origin: &Origin) -> u64 {
1233        origin.sessions.values().map(|s| s.fs.round_trips()).sum()
1234    }
1235
1236    fn one_page() -> FakeRemote {
1237        FakeRemote::new()
1238            .dir("/srv", vec![("a.html", file_attrs(5, 100))])
1239            .file("/srv/a.html", b"hello")
1240    }
1241
1242    /// A page with subresources in a sibling directory, which is the shape a generated
1243    /// report has: one HTML file and an `assets/` beside it.
1244    fn page_with_subresources(n: usize) -> FakeRemote {
1245        let mut html = String::from(
1246            "<!doctype html><html><head><link rel=\"stylesheet\" href=\"assets/style.css\"><script src=\"assets/app.js\"></script></head><body>",
1247        );
1248        for i in 0..n {
1249            html.push_str(&format!("<img src=\"assets/{i}.png\">"));
1250        }
1251        html.push_str("</body></html>");
1252
1253        let mut assets = vec!["style.css".to_string(), "app.js".to_string()];
1254        assets.extend((0..n).map(|i| format!("{i}.png")));
1255
1256        let mut remote = FakeRemote::new()
1257            .dir(
1258                "/srv",
1259                vec![
1260                    ("index.html", file_attrs(html.len() as u64, 100)),
1261                    ("assets", dir_attrs()),
1262                ],
1263            )
1264            .dir(
1265                "/srv/assets",
1266                assets
1267                    .iter()
1268                    .map(|name| (name.as_str(), file_attrs(3, 1)))
1269                    .collect(),
1270            )
1271            .file("/srv/index.html", html.as_bytes());
1272        for name in &assets {
1273            remote = remote.file(&format!("/srv/assets/{name}"), b"xxx");
1274        }
1275        remote
1276    }
1277
1278    /// The subresource half of invariant 1, which is about the browser rather than the
1279    /// remote. HTTP/1.1 allows six connections per origin, so forty subresources are seven
1280    /// waves of requests and each wave the browser has to discover is a round trip.
1281    ///
1282    /// Asking for them one at a time is the worst case any browser can produce. If that
1283    /// costs nothing, no arrangement of waves can cost anything either.
1284    #[tokio::test]
1285    async fn a_pages_subresources_are_already_held_when_the_browser_asks_for_them() {
1286        const N: usize = 40;
1287        let origin = origin_with(page_with_subresources(N)).await;
1288
1289        let res = origin.handle(get("/index.html", None)).await;
1290        assert_eq!(res.status(), StatusCode::OK);
1291
1292        let before = trips(&origin);
1293        for i in 0..N {
1294            let path = format!("/assets/{i}.png");
1295            let res = origin.handle(get(&path, None)).await;
1296            assert_eq!(res.status(), StatusCode::OK, "{path}");
1297            assert_eq!(&body_of(res).await[..], b"xxx", "{path}");
1298        }
1299        for name in ["style.css", "app.js"] {
1300            let res = origin.handle(get(&format!("/assets/{name}"), None)).await;
1301            assert_eq!(res.status(), StatusCode::OK, "{name}");
1302        }
1303
1304        assert_eq!(
1305            trips(&origin) - before,
1306            0,
1307            "reading the page's own references is what makes these free"
1308        );
1309    }
1310
1311    /// And the page itself does not get more expensive as it gains subresources: the
1312    /// listings are one batch and the reads are another, whatever the count.
1313    #[tokio::test]
1314    async fn serving_a_page_costs_the_same_however_many_subresources_it_has() {
1315        async fn cost(n: usize) -> u64 {
1316            let origin = origin_with(page_with_subresources(n)).await;
1317            let before = trips(&origin);
1318            let res = origin.handle(get("/index.html", None)).await;
1319            assert_eq!(res.status(), StatusCode::OK);
1320            trips(&origin) - before
1321        }
1322        assert_eq!(cost(4).await, cost(40).await);
1323    }
1324
1325    /// One HTML page naming whatever it likes, for the two tests below. The page is
1326    /// untrusted input, and prefetching is the first thing in this daemon that acts on what
1327    /// a page says rather than on what the reader asked for.
1328    fn page_referring_to(refs: &[&str], extra: Vec<(&'static str, Attrs)>) -> FakeRemote {
1329        let mut html = String::from("<!doctype html><html><body>");
1330        for r in refs {
1331            html.push_str(&format!("<img src=\"{r}\">"));
1332        }
1333        html.push_str("</body></html>");
1334
1335        let mut entries = vec![("index.html", file_attrs(html.len() as u64, 100))];
1336        entries.extend(extra);
1337        FakeRemote::new()
1338            .dir("/srv", entries)
1339            .file("/srv/index.html", html.as_bytes())
1340    }
1341
1342    async fn cost_of_serving(refs: &[&str], extra: Vec<(&'static str, Attrs)>) -> u64 {
1343        let origin = origin_with(page_referring_to(refs, extra)).await;
1344        let before = trips(&origin);
1345        let res = origin.handle(get("/index.html", None)).await;
1346        assert_eq!(res.status(), StatusCode::OK);
1347        trips(&origin) - before
1348    }
1349
1350    /// A reference that climbs out of the alias base must not be read. The check is the
1351    /// same `resolve` the request path uses, not a second copy that could drift from it.
1352    #[tokio::test]
1353    async fn a_page_cannot_prefetch_its_way_out_of_the_alias_base() {
1354        let baseline = cost_of_serving(&[], vec![]).await;
1355        assert_eq!(
1356            cost_of_serving(&["../../../etc/passwd", "/../../etc/shadow"], vec![]).await,
1357            baseline,
1358            "an escaping reference is gone before anything is listed or read"
1359        );
1360    }
1361
1362    /// Nor through a symlink — and not even as far as listing it. A page that could get the
1363    /// directory a symlink points at listed would have defeated the rule by naming it.
1364    #[tokio::test]
1365    async fn a_page_cannot_prefetch_through_a_symlink() {
1366        let link = || vec![("link", symlink_attrs())];
1367        let baseline = cost_of_serving(&[], link()).await;
1368        assert_eq!(
1369            cost_of_serving(&["link/inside.png"], link()).await,
1370            baseline,
1371            "the symlink is known from the listing the page itself needed"
1372        );
1373
1374        // And the ordinary request for it is still refused, which is the guarantee the
1375        // prefetcher is being held to rather than a separate one.
1376        let origin = origin_with(page_referring_to(&["link/inside.png"], link())).await;
1377        assert_eq!(
1378            origin.handle(get("/index.html", None)).await.status(),
1379            StatusCode::OK
1380        );
1381        assert_eq!(
1382            origin.handle(get("/link/inside.png", None)).await.status(),
1383            StatusCode::FORBIDDEN
1384        );
1385    }
1386
1387    /// The hole the shallow symlink test did not cover: a symlink one level below the
1388    /// deepest listing the cache holds.
1389    ///
1390    /// `first_symlink` can only see what is cached, so at the moment the batch is assembled
1391    /// it has no opinion about `assets/link` — and the batch that would tell it includes the
1392    /// symlink's own path. SFTP v3 `OPENDIR` has no `O_NOFOLLOW`, so the remote resolves it
1393    /// and hands back a listing of wherever it points. Nothing is ever served through it,
1394    /// but the daemon has already read it, which is the act the alias base exists to forbid.
1395    ///
1396    /// Round trips cannot detect this — `list_dirs` is one flush however many directories
1397    /// are in it — so the assertion is on what the cache ends up holding.
1398    #[tokio::test]
1399    async fn a_page_cannot_get_a_symlink_below_an_unlisted_directory_opened() {
1400        let html = "<!doctype html><html><body><img src=\"assets/link/secret.txt\"></body></html>";
1401        let origin = origin_with(
1402            FakeRemote::new()
1403                .dir(
1404                    "/srv",
1405                    vec![
1406                        ("index.html", file_attrs(html.len() as u64, 100)),
1407                        ("assets", dir_attrs()),
1408                    ],
1409                )
1410                .dir("/srv/assets", vec![("link", symlink_attrs())])
1411                // What the remote returns once it has followed the symlink for us.
1412                .dir("/srv/assets/link", vec![("secret.txt", file_attrs(9, 1))])
1413                .file("/srv/index.html", html.as_bytes())
1414                .file("/srv/assets/link/secret.txt", b"elsewhere"),
1415        )
1416        .await;
1417
1418        assert_eq!(
1419            origin.handle(get("/index.html", None)).await.status(),
1420            StatusCode::OK
1421        );
1422        assert!(
1423            !origin.cache.has_listing("/srv/assets/link"),
1424            "the daemon listed the directory a symlink points at"
1425        );
1426
1427        // And the ordinary request for it is still refused, so closing the prefetch route
1428        // did not quietly become the only thing stopping it.
1429        assert_eq!(
1430            origin
1431                .handle(get("/assets/link/secret.txt", None))
1432                .await
1433                .status(),
1434            StatusCode::FORBIDDEN
1435        );
1436    }
1437
1438    /// The other half: a reference one level down is still prefetched, because the listing
1439    /// the page's own request already fetched proves that step is a real directory. Closing
1440    /// the hole above must not turn prefetching off for the ordinary `assets/` layout.
1441    #[tokio::test]
1442    async fn a_reference_in_a_real_subdirectory_is_still_prefetched() {
1443        let html = "<!doctype html><html><body><img src=\"assets/x.png\"></body></html>";
1444        let origin = origin_with(
1445            FakeRemote::new()
1446                .dir(
1447                    "/srv",
1448                    vec![
1449                        ("index.html", file_attrs(html.len() as u64, 100)),
1450                        ("assets", dir_attrs()),
1451                    ],
1452                )
1453                .dir("/srv/assets", vec![("x.png", file_attrs(3, 1))])
1454                .file("/srv/index.html", html.as_bytes())
1455                .file("/srv/assets/x.png", b"xxx"),
1456        )
1457        .await;
1458
1459        assert_eq!(
1460            origin.handle(get("/index.html", None)).await.status(),
1461            StatusCode::OK
1462        );
1463        let before = trips(&origin);
1464        let res = origin.handle(get("/assets/x.png", None)).await;
1465        assert_eq!(res.status(), StatusCode::OK);
1466        assert_eq!(&body_of(res).await[..], b"xxx");
1467        assert_eq!(
1468            trips(&origin) - before,
1469            0,
1470            "a subdirectory one level down must still be warmed"
1471        );
1472    }
1473
1474    /// A subresource larger than one read chunk costs the same as a small one.
1475    ///
1476    /// This is what `read_ranges` buys over `read_batch` here: a read whose length is known
1477    /// can have all its chunks issued together, and a read whose length is not has to poll.
1478    /// Before, a bundle of any real size cost one round trip per 32 KiB — invisible to every
1479    /// other test, because they all use three-byte fixtures.
1480    #[tokio::test]
1481    async fn a_large_subresource_costs_what_a_small_one_costs() {
1482        async fn cost(bytes: usize) -> u64 {
1483            let html = "<!doctype html><html><body><img src=\"assets/big.bin\"></body></html>";
1484            let origin = origin_with(
1485                FakeRemote::new()
1486                    .dir(
1487                        "/srv",
1488                        vec![
1489                            ("index.html", file_attrs(html.len() as u64, 100)),
1490                            ("assets", dir_attrs()),
1491                        ],
1492                    )
1493                    .dir(
1494                        "/srv/assets",
1495                        vec![("big.bin", file_attrs(bytes as u64, 1))],
1496                    )
1497                    .file("/srv/index.html", html.as_bytes())
1498                    .file("/srv/assets/big.bin", &vec![b'x'; bytes]),
1499            )
1500            .await;
1501
1502            let before = trips(&origin);
1503            assert_eq!(
1504                origin.handle(get("/index.html", None)).await.status(),
1505                StatusCode::OK
1506            );
1507            let spent = trips(&origin) - before;
1508
1509            // And it really was warmed, so the comparison is between two prefetches rather
1510            // than between a prefetch and a skip.
1511            let at = trips(&origin);
1512            let res = origin.handle(get("/assets/big.bin", None)).await;
1513            assert_eq!(res.status(), StatusCode::OK);
1514            assert_eq!(body_of(res).await.len(), bytes);
1515            assert_eq!(
1516                trips(&origin) - at,
1517                0,
1518                "{bytes} bytes should have been held"
1519            );
1520
1521            spent
1522        }
1523
1524        // Either side of the 32 KiB chunk, and well past it.
1525        assert_eq!(cost(1024).await, cost(200 * 1024).await);
1526    }
1527
1528    /// A listing that understates a file's length must not turn into an empty `200`.
1529    ///
1530    /// The prefetch reads a range, and a range is exactly as long as it was told to be. A
1531    /// listing reporting zero bytes for a file that has some would therefore cache an empty
1532    /// body — and the reader would be served it, because the cache is consulted first. This
1533    /// is the failure mode `CONTRIBUTING.md` names, arriving through a new door.
1534    ///
1535    /// Caught by the fake reporting a size of zero where a size was not set, which is what a
1536    /// real listing does when it is wrong rather than silent.
1537    #[tokio::test]
1538    async fn a_subresource_the_listing_calls_empty_is_not_prefetched() {
1539        let html = "<!doctype html><html><body><img src=\"assets/x.png\"></body></html>";
1540        let sizeless = Attrs {
1541            permissions: Some(0o100644),
1542            mtime: Some(1),
1543            ..Attrs::default()
1544        };
1545        let origin = origin_with(
1546            FakeRemote::new()
1547                .dir(
1548                    "/srv",
1549                    vec![
1550                        ("index.html", file_attrs(html.len() as u64, 100)),
1551                        ("assets", dir_attrs()),
1552                    ],
1553                )
1554                .dir("/srv/assets", vec![("x.png", sizeless)])
1555                .file("/srv/index.html", html.as_bytes())
1556                .file("/srv/assets/x.png", b"xxx"),
1557        )
1558        .await;
1559
1560        assert_eq!(
1561            origin.handle(get("/index.html", None)).await.status(),
1562            StatusCode::OK
1563        );
1564        let res = origin.handle(get("/assets/x.png", None)).await;
1565        assert_eq!(res.status(), StatusCode::OK);
1566        assert_eq!(
1567            &body_of(res).await[..],
1568            b"xxx",
1569            "the real request must still serve the whole file"
1570        );
1571    }
1572
1573    /// A subresource over the hold-whole limit is skipped rather than read and discarded.
1574    #[tokio::test]
1575    async fn an_oversized_subresource_is_not_prefetched() {
1576        async fn cost(size: u64) -> u64 {
1577            let html =
1578                "<!doctype html><html><body><video src=\"assets/film.mp4\"></video></body></html>";
1579            let origin = origin_with(
1580                FakeRemote::new()
1581                    .dir(
1582                        "/srv",
1583                        vec![
1584                            ("index.html", file_attrs(html.len() as u64, 100)),
1585                            ("assets", dir_attrs()),
1586                        ],
1587                    )
1588                    .dir("/srv/assets", vec![("film.mp4", file_attrs(size, 1))])
1589                    .file("/srv/index.html", html.as_bytes())
1590                    .file("/srv/assets/film.mp4", b"xxx"),
1591            )
1592            .await;
1593            let before = trips(&origin);
1594            assert_eq!(
1595                origin.handle(get("/index.html", None)).await.status(),
1596                StatusCode::OK
1597            );
1598            trips(&origin) - before
1599        }
1600
1601        // The listing is fetched either way; only the read differs. A film the cache would
1602        // decline must not be pulled across the network first to find that out.
1603        let read_it = cost(3).await;
1604        let skipped = cost(CACHE_WHOLE_MAX + 1).await;
1605        assert!(
1606            skipped < read_it,
1607            "an oversized subresource cost {skipped} against {read_it} for a small one"
1608        );
1609    }
1610
1611    /// The port is taken before any host is connected.
1612    ///
1613    /// This ordering is the whole of what a previous change set out to fix, and nothing
1614    /// tested it: every other test here builds an `Origin` directly and never goes through
1615    /// `bind` at all. A regression that put the ssh handshakes first would pass the entire
1616    /// suite, and would cost a full set of connections before reporting the one failure an
1617    /// operator can actually act on.
1618    ///
1619    /// Cheap to check without any ssh infrastructure, precisely because the port failing
1620    /// first means the host is never reached: the error naming the bind and *not* naming the
1621    /// host is the evidence.
1622    #[tokio::test]
1623    async fn the_port_is_taken_before_any_host_is_connected() {
1624        let held = TcpListener::bind(("127.0.0.1", 0))
1625            .await
1626            .expect("a free port");
1627        let port = held.local_addr().expect("its address").port();
1628
1629        const NOWHERE: &str = "a-host-that-cannot-resolve.invalid";
1630        let result = Origin::bind(
1631            vec![Alias::new("docs", NOWHERE, "/srv").expect("a valid alias")],
1632            "ssh-browser".to_string(),
1633            port,
1634            Token::from_hex(TEST_TOKEN),
1635            "souta".to_string(),
1636        )
1637        .await;
1638
1639        let Err(e) = result else {
1640            panic!("binding a port that is already held must fail");
1641        };
1642        let text = format!("{e:#}");
1643        assert!(
1644            text.contains(&format!("bind 127.0.0.1:{port}")),
1645            "the error should name the port, got: {text}"
1646        );
1647        assert!(
1648            !text.contains(NOWHERE),
1649            "the ssh host was reached before the port was taken: {text}"
1650        );
1651    }
1652
1653    /// The same single file, four directories down.
1654    fn deep_tree() -> FakeRemote {
1655        FakeRemote::new()
1656            .dir("/srv", vec![("a", dir_attrs())])
1657            .dir("/srv/a", vec![("b", dir_attrs())])
1658            .dir("/srv/a/b", vec![("c", dir_attrs())])
1659            .dir("/srv/a/b/c", vec![("d.html", file_attrs(5, 100))])
1660            .file("/srv/a/b/c/d.html", b"deep!")
1661    }
1662
1663    fn entry(name: &str, dir: bool) -> Entry {
1664        Entry {
1665            name: name.to_string(),
1666            attrs: Attrs {
1667                permissions: Some(if dir { 0o040755 } else { 0o100644 }),
1668                ..Attrs::default()
1669            },
1670            owner: None,
1671        }
1672    }
1673
1674    #[test]
1675    fn a_hostile_filename_cannot_inject_script_into_our_origin() {
1676        let page = autoindex("/", &[entry("<script>alert(1)</script>", false)]);
1677        assert!(!page.contains("<script>alert"));
1678        assert!(page.contains("&lt;script&gt;"));
1679    }
1680
1681    #[test]
1682    fn listings_put_directories_first_then_sort_by_name() {
1683        let page = autoindex(
1684            "/",
1685            &[
1686                entry("b.txt", false),
1687                entry("z-dir", true),
1688                entry("a.txt", false),
1689            ],
1690        );
1691        let dir = page.find("z-dir").expect("dir listed");
1692        let a = page.find("a.txt").expect("a listed");
1693        let b = page.find("b.txt").expect("b listed");
1694        assert!(dir < a, "directories come first");
1695        assert!(a < b, "files sort by name");
1696    }
1697
1698    #[test]
1699    fn hrefs_are_url_escaped() {
1700        let page = autoindex("/", &[entry("a b#c.html", false)]);
1701        assert!(page.contains("href=\"a%20b%23c.html\""));
1702    }
1703
1704    #[test]
1705    fn the_component_chain_walks_from_the_base_down() {
1706        assert_eq!(
1707            components("/srv", "/srv/a/b/c.html"),
1708            vec![
1709                ("/srv".to_string(), "a".to_string()),
1710                ("/srv/a".to_string(), "b".to_string()),
1711                ("/srv/a/b".to_string(), "c.html".to_string()),
1712            ]
1713        );
1714        assert_eq!(
1715            components("/srv", "/srv/index.html"),
1716            vec![("/srv".to_string(), "index.html".to_string())]
1717        );
1718        // A trailing slash on the base must not produce an empty first component.
1719        assert_eq!(
1720            components("/srv/", "/srv/a.html"),
1721            vec![("/srv".to_string(), "a.html".to_string())]
1722        );
1723        // The file *is* the base: nothing between them to check.
1724        assert!(components("/srv", "/srv").is_empty());
1725    }
1726
1727    /// Invariant 2. The listing and the body are both held, so the second request
1728    /// has nothing left to ask the remote.
1729    #[tokio::test]
1730    async fn a_revisit_costs_no_remote_round_trips() {
1731        let origin = origin_with(one_page()).await;
1732
1733        let first = origin.handle(get("/a.html", None)).await;
1734        assert_eq!(first.status(), StatusCode::OK);
1735        let after_first = trips(&origin);
1736        assert!(after_first > 0, "the first request has to fetch something");
1737
1738        let second = origin.handle(get("/a.html", None)).await;
1739        assert_eq!(second.status(), StatusCode::OK);
1740        assert_eq!(
1741            trips(&origin),
1742            after_first,
1743            "a revisit must be answered entirely from cache"
1744        );
1745    }
1746
1747    /// Invariant 2 through the browser's own validator: the ETag came from the
1748    /// cached listing, so the 304 is decided inside this process.
1749    #[tokio::test]
1750    async fn a_conditional_get_is_answered_without_the_remote() {
1751        let origin = origin_with(one_page()).await;
1752
1753        let first = origin.handle(get("/a.html", None)).await;
1754        let tag = first
1755            .headers()
1756            .get(ETAG)
1757            .expect("a validator is offered")
1758            .to_str()
1759            .expect("ascii")
1760            .to_string();
1761        let after_first = trips(&origin);
1762
1763        let second = origin.handle(get("/a.html", Some(&tag))).await;
1764        assert_eq!(second.status(), StatusCode::NOT_MODIFIED);
1765        assert_eq!(
1766            trips(&origin),
1767            after_first,
1768            "a 304 must not touch the remote"
1769        );
1770    }
1771
1772    /// A name the listing does not contain needs no fetch to answer.
1773    #[tokio::test]
1774    async fn a_missing_file_is_a_404_from_the_cached_listing() {
1775        let origin = origin_with(one_page()).await;
1776
1777        // Warm the listing.
1778        origin.handle(get("/a.html", None)).await;
1779        let warm = trips(&origin);
1780
1781        let missing = origin.handle(get("/nope.html", None)).await;
1782        assert_eq!(missing.status(), StatusCode::NOT_FOUND);
1783        assert_eq!(
1784            trips(&origin),
1785            warm,
1786            "a 404 for a listed-but-absent name must cost nothing"
1787        );
1788    }
1789
1790    /// The guard SECURITY.md promises, decided from the listing rather than from a
1791    /// REALPATH per request.
1792    #[tokio::test]
1793    async fn a_symlink_is_refused() {
1794        let origin = origin_with(
1795            FakeRemote::new()
1796                .dir("/srv", vec![("link.html", symlink_attrs())])
1797                .file("/srv/link.html", b"whatever the target is"),
1798        )
1799        .await;
1800
1801        let res = origin.handle(get("/link.html", None)).await;
1802        assert_eq!(res.status(), StatusCode::FORBIDDEN);
1803    }
1804
1805    /// The listing knows it is a directory, so this costs no failed open first.
1806    #[tokio::test]
1807    async fn a_directory_without_a_trailing_slash_redirects() {
1808        let origin = origin_with(
1809            FakeRemote::new()
1810                .dir("/srv", vec![("sub", dir_attrs())])
1811                .dir("/srv/sub", vec![("b.html", file_attrs(1, 1))]),
1812        )
1813        .await;
1814
1815        let res = origin.handle(get("/sub", None)).await;
1816        assert_eq!(res.status(), StatusCode::MOVED_PERMANENTLY);
1817        assert_eq!(
1818            res.headers().get(LOCATION).and_then(|v| v.to_str().ok()),
1819            Some("/sub/")
1820        );
1821    }
1822
1823    /// A listing that promises a file the remote then refuses must not be kept, or
1824    /// the same wrong answer is served for a whole TTL.
1825    #[tokio::test]
1826    async fn a_listing_proven_wrong_is_forgotten() {
1827        // Listed, but no body declared: the open fails.
1828        let origin =
1829            origin_with(FakeRemote::new().dir("/srv", vec![("ghost.html", file_attrs(5, 100))]))
1830                .await;
1831
1832        let res = origin.handle(get("/ghost.html", None)).await;
1833        assert_eq!(res.status(), StatusCode::NOT_FOUND);
1834        assert!(
1835            !origin.cache.has_listing("/srv"),
1836            "a listing contradicted by the remote must be dropped"
1837        );
1838    }
1839
1840    /// A directory with no index.html is listed rather than 404'd.
1841    #[tokio::test]
1842    async fn a_directory_without_an_index_is_listed() {
1843        let origin =
1844            origin_with(FakeRemote::new().dir("/srv", vec![("only.txt", file_attrs(2, 1))])).await;
1845
1846        let res = origin.handle(get("/", None)).await;
1847        assert_eq!(res.status(), StatusCode::OK);
1848        assert_eq!(
1849            res.headers()
1850                .get(CONTENT_TYPE)
1851                .and_then(|v| v.to_str().ok()),
1852            Some("text/html; charset=utf-8")
1853        );
1854    }
1855
1856    /// The hole SECURITY.md used to describe. `/link/inside.html` names a file that
1857    /// exists and is not itself a symlink, but every route to it passes through one.
1858    #[tokio::test]
1859    async fn a_symlinked_directory_higher_up_the_path_is_refused() {
1860        let origin = origin_with(
1861            FakeRemote::new()
1862                .dir("/srv", vec![("link", symlink_attrs())])
1863                .dir("/srv/link", vec![("inside.html", file_attrs(2, 1))])
1864                .file("/srv/link/inside.html", b"hi"),
1865        )
1866        .await;
1867
1868        let res = origin.handle(get("/link/inside.html", None)).await;
1869        assert_eq!(res.status(), StatusCode::FORBIDDEN);
1870    }
1871
1872    /// Depth must not buy itself round trips. Every ancestor listing is issued
1873    /// together, so a path four deep costs what a path one deep costs.
1874    #[tokio::test]
1875    async fn a_deep_path_costs_what_a_shallow_one_costs() {
1876        let deep = origin_with(deep_tree()).await;
1877        assert_eq!(
1878            deep.handle(get("/a/b/c/d.html", None)).await.status(),
1879            StatusCode::OK
1880        );
1881
1882        let shallow = origin_with(one_page()).await;
1883        assert_eq!(
1884            shallow.handle(get("/a.html", None)).await.status(),
1885            StatusCode::OK
1886        );
1887
1888        let (d, sh) = (trips(&deep), trips(&shallow));
1889        // The slack absorbs one flush of fire-and-forget CLOSE requests landing on
1890        // either side of the measurement. A walk that listed one ancestor at a time
1891        // would cost about three times as many at this depth, and worse deeper.
1892        assert!(
1893            d <= sh + 2,
1894            "depth 4 cost {d} round trips against depth 1's {sh}"
1895        );
1896    }
1897
1898    /// A component that exists but is not a directory.
1899    #[tokio::test]
1900    async fn a_file_used_as_a_directory_is_a_404() {
1901        let origin = origin_with(one_page()).await;
1902        let res = origin.handle(get("/a.html/b.html", None)).await;
1903        assert_eq!(res.status(), StatusCode::NOT_FOUND);
1904    }
1905
1906    /// A deep path is served, not merely checked: the walk must not lose the file it
1907    /// was walking towards.
1908    #[tokio::test]
1909    async fn a_deep_path_serves_its_body() {
1910        let origin = origin_with(deep_tree()).await;
1911        let res = origin.handle(get("/a/b/c/d.html", None)).await;
1912        assert_eq!(res.status(), StatusCode::OK);
1913        assert_eq!(
1914            res.headers()
1915                .get(CONTENT_TYPE)
1916                .and_then(|v| v.to_str().ok()),
1917            Some("text/html; charset=utf-8")
1918        );
1919    }
1920
1921    /// A range out of a body already held costs nothing: the slice happens here.
1922    #[tokio::test]
1923    async fn a_range_is_sliced_out_of_the_cached_body() {
1924        let origin = origin_with(one_page()).await;
1925        assert_eq!(
1926            origin.handle(get("/a.html", None)).await.status(),
1927            StatusCode::OK
1928        );
1929        let warm = trips(&origin);
1930
1931        let res = origin.handle(ranged("/a.html", "bytes=1-3")).await;
1932        assert_eq!(res.status(), StatusCode::PARTIAL_CONTENT);
1933        assert_eq!(
1934            res.headers()
1935                .get(CONTENT_RANGE)
1936                .and_then(|v| v.to_str().ok()),
1937            Some("bytes 1-3/5")
1938        );
1939        assert_eq!(&body_of(res).await[..], b"ell");
1940        assert_eq!(
1941            trips(&origin),
1942            warm,
1943            "slicing a held body must cost no round trip"
1944        );
1945    }
1946
1947    /// A range on a file not yet held still works, and the file ends up held.
1948    #[tokio::test]
1949    async fn a_range_on_a_cold_small_file_works_and_warms_the_cache() {
1950        let origin = origin_with(one_page()).await;
1951
1952        let res = origin.handle(ranged("/a.html", "bytes=0-1")).await;
1953        assert_eq!(res.status(), StatusCode::PARTIAL_CONTENT);
1954        assert_eq!(&body_of(res).await[..], b"he");
1955
1956        let warm = trips(&origin);
1957        let again = origin.handle(ranged("/a.html", "bytes=2-4")).await;
1958        assert_eq!(&body_of(again).await[..], b"llo");
1959        assert_eq!(
1960            trips(&origin),
1961            warm,
1962            "a small file fetched for a range should be held whole"
1963        );
1964    }
1965
1966    /// The 416 has to name the real size, or a client cannot correct itself.
1967    #[tokio::test]
1968    async fn a_range_past_the_end_is_a_416_carrying_the_real_size() {
1969        let origin = origin_with(one_page()).await;
1970        let res = origin.handle(ranged("/a.html", "bytes=99-")).await;
1971        assert_eq!(res.status(), StatusCode::RANGE_NOT_SATISFIABLE);
1972        assert_eq!(
1973            res.headers()
1974                .get(CONTENT_RANGE)
1975                .and_then(|v| v.to_str().ok()),
1976            Some("bytes */5")
1977        );
1978    }
1979
1980    /// A client that is not told ranges exist will never seek.
1981    #[tokio::test]
1982    async fn a_full_response_advertises_ranges() {
1983        let origin = origin_with(one_page()).await;
1984        let res = origin.handle(get("/a.html", None)).await;
1985        assert_eq!(
1986            res.headers()
1987                .get(ACCEPT_RANGES)
1988                .and_then(|v| v.to_str().ok()),
1989            Some("bytes")
1990        );
1991    }
1992
1993    /// The validator on offer is weak, so `If-Range` cannot be honoured. The whole
1994    /// representation is the specified answer, not a 412 and not a 206.
1995    #[tokio::test]
1996    async fn if_range_yields_the_whole_file() {
1997        let origin = origin_with(one_page()).await;
1998        let req = Request::builder()
1999            .uri("http://docs.ssh-browser/a.html")
2000            .header(HOST, "docs.ssh-browser")
2001            .header(RANGE, "bytes=1-3")
2002            .header(IF_RANGE, "W/\"64-5\"")
2003            .body(Empty::<Bytes>::new())
2004            .expect("request builds");
2005
2006        let res = origin.handle(req).await;
2007        assert_eq!(res.status(), StatusCode::OK);
2008        assert_eq!(&body_of(res).await[..], b"hello");
2009    }
2010
2011    /// The branch that makes a video seekable: a file too big to hold is fetched by
2012    /// range and not cached, so a seek does not pull the whole thing.
2013    #[tokio::test]
2014    async fn a_large_file_is_served_by_range_and_not_held() {
2015        let body: Vec<u8> = (0..64u8).collect();
2016        let origin = origin_with(
2017            FakeRemote::new()
2018                // Declared far larger than the cache threshold; the body behind it is
2019                // small because what is under test is the branch, not the bytes.
2020                .dir("/srv", vec![("big.bin", file_attrs(9 * 1024 * 1024, 7))])
2021                .file("/srv/big.bin", &body),
2022        )
2023        .await;
2024
2025        let res = origin.handle(ranged("/big.bin", "bytes=0-9")).await;
2026        assert_eq!(res.status(), StatusCode::PARTIAL_CONTENT);
2027        assert_eq!(&body_of(res).await[..], &body[0..10]);
2028
2029        let after = trips(&origin);
2030        let second = origin.handle(ranged("/big.bin", "bytes=10-19")).await;
2031        assert_eq!(&body_of(second).await[..], &body[10..20]);
2032        assert!(
2033            trips(&origin) > after,
2034            "a file over the threshold must not be held"
2035        );
2036    }
2037
2038    /// The boundary, from the side that matters. A page served under an alias origin
2039    /// names the control path and gets a file lookup, not the control router: the 404
2040    /// proves it was never routed there. A 401 would mean the router saw it.
2041    #[tokio::test]
2042    async fn an_alias_origin_has_no_control_api_on_it() {
2043        let origin = origin_with(one_page()).await;
2044        let res = origin.handle(get("/_control/hello", None)).await;
2045        assert_eq!(res.status(), StatusCode::NOT_FOUND);
2046        assert_ne!(
2047            res.status(),
2048            StatusCode::UNAUTHORIZED,
2049            "a 401 would mean the control router was reached from an alias origin"
2050        );
2051    }
2052
2053    /// Even with the right token in hand, an alias origin must not route to control.
2054    /// This is the case a compromised page would actually try.
2055    #[tokio::test]
2056    async fn an_alias_origin_with_a_valid_token_still_has_no_control_api() {
2057        let origin = origin_with(one_page()).await;
2058        let req = Request::builder()
2059            .uri("http://docs.ssh-browser/_control/hello")
2060            .header(HOST, "docs.ssh-browser")
2061            .header(control::TOKEN_HEADER, TEST_TOKEN)
2062            .body(Empty::<Bytes>::new())
2063            .expect("request builds");
2064        assert_eq!(origin.handle(req).await.status(), StatusCode::NOT_FOUND);
2065    }
2066
2067    /// There is no write path on the read side, and a POST is told so rather than being
2068    /// quietly served as a GET.
2069    #[tokio::test]
2070    async fn the_alias_origin_refuses_writes() {
2071        let origin = origin_with(one_page()).await;
2072        for method in [Method::POST, Method::PUT, Method::DELETE, Method::PATCH] {
2073            let req = Request::builder()
2074                .method(method.clone())
2075                .uri("http://docs.ssh-browser/a.html")
2076                .header(HOST, "docs.ssh-browser")
2077                .body(Empty::<Bytes>::new())
2078                .expect("request builds");
2079            assert_eq!(
2080                origin.handle(req).await.status(),
2081                StatusCode::METHOD_NOT_ALLOWED,
2082                "{method} should be refused on the read-only origin"
2083            );
2084        }
2085    }
2086
2087    #[tokio::test]
2088    async fn the_control_api_answers_on_loopback_with_the_token() {
2089        let origin = origin_with(one_page()).await;
2090        let res = origin
2091            .handle(loopback("/_control/hello", Some(TEST_TOKEN)))
2092            .await;
2093        assert_eq!(res.status(), StatusCode::OK);
2094        let body = body_of(res).await;
2095        let text = String::from_utf8_lossy(&body);
2096        assert!(
2097            text.contains("\"protocol\""),
2098            "hello must negotiate: {text}"
2099        );
2100        assert!(text.contains("\"docs\""), "hello must list aliases: {text}");
2101    }
2102
2103    #[tokio::test]
2104    async fn the_control_api_refuses_loopback_without_the_token() {
2105        let origin = origin_with(one_page()).await;
2106        assert_eq!(
2107            origin
2108                .handle(loopback("/_control/hello", None))
2109                .await
2110                .status(),
2111            StatusCode::UNAUTHORIZED
2112        );
2113        assert_eq!(
2114            origin
2115                .handle(loopback("/_control/hello", Some("wrong")))
2116                .await
2117                .status(),
2118            StatusCode::UNAUTHORIZED
2119        );
2120    }
2121
2122    /// The direct browsing path still works alongside the control prefix.
2123    #[tokio::test]
2124    async fn the_loopback_path_still_serves_files() {
2125        let origin = origin_with(one_page()).await;
2126        let res = origin.handle(loopback("/docs/a.html", None)).await;
2127        assert_eq!(res.status(), StatusCode::OK);
2128        assert_eq!(&body_of(res).await[..], b"hello");
2129    }
2130
2131    /// The round trip the extension will make: write one, read it back.
2132    #[tokio::test]
2133    async fn an_annotation_written_through_control_comes_back_out() {
2134        let origin = origin_with(one_page()).await;
2135
2136        let added = origin
2137            .handle(control_post(
2138                "/_control/annotations",
2139                Some(TEST_TOKEN),
2140                r#"{"doc":"docs/a.html","op":"add","body":"a note"}"#,
2141            ))
2142            .await;
2143        assert_eq!(added.status(), StatusCode::OK);
2144        let added = json_of(added).await;
2145        let id = added["id"].as_str().expect("an id was minted").to_string();
2146        assert!(
2147            id.starts_with("souta:"),
2148            "the id must name the daemon's author, got {id}"
2149        );
2150        assert_eq!(added["author"], "souta");
2151
2152        let listed = origin
2153            .handle(loopback(
2154                "/_control/annotations?doc=docs/a.html",
2155                Some(TEST_TOKEN),
2156            ))
2157            .await;
2158        assert_eq!(listed.status(), StatusCode::OK);
2159        let listed = json_of(listed).await;
2160        assert_eq!(listed["skipped"], 0);
2161        let annotations = listed["annotations"].as_array().expect("an array");
2162        assert_eq!(annotations.len(), 1);
2163        assert_eq!(annotations[0]["body"], "a note");
2164        assert_eq!(annotations[0]["id"], id.as_str());
2165        assert_eq!(annotations[0]["author"], "souta");
2166        // This fixture's remote reports no owner, so the honest answer is that nobody
2167        // checked. The field must be there saying so rather than absent, because an
2168        // extension cannot tell an absent field from a daemon that verified and approved.
2169        assert_eq!(annotations[0]["attribution"]["state"], "unchecked");
2170    }
2171
2172    /// The wire shape the extension reads for a forged log: a tagged state and the name of
2173    /// the account that actually owns the file.
2174    #[tokio::test]
2175    async fn a_mismatched_author_reaches_the_extension_as_json() {
2176        let dir = "/srv/.ssh-browser/a.html/ann";
2177        let log = b"{\"op\":\"add\",\"id\":\"alice:1\",\"at\":10,\"body\":\"is this alice?\"}\n";
2178        let origin = origin_with(
2179            one_page()
2180                .dir(dir, vec![("alice.jsonl", file_attrs(log.len() as u64, 1))])
2181                .owner(&format!("{dir}/alice.jsonl"), "bob")
2182                .file(&format!("{dir}/alice.jsonl"), log),
2183        )
2184        .await;
2185
2186        let listed = origin
2187            .handle(loopback(
2188                "/_control/annotations?doc=docs/a.html",
2189                Some(TEST_TOKEN),
2190            ))
2191            .await;
2192        assert_eq!(listed.status(), StatusCode::OK);
2193        let listed = json_of(listed).await;
2194        let annotations = listed["annotations"].as_array().expect("an array");
2195        assert_eq!(annotations.len(), 1, "the note is served, not censored");
2196        assert_eq!(annotations[0]["author"], "alice");
2197        assert_eq!(annotations[0]["attribution"]["state"], "mismatched");
2198        assert_eq!(annotations[0]["attribution"]["owner"], "bob");
2199    }
2200
2201    #[tokio::test]
2202    async fn writing_an_annotation_without_the_token_is_refused() {
2203        let origin = origin_with(one_page()).await;
2204        let res = origin
2205            .handle(control_post(
2206                "/_control/annotations",
2207                None,
2208                r#"{"doc":"docs/a.html","op":"add","body":"a note"}"#,
2209            ))
2210            .await;
2211        assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
2212    }
2213
2214    /// An id chosen by the caller could name a different author, which the store would
2215    /// then refuse. Refusing the id outright removes the possibility instead of catching
2216    /// it later.
2217    #[tokio::test]
2218    async fn an_add_may_not_carry_an_id() {
2219        let origin = origin_with(one_page()).await;
2220        let res = origin
2221            .handle(control_post(
2222                "/_control/annotations",
2223                Some(TEST_TOKEN),
2224                r#"{"doc":"docs/a.html","op":"add","id":"alice:1","body":"x"}"#,
2225            ))
2226            .await;
2227        assert_eq!(res.status(), StatusCode::BAD_REQUEST);
2228    }
2229
2230    #[tokio::test]
2231    async fn an_update_without_an_id_is_refused() {
2232        let origin = origin_with(one_page()).await;
2233        let res = origin
2234            .handle(control_post(
2235                "/_control/annotations",
2236                Some(TEST_TOKEN),
2237                r#"{"doc":"docs/a.html","op":"update","body":"x"}"#,
2238            ))
2239            .await;
2240        assert_eq!(res.status(), StatusCode::BAD_REQUEST);
2241    }
2242
2243    #[tokio::test]
2244    async fn listing_annotations_needs_a_doc() {
2245        let origin = origin_with(one_page()).await;
2246        let res = origin
2247            .handle(loopback("/_control/annotations", Some(TEST_TOKEN)))
2248            .await;
2249        assert_eq!(res.status(), StatusCode::BAD_REQUEST);
2250    }
2251
2252    #[tokio::test]
2253    async fn an_unknown_alias_is_a_404() {
2254        let origin = origin_with(one_page()).await;
2255        let res = origin
2256            .handle(loopback(
2257                "/_control/annotations?doc=nope/a.html",
2258                Some(TEST_TOKEN),
2259            ))
2260            .await;
2261        assert_eq!(res.status(), StatusCode::NOT_FOUND);
2262    }
2263
2264    /// A traversal in the doc parameter must not place a log outside the alias base.
2265    #[tokio::test]
2266    async fn a_traversal_in_the_doc_parameter_is_refused() {
2267        let origin = origin_with(one_page()).await;
2268        let res = origin
2269            .handle(control_post(
2270                "/_control/annotations",
2271                Some(TEST_TOKEN),
2272                r#"{"doc":"docs/../../etc/passwd","op":"add","body":"x"}"#,
2273            ))
2274            .await;
2275        assert_eq!(res.status(), StatusCode::FORBIDDEN);
2276    }
2277
2278    /// The same symlink rule the read path uses, and it matters more here: a write that
2279    /// reached through a symlinked directory could place a file outside the base entirely.
2280    #[tokio::test]
2281    async fn writing_through_a_symlinked_directory_is_refused() {
2282        let origin = origin_with(
2283            FakeRemote::new()
2284                .dir("/srv", vec![("link", symlink_attrs())])
2285                .dir("/srv/link", vec![("inside.html", file_attrs(2, 1))])
2286                .file("/srv/link/inside.html", b"hi"),
2287        )
2288        .await;
2289
2290        let res = origin
2291            .handle(control_post(
2292                "/_control/annotations",
2293                Some(TEST_TOKEN),
2294                r#"{"doc":"docs/link/inside.html","op":"add","body":"x"}"#,
2295            ))
2296            .await;
2297        assert_eq!(res.status(), StatusCode::FORBIDDEN);
2298    }
2299
2300    /// A document nobody has annotated is an empty list, not an error.
2301    #[tokio::test]
2302    async fn an_unannotated_document_lists_empty() {
2303        let origin = origin_with(one_page()).await;
2304        let res = origin
2305            .handle(loopback(
2306                "/_control/annotations?doc=docs/a.html",
2307                Some(TEST_TOKEN),
2308            ))
2309            .await;
2310        assert_eq!(res.status(), StatusCode::OK);
2311        let body = json_of(res).await;
2312        assert_eq!(body["annotations"].as_array().expect("array").len(), 0);
2313    }
2314}