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