1pub mod guard;
15pub mod mime;
16pub mod pac;
17pub mod range;
18
19use std::collections::{HashMap, HashSet};
20use std::net::SocketAddr;
21use std::sync::Arc;
22
23use tokio::sync::RwLock;
24
25use anyhow::{Context, Result, ensure};
26use bytes::Bytes;
27use http_body_util::Full;
28use hyper::header::{
29 ACCEPT_RANGES, CACHE_CONTROL, CONTENT_RANGE, CONTENT_TYPE, ETAG, HOST, HeaderName,
30 IF_NONE_MATCH, IF_RANGE, LOCATION, RANGE,
31};
32use hyper::server::conn::http1;
33use hyper::service::service_fn;
34use hyper::{Method, Request, Response, StatusCode};
35use hyper_util::rt::TokioIo;
36use tokio::net::TcpListener;
37
38use crate::cache::{self, Cache};
39use crate::control::{self, Token};
40use crate::fs::sftp::SftpFs;
41use crate::fs::{Entry, RangeReq, RemoteFs};
42use crate::prefetch;
43use crate::reachable;
44use crate::sftp::wire::Attrs;
45use crate::ssh_config;
46use crate::theme;
47
48const CACHE_WHOLE_MAX: u64 = 8 * 1024 * 1024;
52
53struct Conditions {
55 if_none_match: Option<String>,
56 range: Option<String>,
57 if_range: Option<String>,
58 control_token: Option<String>,
60 fetch_site: Option<String>,
65}
66
67#[derive(Debug)]
75pub struct Alias {
76 name: String,
77 host: String,
78 base: Option<String>,
85}
86
87impl Alias {
88 pub fn new(name: &str, host: &str, base: Option<&str>) -> Result<Self> {
89 ensure!(!host.is_empty(), "alias {name:?} has no ssh host");
90 ensure!(
96 guard::is_label(name),
97 "alias {name:?} must be lowercase letters, digits and hyphens, and may not start or end with a hyphen: it becomes a hostname label"
98 );
99 if let Some(base) = base {
100 ensure!(
101 is_base(base),
102 "alias {name:?} needs a base that is an absolute path, or `~`, or `~/` and a path under the home directory with no `..` in it, got {base:?}"
103 );
104 }
105 Ok(Self {
106 name: name.to_string(),
107 host: host.to_string(),
108 base: base.map(str::to_string),
109 })
110 }
111
112 pub fn name(&self) -> &str {
113 &self.name
114 }
115
116 pub fn host(&self) -> &str {
117 &self.host
118 }
119
120 pub fn base(&self) -> Option<&str> {
122 self.base.as_deref()
123 }
124}
125
126#[derive(serde::Serialize)]
128struct KnownHost {
129 alias: String,
130 host: String,
131 #[serde(flatten)]
132 settings: ssh_config::Settings,
133 served: bool,
138 enabled: bool,
144 #[serde(skip_serializing_if = "Option::is_none")]
145 unresolved: Option<String>,
146}
147
148#[derive(serde::Serialize)]
156struct OpenAlias {
157 alias: String,
158 host: String,
159 base: String,
160 url: String,
161 trips: u64,
170}
171
172#[derive(serde::Serialize)]
173struct KnownHosts {
174 open: Vec<OpenAlias>,
175 hosts: Vec<KnownHost>,
176 unusable: Vec<ssh_config::Unusable>,
177}
178
179fn is_base(base: &str) -> bool {
191 if base.starts_with('/') {
192 return true;
193 }
194 let Some(rest) = base.strip_prefix('~') else {
195 return false;
196 };
197 match rest {
198 "" => true,
199 rest => match rest.strip_prefix('/') {
200 Some(under) => {
201 !under.is_empty()
202 && under
203 .split('/')
204 .all(|c| !c.is_empty() && c != "." && c != "..")
205 }
206 None => false,
207 },
208 }
209}
210
211async fn resolve_base(base: Option<&str>, fs: &SftpFs) -> Result<String> {
217 let under = match base {
218 None | Some("~") => "",
219 Some(b) => match b.strip_prefix("~/") {
220 Some(under) => under,
221 None => return Ok(b.to_string()),
224 },
225 };
226 let home = fs.home().await?;
227 let home = home.trim_end_matches('/');
228 let home = if home.is_empty() { "" } else { home };
231 Ok(match under {
232 "" if home.is_empty() => "/".to_string(),
233 "" => home.to_string(),
234 under => format!("{home}/{under}"),
235 })
236}
237
238impl Origin {
243 async fn session(&self, alias: &str) -> Option<Arc<Session>> {
244 self.sessions.read().await.get(alias).cloned()
245 }
246
247 async fn alias_names(&self) -> Vec<String> {
248 let mut names: Vec<String> = self.sessions.read().await.keys().cloned().collect();
249 names.sort();
250 names
251 }
252
253 async fn round_trips(&self) -> u64 {
255 self.sessions
256 .read()
257 .await
258 .values()
259 .map(|s| s.fs.round_trips())
260 .sum()
261 }
262}
263
264struct Session {
265 host: String,
271 base: String,
272 fs: SftpFs,
273}
274
275pub struct Origin {
276 suffix: String,
277 port: u16,
278 sessions: RwLock<HashMap<String, Arc<Session>>>,
289 cache: Cache,
290 token: Token,
291 theme: RwLock<String>,
297 reachable: RwLock<reachable::Set>,
303}
304
305pub struct Bound {
311 origin: Arc<Origin>,
312 listener: TcpListener,
313 routes: Vec<String>,
314 refused: Vec<String>,
315}
316
317impl Bound {
318 pub fn refused(&self) -> &[String] {
327 &self.refused
328 }
329
330 pub fn routes(&self) -> &[String] {
331 &self.routes
332 }
333}
334
335impl Origin {
336 pub async fn bind(
346 aliases: Vec<Alias>,
347 hosts: reachable::Set,
348 suffix: String,
349 port: u16,
350 token: Token,
351 theme: String,
352 ) -> Result<Bound> {
353 let addr = SocketAddr::from(([127, 0, 0, 1], port));
354 let listener = TcpListener::bind(addr)
355 .await
356 .with_context(|| format!("bind {addr}"))?;
357
358 ensure!(
362 pac::is_suffix(&suffix),
363 "suffix {suffix:?} must be lowercase letters, digits, hyphens and dots"
364 );
365 theme::check(&theme)?;
368
369 let mut sessions = HashMap::new();
370 let mut routes = Vec::new();
371 for a in aliases {
372 let fs = SftpFs::connect(&a.host)
373 .await
374 .with_context(|| format!("alias {} -> ssh host {}", a.name, a.host))?;
375 let base = resolve_base(a.base.as_deref(), &fs)
378 .await
379 .with_context(|| {
380 format!(
381 "alias {} -> ssh host {}: working out where {} is",
382 a.name,
383 a.host,
384 a.base.as_deref().unwrap_or("the home directory")
385 )
386 })?;
387 routes.push(format!(
391 " http://{}.{suffix}/ -> {}:{base}",
392 a.name, a.host
393 ));
394 ensure!(
398 sessions
399 .insert(
400 a.name.clone(),
401 Arc::new(Session {
402 host: a.host.clone(),
403 base,
404 fs,
405 }),
406 )
407 .is_none(),
408 "alias {:?} is defined twice",
409 a.name
410 );
411 }
412 let origin = Arc::new(Self {
413 suffix,
414 port,
415 sessions: RwLock::new(sessions),
416 cache: Cache::default(),
417 token,
418 theme: RwLock::new(theme),
419 reachable: RwLock::new(hosts),
420 });
421
422 let (opened, refused) = origin.open_enabled().await;
429 routes.extend(opened);
430
431 Ok(Bound {
432 routes,
433 refused,
434 origin,
435 listener,
436 })
437 }
438}
439
440impl Bound {
441 pub async fn serve(self) -> Result<()> {
442 let Bound {
443 origin, listener, ..
444 } = self;
445 let self_ = origin;
446
447 loop {
448 let (stream, _) = listener.accept().await?;
449 let me = Arc::clone(&self_);
450 tokio::spawn(async move {
451 let service = service_fn(move |req| {
452 let me = Arc::clone(&me);
453 async move { Ok::<_, std::convert::Infallible>(me.handle(req).await) }
454 });
455 let _ = http1::Builder::new()
459 .serve_connection(TokioIo::new(stream), service)
460 .await;
461 });
462 }
463 }
464}
465
466impl Origin {
467 pub async fn handle<B>(&self, req: Request<B>) -> Response<Full<Bytes>>
470 where
471 B: hyper::body::Body,
472 B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
473 {
474 let Some(host) = host_of(&req) else {
475 return fail(StatusCode::BAD_REQUEST, "request carries no Host");
476 };
477 let path = req.uri().path().to_string();
478 let cond = Conditions {
479 if_none_match: header(&req, IF_NONE_MATCH),
480 range: header(&req, RANGE),
481 if_range: header(&req, IF_RANGE),
482 control_token: req
483 .headers()
484 .get(control::TOKEN_HEADER)
485 .and_then(|v| v.to_str().ok())
486 .map(str::to_string),
487 fetch_site: req
488 .headers()
489 .get(control::FETCH_SITE_HEADER)
490 .and_then(|v| v.to_str().ok())
491 .map(str::to_string),
492 };
493 let method = req.method().clone();
494 let query = req.uri().query().map(str::to_string);
495
496 let control_body = if path.starts_with(control::PATH_PREFIX) {
499 match read_body(req.into_body()).await {
500 Ok(b) => b,
501 Err(e) => return fail(StatusCode::BAD_REQUEST, e),
502 }
503 } else {
504 Bytes::new()
505 };
506
507 match guard::classify(&host, &path, &self.suffix, self.port) {
508 Err(e) => fail(StatusCode::FORBIDDEN, format!("{e:#}")),
511 Ok(guard::Target::Direct { path }) => {
512 self.direct(&method, path, &cond, query.as_deref(), &control_body)
513 .await
514 }
515 Ok(guard::Target::Alias { alias, path }) => {
516 self.alias(&method, alias, path, &cond, query.as_deref())
517 .await
518 }
519 }
520 }
521
522 async fn direct(
523 &self,
524 method: &Method,
525 path: &str,
526 cond: &Conditions,
527 query: Option<&str>,
528 body: &[u8],
529 ) -> Response<Full<Bytes>> {
530 if path.starts_with(control::PATH_PREFIX) {
533 if control::from_a_page(cond.fetch_site.as_deref()) {
536 return control::text(
537 StatusCode::FORBIDDEN,
538 "the control API is not reachable from a page",
539 );
540 }
541 if method == Method::GET && control::route_of(path) == "token" {
549 return control::text(StatusCode::OK, self.token.as_str());
550 }
551 if let Some(refusal) = control::gate(
555 method,
556 cond.fetch_site.as_deref(),
557 cond.control_token.as_deref(),
558 &self.token,
559 ) {
560 return refusal;
561 }
562 return self.control(method, path, body).await;
563 }
564
565 if path == "/proxy.pac" {
566 return match pac::script(&self.suffix, self.port) {
567 Ok(body) => plain_ok("application/x-ns-proxy-autoconfig", Bytes::from(body)),
568 Err(e) => fail(StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")),
569 };
570 }
571
572 let rest = path.trim_start_matches('/');
573 if rest.is_empty() {
574 return plain_ok(
575 "text/html; charset=utf-8",
576 Bytes::from(self.alias_index().await),
577 );
578 }
579
580 let (alias, sub) = rest.split_once('/').unwrap_or((rest, ""));
581 self.alias(method, alias, &format!("/{sub}"), cond, query)
582 .await
583 }
584
585 async fn alias(
586 &self,
587 method: &Method,
588 alias: &str,
589 path: &str,
590 cond: &Conditions,
591 query: Option<&str>,
592 ) -> Response<Full<Bytes>> {
593 if !matches!(*method, Method::GET | Method::HEAD) {
598 return fail(
599 StatusCode::METHOD_NOT_ALLOWED,
600 format!("{method} is not allowed: this origin is read-only"),
601 );
602 }
603
604 let Some(session) = self.session(alias).await else {
605 return fail(StatusCode::NOT_FOUND, format!("no alias named {alias:?}"));
606 };
607 let session = session.as_ref();
608 let resolved = match guard::resolve(&session.base, path) {
609 Ok(p) => p,
610 Err(e) => return fail(StatusCode::FORBIDDEN, format!("{e:#}")),
611 };
612
613 let wants_dir = path.ends_with('/');
614 let file = if wants_dir {
615 format!("{resolved}/index.html")
616 } else {
617 resolved.clone()
618 };
619
620 let chain = components(&session.base, &file);
624 if chain.is_empty() {
625 return self
626 .autoindex_of(session, alias, path, &resolved, query)
627 .await;
628 }
629 let last = chain.len() - 1;
630
631 if let Some((_, name)) = chain.iter().find(|(_, n)| hidden(n)) {
635 return fail(
636 StatusCode::FORBIDDEN,
637 format!("refusing {name}: names beginning with a dot are not served"),
638 );
639 }
640
641 let held = match self.listings_along(session, &chain).await {
642 Ok(held) => held,
643 Err((at, why)) => {
645 return fail(
646 StatusCode::BAD_GATEWAY,
647 format!("{path}: listing {at} failed: {why}"),
648 );
649 }
650 };
651
652 if let Some(at) = first_symlink(&held, &chain) {
656 return fail(
657 StatusCode::FORBIDDEN,
658 format!("refusing symlink at {at} (its target is not checked)"),
659 );
660 }
661
662 let mut found_last = None;
663 for (i, (dir, name)) in chain.iter().enumerate() {
664 let Some(attrs) = attrs_in(&held, dir, name) else {
665 if i == last && wants_dir {
668 return self
669 .autoindex_of(session, alias, path, &resolved, query)
670 .await;
671 }
672 return fail(StatusCode::NOT_FOUND, format!("not found: {path}"));
673 };
674
675 if i < last && !attrs.is_dir() {
676 return fail(
677 StatusCode::NOT_FOUND,
678 format!("{path}: {dir}/{name} is not a directory"),
679 );
680 }
681 if i == last {
682 found_last = Some(attrs);
683 }
684 }
685 let attrs = found_last.expect("the walk assigns on its final iteration");
686
687 if attrs.is_dir() {
688 if wants_dir {
689 return self
691 .autoindex_of(session, alias, path, &resolved, query)
692 .await;
693 }
694 return redirect(&format!("{path}/"));
697 }
698
699 let tag = cache::etag(&attrs);
700
701 if let (Some(tag), Some(header)) = (tag.as_deref(), cond.if_none_match.as_deref()) {
708 if cache::etag_matches(header, tag) {
709 return not_modified(tag);
710 }
711 }
712
713 let size = attrs.size.unwrap_or(0);
716 let wanted = match cond.range.as_deref() {
717 Some(header) => range::resolve(header, cond.if_range.as_deref(), size),
718 None => range::Resolved::Whole,
719 };
720 if wanted == range::Resolved::Unsatisfiable {
721 return unsatisfiable(size);
722 }
723
724 if let Some(body) = self.cache.body(&file, &attrs) {
726 return respond(&file, body, tag.as_deref(), &wanted, size);
727 }
728
729 if let range::Resolved::Part { start, end } = wanted {
733 if size > CACHE_WHOLE_MAX {
734 let req = RangeReq {
735 path: file.clone(),
736 offset: start,
737 len: end - start + 1,
738 };
739 let mut got = session.fs.read_ranges(std::slice::from_ref(&req)).await;
740 return match got.pop() {
741 Some(Ok(body)) => partial(
742 mime::guess(&file),
743 Bytes::from(body),
744 tag.as_deref(),
745 start,
746 end,
747 size,
748 ),
749 Some(Err(e)) => {
750 self.cache.forget_listing(&chain[last].0);
751 fail(StatusCode::NOT_FOUND, format!("{path}: {e:#}"))
752 }
753 None => fail(
754 StatusCode::INTERNAL_SERVER_ERROR,
755 "read_ranges returned no result",
756 ),
757 };
758 }
759 }
760
761 let mut got = match size {
776 0 => session.fs.read_batch(std::slice::from_ref(&file)).await,
777 size => {
778 let req = RangeReq {
779 path: file.clone(),
780 offset: 0,
781 len: size,
782 };
783 let mut ranged = session.fs.read_ranges(std::slice::from_ref(&req)).await;
784 match ranged.pop() {
785 Some(Ok(body)) if body.len() as u64 == size => vec![Ok(body)],
786 _ => session.fs.read_batch(std::slice::from_ref(&file)).await,
793 }
794 }
795 };
796 match got.pop() {
797 Some(Ok(body)) => {
798 let body = Bytes::from(body);
799 self.cache.put_body(&file, &attrs, body.clone());
800 if mime::guess(&file).starts_with("text/html") {
805 self.warm_subresources(session, path, &body).await;
806 }
807 respond(&file, body, tag.as_deref(), &wanted, size)
808 }
809 Some(Err(e)) => {
813 self.cache.forget_listing(&chain[last].0);
814 fail(StatusCode::NOT_FOUND, format!("{path}: {e:#}"))
815 }
816 None => fail(
817 StatusCode::INTERNAL_SERVER_ERROR,
818 "read_batch returned no result",
819 ),
820 }
821 }
822
823 async fn control(&self, method: &Method, path: &str, body: &[u8]) -> Response<Full<Bytes>> {
824 match (method, control::route_of(path)) {
825 (&Method::GET, "hello") => {
826 let aliases = self.alias_names().await;
827 control::hello(&aliases, &self.suffix, self.round_trips().await)
828 }
829 (&Method::GET, "hosts") => self.list_hosts().await,
830 (&Method::POST, "open") => self.open_host(body).await,
831 (&Method::POST, "close") => self.close_alias(body).await,
832 (&Method::POST, "enabled") => self.set_enabled(body).await,
833 (&Method::GET, "theme") => self.show_theme().await,
834 (&Method::POST, "theme") => self.set_theme(body).await,
835 (&Method::GET, route) => {
836 control::text(StatusCode::NOT_FOUND, format!("no control route {route:?}"))
837 }
838 (_, route) => control::text(
839 StatusCode::METHOD_NOT_ALLOWED,
840 format!("{method} is not allowed on {route:?}"),
841 ),
842 }
843 }
844
845 async fn list_hosts(&self) -> Response<Full<Bytes>> {
856 let found = match ssh_config::read() {
857 Ok(found) => found,
858 Err(e) => {
859 return control::text(
860 StatusCode::INTERNAL_SERVER_ERROR,
861 format!("reading ssh_config: {e:#}"),
862 );
863 }
864 };
865
866 let described: Vec<_> = found
870 .hosts
871 .iter()
872 .map(|h| {
873 let host = h.host.clone();
874 tokio::spawn(async move { ssh_config::describe(&host).await })
875 })
876 .collect();
877
878 let open = {
879 let sessions = self.sessions.read().await;
880 let mut open: Vec<OpenAlias> = sessions
881 .iter()
882 .map(|(alias, s)| OpenAlias {
883 alias: alias.clone(),
884 host: s.host.clone(),
885 base: s.base.clone(),
886 url: format!("http://{alias}.{}/", self.suffix),
887 trips: s.fs.round_trips(),
888 })
889 .collect();
890 open.sort_by(|a, b| a.alias.cmp(&b.alias));
891 open
892 };
893 let enabled: Vec<String> = self
895 .reachable
896 .read()
897 .await
898 .enabled()
899 .map(|h| h.name.clone())
900 .collect();
901 let mut hosts = Vec::with_capacity(found.hosts.len());
902 for (h, task) in found.hosts.iter().zip(described) {
903 let (settings, unresolved) = match task.await {
907 Ok(Ok(settings)) => (settings, None),
908 Ok(Err(e)) => (ssh_config::Settings::default(), Some(format!("{e:#}"))),
909 Err(e) => (ssh_config::Settings::default(), Some(e.to_string())),
910 };
911 hosts.push(KnownHost {
912 alias: h.alias.clone(),
913 host: h.host.clone(),
914 settings,
915 served: open.iter().any(|o| o.alias == h.alias),
916 enabled: enabled.iter().any(|name| name == &h.alias),
917 unresolved,
918 });
919 }
920 control::json(&KnownHosts {
921 open,
922 hosts,
923 unusable: found.unusable,
924 })
925 }
926
927 async fn open_host(&self, body: &[u8]) -> Response<Full<Bytes>> {
937 #[derive(serde::Deserialize)]
938 #[serde(deny_unknown_fields)]
939 struct Ask {
940 host: String,
941 #[serde(default)]
943 base: Option<String>,
944 }
945
946 let ask: Ask = match serde_json::from_slice(body) {
947 Ok(ask) => ask,
948 Err(e) => {
949 return control::text(
950 StatusCode::BAD_REQUEST,
951 format!("open needs a JSON body naming a host: {e}"),
952 );
953 }
954 };
955
956 let found = match ssh_config::read() {
957 Ok(found) => found,
958 Err(e) => {
959 return control::text(
960 StatusCode::INTERNAL_SERVER_ERROR,
961 format!("reading ssh_config: {e:#}"),
962 );
963 }
964 };
965 let Some(known) = found
969 .hosts
970 .iter()
971 .find(|h| h.host.eq_ignore_ascii_case(&ask.host) || h.alias == ask.host)
972 else {
973 return control::text(
974 StatusCode::NOT_FOUND,
975 format!("{:?} is not a host in your ssh_config", ask.host),
976 );
977 };
978
979 let alias = match Alias::new(&known.alias, &known.host, ask.base.as_deref()) {
980 Ok(alias) => alias,
981 Err(e) => return control::text(StatusCode::BAD_REQUEST, format!("{e:#}")),
982 };
983
984 if let Some(open) = self.session(&known.alias).await {
989 let Some(asked) = alias.base() else {
994 return self.opened(&known.alias, &known.host, &open.base);
995 };
996 let wanted = match resolve_base(Some(asked), &open.fs).await {
1002 Ok(base) => base,
1003 Err(e) => {
1004 return control::text(
1005 StatusCode::BAD_GATEWAY,
1006 format!("working out where to root {}: {e:#}", known.alias),
1007 );
1008 }
1009 };
1010 if wanted != open.base {
1011 return control::text(
1012 StatusCode::CONFLICT,
1013 format!(
1014 "{} is already open at {}, and {} is not the same place; a second base would change what that origin means underneath any page open in it",
1015 known.alias, open.base, wanted
1016 ),
1017 );
1018 }
1019 return self.opened(&known.alias, &known.host, &open.base);
1020 }
1021
1022 let fs = match SftpFs::connect(&known.host).await {
1023 Ok(fs) => fs,
1024 Err(e) => {
1025 return control::text(
1030 StatusCode::BAD_GATEWAY,
1031 format!("ssh to {}: {e:#}", known.host),
1032 );
1033 }
1034 };
1035 let base = match resolve_base(alias.base(), &fs).await {
1036 Ok(base) => base,
1037 Err(e) => {
1038 return control::text(
1039 StatusCode::BAD_GATEWAY,
1040 format!("working out where to root {}: {e:#}", known.alias),
1041 );
1042 }
1043 };
1044
1045 let session = {
1050 let mut sessions = self.sessions.write().await;
1051 Arc::clone(sessions.entry(known.alias.clone()).or_insert_with(|| {
1052 Arc::new(Session {
1053 host: known.host.clone(),
1054 base,
1055 fs,
1056 })
1057 }))
1058 };
1059 self.opened(&known.alias, &known.host, &session.base)
1060 }
1061
1062 async fn close_alias(&self, body: &[u8]) -> Response<Full<Bytes>> {
1071 #[derive(serde::Deserialize)]
1072 #[serde(deny_unknown_fields)]
1073 struct Ask {
1074 alias: String,
1075 }
1076
1077 let ask: Ask = match serde_json::from_slice(body) {
1078 Ok(ask) => ask,
1079 Err(e) => {
1080 return control::text(
1081 StatusCode::BAD_REQUEST,
1082 format!("close needs a JSON body naming an alias: {e}"),
1083 );
1084 }
1085 };
1086
1087 let gone = self.sessions.write().await.remove(&ask.alias);
1092 match gone {
1093 Some(session) => {
1094 #[derive(serde::Serialize)]
1095 struct Closed<'a> {
1096 alias: &'a str,
1097 host: &'a str,
1098 base: &'a str,
1099 }
1100 control::json(&Closed {
1101 alias: &ask.alias,
1102 host: &session.host,
1103 base: &session.base,
1104 })
1105 }
1106 None => control::text(
1110 StatusCode::NOT_FOUND,
1111 format!("no alias named {:?} is open", ask.alias),
1112 ),
1113 }
1114 }
1115
1116 async fn dial(alias: String, host: String, base: Option<String>) -> Result<Session> {
1123 let fs = SftpFs::connect(&host)
1124 .await
1125 .with_context(|| format!("ssh to {host}"))?;
1126 let resolved = resolve_base(base.as_deref(), &fs)
1127 .await
1128 .with_context(|| format!("working out where to root {alias}"))?;
1129 Ok(Session {
1130 host,
1131 base: resolved,
1132 fs,
1133 })
1134 }
1135
1136 async fn adopt(&self, alias: &str, session: Session) -> Arc<Session> {
1142 let mut sessions = self.sessions.write().await;
1143 Arc::clone(
1144 sessions
1145 .entry(alias.to_string())
1146 .or_insert_with(|| Arc::new(session)),
1147 )
1148 }
1149
1150 async fn connect(&self, alias: &str, host: &str, base: Option<&str>) -> Result<Arc<Session>> {
1151 let session = Self::dial(
1152 alias.to_string(),
1153 host.to_string(),
1154 base.map(str::to_string),
1155 )
1156 .await?;
1157 Ok(self.adopt(alias, session).await)
1158 }
1159
1160 async fn open_enabled(&self) -> (Vec<String>, Vec<String>) {
1165 let wanted: Vec<reachable::Host> = self.reachable.read().await.enabled().cloned().collect();
1166 if wanted.is_empty() {
1167 return (Vec::new(), Vec::new());
1168 }
1169
1170 let known = match ssh_config::read() {
1183 Ok(found) => found.hosts,
1184 Err(e) => {
1185 let mut refused: Vec<String> = wanted
1186 .iter()
1187 .map(|h| {
1188 format!(
1189 " {} is enabled but ssh_config could not be read: {e:#}",
1190 h.name
1191 )
1192 })
1193 .collect();
1194 refused.sort();
1195 return (Vec::new(), refused);
1196 }
1197 };
1198
1199 let mut dialling = tokio::task::JoinSet::new();
1200 let mut refused = Vec::new();
1201 for host in wanted {
1202 let Some(entry) = entry_for(&known, &host.name) else {
1203 refused.push(format!(
1207 " {} is enabled but is no longer a host in your ssh_config",
1208 host.name
1209 ));
1210 continue;
1211 };
1212 let (label, target) = (entry.alias.clone(), entry.host.clone());
1213 dialling.spawn(async move {
1214 let got = Self::dial(label.clone(), target, host.base.clone()).await;
1215 (label, got)
1216 });
1217 }
1218
1219 let mut opened = Vec::new();
1220 while let Some(finished) = dialling.join_next().await {
1221 let (name, got) = match finished {
1222 Ok(pair) => pair,
1223 Err(e) => {
1227 refused.push(format!(" an enabled host could not be opened: {e}"));
1228 continue;
1229 }
1230 };
1231 match got {
1232 Ok(session) => {
1233 let base = session.base.clone();
1234 self.adopt(&name, session).await;
1235 opened.push(format!(
1236 " http://{name}.{}/ -> {name}:{base}",
1237 self.suffix
1238 ));
1239 }
1240 Err(e) => refused.push(format!(" {name} is enabled but did not answer: {e:#}")),
1244 }
1245 }
1246 opened.sort();
1247 refused.sort();
1248 (opened, refused)
1249 }
1250
1251 fn opened(&self, alias: &str, host: &str, base: &str) -> Response<Full<Bytes>> {
1252 #[derive(serde::Serialize)]
1253 struct Opened<'a> {
1254 alias: &'a str,
1255 host: &'a str,
1256 base: &'a str,
1257 url: String,
1258 }
1259 control::json(&Opened {
1260 alias,
1261 host,
1262 base,
1263 url: format!("http://{alias}.{}/", self.suffix),
1264 })
1265 }
1266
1267 async fn set_enabled(&self, body: &[u8]) -> Response<Full<Bytes>> {
1278 #[derive(serde::Deserialize)]
1279 #[serde(deny_unknown_fields)]
1280 struct Ask {
1281 host: String,
1282 enabled: bool,
1283 #[serde(default)]
1285 base: Option<String>,
1286 }
1287
1288 let ask: Ask = match serde_json::from_slice(body) {
1289 Ok(ask) => ask,
1290 Err(e) => {
1291 return control::text(
1292 StatusCode::BAD_REQUEST,
1293 format!("enabled needs a JSON body naming a host and whether it is on: {e}"),
1294 );
1295 }
1296 };
1297
1298 let found = match ssh_config::read() {
1303 Ok(found) => found,
1304 Err(e) => {
1305 return control::text(
1306 StatusCode::INTERNAL_SERVER_ERROR,
1307 format!("reading ssh_config: {e:#}"),
1308 );
1309 }
1310 };
1311 let Some(known) = found
1312 .hosts
1313 .iter()
1314 .find(|h| h.host.eq_ignore_ascii_case(&ask.host) || h.alias == ask.host)
1315 else {
1316 return control::text(
1317 StatusCode::NOT_FOUND,
1318 format!("{:?} is not a host in your ssh_config", ask.host),
1319 );
1320 };
1321
1322 if let Err(e) = Alias::new(&known.alias, &known.host, ask.base.as_deref()) {
1325 return control::text(StatusCode::BAD_REQUEST, format!("{e:#}"));
1326 }
1327
1328 if ask.enabled {
1329 if self.session(&known.alias).await.is_none() {
1333 if let Err(e) = self
1334 .connect(&known.alias, &known.host, ask.base.as_deref())
1335 .await
1336 {
1337 return control::text(StatusCode::BAD_GATEWAY, format!("{e:#}"));
1338 }
1339 }
1340 } else {
1341 self.sessions.write().await.remove(&known.alias);
1342 }
1343
1344 let remembered = {
1345 let mut set = self.reachable.write().await;
1346 set.set(&known.alias, ask.enabled, ask.base.clone());
1347 reachable::remember(&set).is_ok()
1351 };
1352
1353 #[derive(serde::Serialize)]
1354 struct Switched<'a> {
1355 host: &'a str,
1356 enabled: bool,
1357 remembered: bool,
1358 url: Option<String>,
1359 }
1360 control::json(&Switched {
1361 host: &known.alias,
1362 enabled: ask.enabled,
1363 remembered,
1364 url: ask
1365 .enabled
1366 .then(|| format!("http://{}.{}/", known.alias, self.suffix)),
1367 })
1368 }
1369
1370 async fn show_theme(&self) -> Response<Full<Bytes>> {
1372 #[derive(serde::Serialize)]
1373 struct Choice<'a> {
1374 name: &'a str,
1375 label: &'a str,
1376 variant: &'a str,
1378 }
1379 #[derive(serde::Serialize)]
1380 struct Themes<'a> {
1381 current: &'a str,
1382 themes: Vec<Choice<'a>>,
1383 }
1384 control::json(&Themes {
1387 current: &self.theme.read().await,
1388 themes: theme::all()
1389 .iter()
1390 .map(|t| Choice {
1391 name: &t.name,
1392 label: &t.label,
1393 variant: t.variant,
1394 })
1395 .collect(),
1396 })
1397 }
1398
1399 async fn set_theme(&self, body: &[u8]) -> Response<Full<Bytes>> {
1401 #[derive(serde::Deserialize)]
1402 #[serde(deny_unknown_fields)]
1403 struct Ask {
1404 name: String,
1405 }
1406 let ask: Ask = match serde_json::from_slice(body) {
1407 Ok(ask) => ask,
1408 Err(e) => {
1409 return control::text(
1410 StatusCode::BAD_REQUEST,
1411 format!("theme needs a JSON body naming one: {e}"),
1412 );
1413 }
1414 };
1415 if let Err(e) = theme::check(&ask.name) {
1418 return control::text(StatusCode::BAD_REQUEST, format!("{e:#}"));
1419 }
1420
1421 *self.theme.write().await = ask.name.clone();
1422 let remembered = theme::remember(&ask.name).is_ok();
1426 #[derive(serde::Serialize)]
1427 struct Chose<'a> {
1428 current: &'a str,
1429 remembered: bool,
1430 }
1431 control::json(&Chose {
1432 current: &ask.name,
1433 remembered,
1434 })
1435 }
1436
1437 async fn autoindex_of(
1438 &self,
1439 session: &Session,
1440 alias: &str,
1441 path: &str,
1442 resolved: &str,
1443 query: Option<&str>,
1444 ) -> Response<Full<Bytes>> {
1445 let rel = resolved
1449 .strip_prefix(&session.base)
1450 .unwrap_or("")
1451 .to_string();
1452 let entries = match self.listing_of(session, resolved).await {
1453 Ok(entries) => entries,
1454 Err(e) => return fail(StatusCode::NOT_FOUND, format!("{path}: {e:#}")),
1455 };
1456 let sites = self.sites_among(session, resolved, &entries).await;
1457
1458 if query == Some("ls") {
1469 let mut out = String::new();
1470 render_level(&mut out, &rel, &rows_of(&entries, &sites), &[]);
1471 return plain_ok("text/html; charset=utf-8", Bytes::from(out));
1472 }
1473
1474 let mut levels = Vec::new();
1478 let mut at = session.base.clone();
1479 for part in rel.split('/').filter(|p| !p.is_empty()) {
1480 if let Some(entries) = self.cache.listing_entries(&at) {
1481 let here = at.strip_prefix(&session.base).unwrap_or("").to_string();
1482 levels.push((here, rows_of(&entries, &HashSet::new())));
1487 }
1488 at.push('/');
1489 at.push_str(part);
1490 }
1491 levels.push((rel.clone(), rows_of(&entries, &sites)));
1492
1493 plain_ok(
1494 "text/html; charset=utf-8",
1495 Bytes::from(autoindex(alias, &rel, &levels, &self.theme.read().await)),
1496 )
1497 }
1498
1499 async fn listing_of(&self, session: &Session, dir: &str) -> Result<Vec<Entry>> {
1501 if let Some(entries) = self.cache.listing_entries(dir) {
1502 return Ok(entries);
1503 }
1504 let entries = session.fs.list_dir(dir).await?;
1505 self.cache.put_listing(dir, &entries);
1506 Ok(entries)
1507 }
1508
1509 async fn sites_among(
1523 &self,
1524 session: &Session,
1525 dir: &str,
1526 entries: &[Entry],
1527 ) -> HashSet<String> {
1528 const MAX_SCAN: usize = 64;
1531
1532 let names: Vec<&str> = entries
1533 .iter()
1534 .filter(|e| e.attrs.is_dir() && e.name != "." && e.name != ".." && !hidden(&e.name))
1535 .map(|e| e.name.as_str())
1536 .take(MAX_SCAN)
1537 .collect();
1538 if names.is_empty() {
1539 return HashSet::new();
1540 }
1541
1542 let paths: Vec<String> = names.iter().map(|n| format!("{dir}/{n}")).collect();
1543 let missing: Vec<String> = paths
1546 .iter()
1547 .filter(|p| self.cache.listing_entries(p).is_none())
1548 .cloned()
1549 .collect();
1550 if !missing.is_empty() {
1551 for (path, got) in missing.iter().zip(session.fs.list_dirs(&missing).await) {
1552 if let Ok(entries) = got {
1553 self.cache.put_listing(path, &entries);
1554 }
1555 }
1559 }
1560
1561 names
1562 .iter()
1563 .zip(paths.iter())
1564 .filter(|(_, path)| {
1565 self.cache.listing_entries(path).is_some_and(|listing| {
1566 listing
1567 .iter()
1568 .any(|e| e.name == "index.html" && !e.attrs.is_dir())
1569 })
1570 })
1571 .map(|(name, _)| (*name).to_string())
1572 .collect()
1573 }
1574
1575 async fn warm_subresources(&self, session: &Session, doc_path: &str, html: &[u8]) {
1597 let refs = prefetch::scan(html, prefetch::MAX_SUBRESOURCES);
1598 if refs.is_empty() {
1599 return;
1600 }
1601 let dir_of_doc = match doc_path.rsplit_once('/') {
1604 Some((head, _)) => head,
1605 None => "",
1606 };
1607
1608 let mut wanted: Vec<(String, Vec<(String, String)>)> = Vec::new();
1611 for r in &refs {
1612 let url = if r.starts_with('/') {
1613 r.clone()
1614 } else {
1615 format!("{dir_of_doc}/{r}")
1616 };
1617 let Ok(resolved) = guard::resolve(&session.base, &url) else {
1618 continue;
1619 };
1620 let chain = components(&session.base, &resolved);
1621 if chain.is_empty() {
1622 continue;
1623 }
1624 if chain.iter().any(|(_, n)| hidden(n)) {
1628 continue;
1629 }
1630 if self.first_symlink_cached(&chain).is_some() {
1635 continue;
1636 }
1637 if !chain
1643 .iter()
1644 .all(|(dir, _)| self.listable(&session.base, dir))
1645 {
1646 continue;
1647 }
1648 wanted.push((resolved, chain));
1649 }
1650
1651 let all: Vec<(String, String)> = wanted.iter().flat_map(|(_, c)| c.clone()).collect();
1652 let held = self.held_listings(session, &all).await;
1656
1657 let mut to_read = Vec::new();
1658 for (resolved, chain) in &wanted {
1659 if first_symlink(&held, chain).is_some() {
1660 continue;
1661 }
1662 let (dir, name) = &chain[chain.len() - 1];
1663 let Some(attrs) = attrs_in(&held, dir, name) else {
1664 continue;
1665 };
1666 if attrs.is_dir() {
1667 continue;
1668 }
1669 let Some(size) = attrs.size else {
1674 continue;
1675 };
1676 if size == 0 || size > CACHE_WHOLE_MAX {
1684 continue;
1685 }
1686 if self.cache.body(resolved, &attrs).is_some() {
1687 continue;
1688 }
1689 to_read.push((resolved.clone(), attrs, size));
1690 }
1691 if to_read.is_empty() {
1692 return;
1693 }
1694
1695 let reqs: Vec<RangeReq> = to_read
1703 .iter()
1704 .map(|(path, _, size)| RangeReq {
1705 path: path.clone(),
1706 offset: 0,
1707 len: *size,
1708 })
1709 .collect();
1710
1711 for ((path, attrs, size), got) in to_read.iter().zip(session.fs.read_ranges(&reqs).await) {
1712 let Ok(body) = got else {
1713 continue;
1714 };
1715 if body.len() as u64 != *size {
1720 continue;
1721 }
1722 self.cache.put_body(path, attrs, Bytes::from(body));
1723 }
1724 }
1725
1726 async fn listings_along(
1744 &self,
1745 session: &Session,
1746 chain: &[(String, String)],
1747 ) -> Result<HashMap<String, Vec<Entry>>, (String, String)> {
1748 let mut held: HashMap<String, Vec<Entry>> = HashMap::new();
1749 let mut missing: Vec<String> = Vec::new();
1750 for (dir, _) in chain {
1751 if held.contains_key(dir) {
1752 continue;
1753 }
1754 match self.cache.listing_entries(dir) {
1755 Some(entries) => {
1756 held.insert(dir.clone(), entries);
1757 }
1758 None if !missing.contains(dir) => missing.push(dir.clone()),
1762 None => {}
1763 }
1764 }
1765 if missing.is_empty() {
1766 return Ok(held);
1767 }
1768 for (dir, result) in missing.iter().zip(session.fs.list_dirs(&missing).await) {
1769 match result {
1770 Ok(entries) => {
1771 self.cache.put_listing(dir, &entries);
1772 held.insert(dir.clone(), entries);
1773 }
1774 Err(e) if crate::fs::is_absent(&e) => {}
1779 Err(e) => return Err((dir.clone(), format!("{e:#}"))),
1780 }
1781 }
1782 Ok(held)
1783 }
1784
1785 fn listable(&self, base: &str, dir: &str) -> bool {
1794 if dir.trim_end_matches('/') == base.trim_end_matches('/') {
1795 return true;
1796 }
1797 components(base, dir).iter().all(|(parent, name)| {
1798 self.cache
1799 .attrs_of(parent, name)
1800 .is_some_and(|a| a.is_dir() && !a.is_symlink())
1801 })
1802 }
1803
1804 async fn held_listings(
1814 &self,
1815 session: &Session,
1816 chain: &[(String, String)],
1817 ) -> HashMap<String, Vec<Entry>> {
1818 self.listings_along(session, chain)
1819 .await
1820 .unwrap_or_default()
1821 }
1822}
1823
1824impl Origin {
1825 fn first_symlink_cached(&self, chain: &[(String, String)]) -> Option<String> {
1831 chain.iter().find_map(|(dir, name)| {
1832 self.cache
1833 .attrs_of(dir, name)
1834 .filter(Attrs::is_symlink)
1835 .map(|_| format!("{dir}/{name}"))
1836 })
1837 }
1838}
1839
1840fn attrs_in(held: &HashMap<String, Vec<Entry>>, dir: &str, name: &str) -> Option<Attrs> {
1842 held.get(dir)
1843 .and_then(|entries| entries.iter().find(|e| e.name == name))
1844 .map(|e| e.attrs)
1845}
1846
1847fn first_symlink(held: &HashMap<String, Vec<Entry>>, chain: &[(String, String)]) -> Option<String> {
1848 chain.iter().find_map(|(dir, name)| {
1849 attrs_in(held, dir, name)
1850 .filter(Attrs::is_symlink)
1851 .map(|_| format!("{dir}/{name}"))
1852 })
1853}
1854
1855impl Origin {
1856 async fn alias_index(&self) -> String {
1857 let names = self.alias_names().await;
1858 let mut s = String::from(
1859 "<!doctype html><html><head><meta charset=\"utf-8\"><title>ssh-browser</title></head><body><h1>ssh-browser</h1><ul>",
1860 );
1861 for name in names {
1862 let href = format!("http://{name}.{}/", self.suffix);
1863 s.push_str("<li><a href=\"");
1864 s.push_str(&escape(&href));
1865 s.push_str("\">");
1866 s.push_str(&escape(&href));
1867 s.push_str("</a></li>");
1868 }
1869 s.push_str("</ul></body></html>");
1870 s
1871 }
1872}
1873
1874fn components(base: &str, file: &str) -> Vec<(String, String)> {
1880 let base = base.trim_end_matches('/');
1881 let relative = file
1882 .strip_prefix(base)
1883 .unwrap_or("")
1884 .trim_start_matches('/');
1885
1886 let mut out = Vec::new();
1887 let mut dir = base.to_string();
1888 for name in relative.split('/').filter(|s| !s.is_empty()) {
1889 out.push((dir.clone(), name.to_string()));
1890 dir = format!("{dir}/{name}");
1891 }
1892 out
1893}
1894
1895const MAX_CONTROL_BODY: usize = 256 * 1024;
1900
1901async fn read_body<B>(body: B) -> Result<Bytes, String>
1902where
1903 B: hyper::body::Body,
1904 B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
1905{
1906 use http_body_util::{BodyExt, Limited};
1907 Limited::new(body, MAX_CONTROL_BODY)
1908 .collect()
1909 .await
1910 .map(|collected| collected.to_bytes())
1911 .map_err(|e| format!("reading the request body: {e}"))
1912}
1913
1914fn header<B>(req: &Request<B>, name: HeaderName) -> Option<String> {
1915 req.headers()
1916 .get(name)
1917 .and_then(|v| v.to_str().ok())
1918 .map(str::to_string)
1919}
1920
1921fn respond(
1923 file: &str,
1924 body: Bytes,
1925 tag: Option<&str>,
1926 wanted: &range::Resolved,
1927 size: u64,
1928) -> Response<Full<Bytes>> {
1929 match wanted {
1930 range::Resolved::Part { start, end } => {
1931 let lo = usize::try_from(*start)
1934 .unwrap_or(usize::MAX)
1935 .min(body.len());
1936 let hi = usize::try_from(end.saturating_add(1))
1937 .unwrap_or(usize::MAX)
1938 .min(body.len())
1939 .max(lo);
1940 partial(
1941 mime::guess(file),
1942 body.slice(lo..hi),
1943 tag,
1944 *start,
1945 *end,
1946 size,
1947 )
1948 }
1949 _ => served(mime::guess(file), body, tag),
1950 }
1951}
1952
1953fn partial(
1954 content_type: &str,
1955 body: Bytes,
1956 tag: Option<&str>,
1957 start: u64,
1958 end: u64,
1959 size: u64,
1960) -> Response<Full<Bytes>> {
1961 let mut b = Response::builder()
1962 .status(StatusCode::PARTIAL_CONTENT)
1963 .header(CONTENT_TYPE, content_type)
1964 .header(CACHE_CONTROL, "no-cache")
1965 .header(ACCEPT_RANGES, "bytes")
1966 .header(CONTENT_RANGE, format!("bytes {start}-{end}/{size}"));
1967 if let Some(tag) = tag {
1968 b = b.header(ETAG, tag);
1969 }
1970 b.body(Full::new(body))
1971 .unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR, "malformed 206"))
1972}
1973
1974fn unsatisfiable(size: u64) -> Response<Full<Bytes>> {
1977 Response::builder()
1978 .status(StatusCode::RANGE_NOT_SATISFIABLE)
1979 .header(CONTENT_TYPE, "text/plain; charset=utf-8")
1980 .header(CONTENT_RANGE, format!("bytes */{size}"))
1981 .body(Full::new(Bytes::from_static(b"range not satisfiable")))
1982 .unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR, "malformed 416"))
1983}
1984
1985fn host_of<B>(req: &Request<B>) -> Option<String> {
1986 req.headers()
1989 .get(HOST)
1990 .and_then(|v| v.to_str().ok())
1991 .map(str::to_string)
1992 .or_else(|| req.uri().host().map(str::to_string))
1993}
1994
1995fn served(content_type: &str, body: Bytes, tag: Option<&str>) -> Response<Full<Bytes>> {
1996 let mut b = Response::builder()
1997 .status(StatusCode::OK)
1998 .header(CONTENT_TYPE, content_type)
1999 .header(CACHE_CONTROL, "no-cache")
2003 .header(ACCEPT_RANGES, "bytes");
2006 if let Some(tag) = tag {
2007 b = b.header(ETAG, tag);
2008 }
2009 b.body(Full::new(body))
2010 .unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR, "malformed response"))
2011}
2012
2013fn plain_ok(content_type: &str, body: Bytes) -> Response<Full<Bytes>> {
2015 served(content_type, body, None)
2016}
2017
2018fn not_modified(tag: &str) -> Response<Full<Bytes>> {
2025 Response::builder()
2026 .status(StatusCode::NOT_MODIFIED)
2027 .header(ETAG, tag)
2028 .header(CACHE_CONTROL, "no-cache")
2029 .body(Full::new(Bytes::new()))
2030 .unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR, "malformed 304"))
2031}
2032
2033fn fail(status: StatusCode, detail: impl Into<String>) -> Response<Full<Bytes>> {
2034 Response::builder()
2035 .status(status)
2036 .header(CONTENT_TYPE, "text/plain; charset=utf-8")
2037 .body(Full::new(Bytes::from(detail.into())))
2038 .expect("a plain-text body with static headers always builds")
2039}
2040
2041fn redirect(to: &str) -> Response<Full<Bytes>> {
2042 Response::builder()
2043 .status(StatusCode::MOVED_PERMANENTLY)
2044 .header(LOCATION, to)
2045 .body(Full::new(Bytes::new()))
2046 .unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR, "bad redirect target"))
2047}
2048
2049fn entry_for<'a>(known: &'a [ssh_config::Host], name: &str) -> Option<&'a ssh_config::Host> {
2063 known
2064 .iter()
2065 .find(|h| h.alias == name || h.host.eq_ignore_ascii_case(name))
2066}
2067
2068fn hidden(name: &str) -> bool {
2074 name.starts_with('.')
2075}
2076
2077struct Row {
2079 name: String,
2080 dir: bool,
2081 site: bool,
2083 size: Option<String>,
2085 modified: Option<String>,
2086 kind: &'static str,
2088}
2089
2090fn rows_of(entries: &[Entry], sites: &HashSet<String>) -> Vec<Row> {
2092 let mut visible: Vec<&Entry> = entries
2093 .iter()
2094 .filter(|e| e.name != "." && e.name != ".." && !hidden(&e.name))
2097 .collect();
2098 visible.sort_by(|a, b| (rank(a, sites), &a.name).cmp(&(rank(b, sites), &b.name)));
2099
2100 visible
2101 .into_iter()
2102 .map(|e| {
2103 let dir = e.attrs.is_dir();
2104 Row {
2105 name: e.name.clone(),
2106 dir,
2107 site: dir && sites.contains(&e.name),
2108 size: if dir {
2109 None
2110 } else {
2111 e.attrs.size.map(human_size)
2112 },
2113 modified: e.attrs.mtime.map(utc_stamp),
2114 kind: if dir { "dir" } else { family(&e.name) },
2115 }
2116 })
2117 .collect()
2118}
2119
2120fn rank(e: &Entry, sites: &HashSet<String>) -> (u8, u8) {
2130 if e.attrs.is_dir() {
2131 (0, u8::from(!sites.contains(&e.name)))
2132 } else {
2133 (1, u8::from(!is_page(&e.name)))
2134 }
2135}
2136
2137fn is_page(name: &str) -> bool {
2138 matches!(extension_of(name).as_deref(), Some("html" | "htm"))
2139}
2140
2141fn family(name: &str) -> &'static str {
2147 match extension_of(name).as_deref() {
2148 Some("html" | "htm") => "k-page",
2149 Some("md" | "txt" | "rst" | "tex" | "bib" | "pdf" | "org" | "adoc") => "k-doc",
2150 Some("json" | "toml" | "yaml" | "yml" | "csv" | "tsv" | "xml" | "ini" | "lock") => "k-data",
2151 Some(
2152 "rs" | "jl" | "py" | "ts" | "js" | "mjs" | "sh" | "c" | "h" | "cpp" | "go" | "rb"
2153 | "lua" | "css" | "scss" | "lean" | "hs" | "java" | "kt" | "swift" | "sql",
2154 ) => "k-code",
2155 Some(
2156 "png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "avif" | "ico" | "mp4" | "webm"
2157 | "mov" | "mp3" | "wav",
2158 ) => "k-media",
2159 _ => "k-plain",
2160 }
2161}
2162
2163fn extension_of(name: &str) -> Option<String> {
2165 let dot = name.rfind('.')?;
2166 if dot == 0 || dot + 1 == name.len() {
2169 return None;
2170 }
2171 Some(name[dot + 1..].to_ascii_lowercase())
2172}
2173
2174fn human_size(n: u64) -> String {
2179 const UNITS: [&str; 5] = ["KiB", "MiB", "GiB", "TiB", "PiB"];
2180 if n < 1024 {
2181 return format!("{n} B");
2182 }
2183 let mut v = n as f64 / 1024.0;
2184 let mut unit = 0;
2185 while v >= 1024.0 && unit + 1 < UNITS.len() {
2186 v /= 1024.0;
2187 unit += 1;
2188 }
2189 if v < 10.0 {
2192 format!("{v:.1} {}", UNITS[unit])
2193 } else {
2194 format!("{v:.0} {}", UNITS[unit])
2195 }
2196}
2197
2198fn utc_stamp(secs: u32) -> String {
2205 let secs = i64::from(secs);
2206 let (y, m, d) = civil_from_days(secs.div_euclid(86_400));
2207 let rest = secs.rem_euclid(86_400);
2208 let (hh, mm) = (rest / 3600, (rest % 3600) / 60);
2209 format!("{y:04}-{m:02}-{d:02} {hh:02}:{mm:02}")
2210}
2211
2212fn civil_from_days(z: i64) -> (i64, u32, u32) {
2216 let z = z + 719_468;
2217 let era = if z >= 0 { z } else { z - 146_096 }.div_euclid(146_097);
2218 let doe = (z - era * 146_097) as u64;
2219 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
2220 let y = yoe as i64 + era * 400;
2221 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
2222 let mp = (5 * doy + 2) / 153;
2223 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
2224 let m = u32::try_from(if mp < 10 { mp + 3 } else { mp - 9 }).unwrap_or(1);
2225 (if m <= 2 { y + 1 } else { y }, m, d)
2226}
2227
2228const LISTING_CSS: &str = "\
2237*{box-sizing:border-box}\
2238html{background:var(--bg)}\
2239body{color:var(--fg);font:13px/1.5 system-ui,-apple-system,Segoe UI,sans-serif;margin:0}\
2240header{align-items:baseline;background:var(--bg);border-bottom:1px solid var(--line);\
2241display:flex;gap:6px;padding:7px 12px;position:sticky;top:0;z-index:1}\
2242header b{font-size:12px;font-weight:600;letter-spacing:.04em}\
2243header span{color:var(--dim);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;\
2244font-size:11px;overflow-wrap:anywhere}\
2245#tree{padding:4px 0 40px}\
2246ul{list-style:none;margin:0;padding:0}\
2247li ul{border-left:1px solid var(--line);margin-left:15px}\
2248li>ul{display:none}\
2249li.open>ul{display:block}\
2250.row{align-items:center;color:inherit;display:grid;gap:6px;\
2251grid-template-columns:14px 14px 1fr auto auto;line-height:22px;padding-right:12px;\
2252text-decoration:none;white-space:nowrap}\
2253.row:hover{background:var(--hover)}\
2254.row.here{background:var(--sel)}\
2255.row.here .size,.row.here .when{color:var(--dim)}\
2256.row:focus-visible{outline:1px solid var(--accent);outline-offset:-1px}\
2257.tw{color:var(--dim);font-size:11px;line-height:22px;text-align:center;\
2258transition:transform .1s linear}\
2259li.open>.row .tw{transform:rotate(90deg)}\
2260.ico{border-radius:2px;height:9px;justify-self:center;width:9px}\
2261.dir>.ico{background:var(--dim);border-radius:1px 3px 3px 3px}\
2262.site>.ico{background:var(--accent);border-radius:1px 3px 3px 3px}\
2263.site>.name{color:var(--accent)}\
2264.k-page>.ico{background:var(--k-page)}\
2265.k-page>.name{color:var(--k-page)}\
2266.k-doc>.ico{background:var(--k-doc)}\
2267.k-data>.ico{background:var(--k-data)}\
2268.k-code>.ico{background:var(--k-code)}\
2269.k-media>.ico{background:var(--k-media)}\
2270.k-plain>.ico{background:var(--k-plain)}\
2271.name{overflow:hidden;text-overflow:ellipsis}\
2272.size,.when{color:var(--faint);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;\
2273font-size:11px;font-variant-numeric:tabular-nums}\
2274.size{text-align:right}\
2275.row.busy .tw{opacity:.4}\
2276.row.failed .when{color:var(--k-page)}\
2277.empty{color:var(--faint);padding:10px 16px}\
2278@media(max-width:620px){.when{display:none}}";
2279
2280const LISTING_JS: &str = "\
2290const tree=document.getElementById('tree');\
2291tree.addEventListener('click',async e=>{\
2292const row=e.target.closest('a.row');\
2293if(!row||row.dataset.dir!=='1')return;\
2294e.preventDefault();\
2295const li=row.parentElement;\
2296if(li.querySelector(':scope>ul')){li.classList.toggle('open');mark(row);return;}\
2297row.classList.add('busy');\
2298try{\
2299const res=await fetch(row.getAttribute('href')+'?ls');\
2300if(!res.ok)throw new Error(res.status);\
2301li.insertAdjacentHTML('beforeend',await res.text());\
2302li.classList.add('open');mark(row);\
2303}catch(err){row.classList.add('failed');\
2304row.querySelector('.when').textContent='could not be listed: '+err.message;}\
2305finally{row.classList.remove('busy');}\
2306});\
2307function mark(row){\
2308for(const other of tree.querySelectorAll('a.row.here'))other.classList.remove('here');\
2309row.classList.add('here');\
2310history.replaceState(null,'',row.getAttribute('href'));\
2311document.querySelector('header span').textContent=\
2312decodeURIComponent(new URL(row.href).pathname);\
2313}";
2314
2315fn render_level(out: &mut String, path: &str, rows: &[Row], open: &[(String, Vec<Row>)]) {
2321 out.push_str("<ul>");
2322 for row in rows {
2323 let here = format!("{path}/{}", row.name);
2324 let deeper = open.first().filter(|(next, _)| *next == here);
2325
2326 out.push_str(if deeper.is_some() {
2327 "<li class=\"open\">"
2328 } else {
2329 "<li>"
2330 });
2331 out.push_str("<a class=\"row ");
2332 out.push_str(match (row.dir, row.site) {
2333 (true, true) => "site",
2336 (true, false) => "dir",
2337 (false, _) => row.kind,
2338 });
2339 if deeper.is_some() && open.len() == 1 {
2342 out.push_str(" here");
2343 }
2344 out.push_str("\" href=\"");
2345 out.push_str(path);
2346 out.push('/');
2347 out.push_str(&url_escape(&row.name));
2348 if row.dir {
2349 out.push('/');
2350 }
2351 out.push_str(if row.dir {
2353 "\" data-dir=\"1\"><span class=\"tw\">\u{25b8}</span>"
2354 } else {
2355 "\"><span class=\"tw\"></span>"
2356 });
2357 out.push_str("<span class=\"ico\"></span><span class=\"name\">");
2358 out.push_str(&escape(&row.name));
2359 out.push_str("</span><span class=\"size\">");
2360 out.push_str(row.size.as_deref().unwrap_or(""));
2361 out.push_str("</span><span class=\"when\">");
2362 out.push_str(row.modified.as_deref().unwrap_or(""));
2363 out.push_str("</span></a>");
2364
2365 if let Some((next, rows)) = deeper {
2366 render_level(out, next, rows, &open[1..]);
2367 }
2368 out.push_str("</li>");
2369 }
2370 out.push_str("</ul>");
2371}
2372
2373fn autoindex(alias: &str, rel: &str, levels: &[(String, Vec<Row>)], theme: &str) -> String {
2379 let shown = if rel.is_empty() { "/" } else { rel };
2380 let mut s = String::from("<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">");
2381 s.push_str("<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>");
2382 s.push_str(&escape(&format!("{shown} \u{b7} {alias}")));
2383 s.push_str("</title><style>");
2384 s.push_str(&theme::css_for(theme));
2386 s.push_str(LISTING_CSS);
2387 s.push_str("</style></head><body><header><b>");
2388 s.push_str(&escape(alias));
2389 s.push_str("</b><span>");
2390 s.push_str(&escape(shown));
2391 s.push_str("</span></header><div id=\"tree\">");
2392
2393 match levels.split_first() {
2394 Some(((path, rows), rest)) if !rows.is_empty() => render_level(&mut s, path, rows, rest),
2395 _ => s.push_str("<p class=\"empty\">This directory is empty.</p>"),
2398 }
2399
2400 s.push_str("</div><script>");
2401 s.push_str(LISTING_JS);
2402 s.push_str("</script></body></html>");
2403 s
2404}
2405
2406fn escape(s: &str) -> String {
2409 s.replace('&', "&")
2410 .replace('<', "<")
2411 .replace('>', ">")
2412 .replace('"', """)
2413}
2414
2415fn url_escape(s: &str) -> String {
2418 let mut out = String::with_capacity(s.len());
2419 for b in s.bytes() {
2420 match b {
2421 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
2422 out.push(b as char);
2423 }
2424 _ => out.push_str(&format!("%{b:02X}")),
2425 }
2426 }
2427 out
2428}
2429
2430#[cfg(test)]
2431mod tests {
2432 use super::*;
2433 use crate::sftp::wire::Attrs;
2434 use crate::testing::{FakeRemote, dir_attrs, file_attrs, symlink_attrs};
2435 use http_body_util::{BodyExt, Empty};
2436
2437 const TEST_TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
2438
2439 async fn body_of(res: Response<Full<Bytes>>) -> Bytes {
2440 res.into_body()
2441 .collect()
2442 .await
2443 .expect("a Full body always collects")
2444 .to_bytes()
2445 }
2446
2447 fn loopback(path: &str, token: Option<&str>) -> Request<Empty<Bytes>> {
2449 let mut b = Request::builder().uri(path).header(HOST, "127.0.0.1:7391");
2450 if let Some(t) = token {
2451 b = b.header(control::TOKEN_HEADER, t);
2452 }
2453 b.body(Empty::<Bytes>::new()).expect("request builds")
2454 }
2455
2456 fn from_site(path: &str, site: Option<&str>) -> Request<Empty<Bytes>> {
2459 let mut b = Request::builder().uri(path).header(HOST, "127.0.0.1:7391");
2460 if let Some(site) = site {
2461 b = b.header(control::FETCH_SITE_HEADER, site);
2462 }
2463 b.body(Empty::<Bytes>::new()).expect("request builds")
2464 }
2465
2466 fn control_post(path: &str, token: Option<&str>, body: &str) -> Request<Full<Bytes>> {
2467 let mut b = Request::builder()
2468 .method(Method::POST)
2469 .uri(path)
2470 .header(HOST, "127.0.0.1:7391");
2471 if let Some(t) = token {
2472 b = b.header(control::TOKEN_HEADER, t);
2473 }
2474 b.body(Full::new(Bytes::from(body.to_string())))
2475 .expect("request builds")
2476 }
2477
2478 fn ranged(path: &str, range: &str) -> Request<Empty<Bytes>> {
2479 Request::builder()
2480 .uri(format!("http://docs.ssh-browser{path}"))
2481 .header(HOST, "docs.ssh-browser")
2482 .header(RANGE, range)
2483 .body(Empty::new())
2484 .expect("request builds")
2485 }
2486
2487 async fn origin_with(remote: FakeRemote) -> Origin {
2490 origin_with_cache(remote, Cache::default()).await
2491 }
2492
2493 async fn origin_with_cache(remote: FakeRemote, cache: Cache) -> Origin {
2494 let fs = remote.spawn().await;
2495 let mut sessions = HashMap::new();
2496 sessions.insert(
2497 "docs".to_string(),
2498 Arc::new(Session {
2499 host: "nowhere".to_string(),
2500 base: "/srv".to_string(),
2501 fs,
2502 }),
2503 );
2504 Origin {
2505 suffix: "ssh-browser".to_string(),
2506 port: 7391,
2507 sessions: RwLock::new(sessions),
2508 cache,
2509 theme: RwLock::new(theme::DEFAULT.to_string()),
2510 token: Token::from_hex(TEST_TOKEN),
2511 reachable: RwLock::new(reachable::Set::default()),
2514 }
2515 }
2516
2517 fn get(path: &str, if_none_match: Option<&str>) -> Request<Empty<Bytes>> {
2518 let mut b = Request::builder()
2519 .uri(format!("http://docs.ssh-browser{path}"))
2520 .header(HOST, "docs.ssh-browser");
2521 if let Some(tag) = if_none_match {
2522 b = b.header(IF_NONE_MATCH, tag);
2523 }
2524 b.body(Empty::new()).expect("request builds")
2525 }
2526
2527 async fn trips(origin: &Origin) -> u64 {
2528 origin
2529 .sessions
2530 .read()
2531 .await
2532 .values()
2533 .map(|s| s.fs.round_trips())
2534 .sum()
2535 }
2536
2537 fn one_page() -> FakeRemote {
2538 FakeRemote::new()
2539 .dir("/srv", vec![("a.html", file_attrs(5, 100))])
2540 .file("/srv/a.html", b"hello")
2541 }
2542
2543 fn page_with_subresources(n: usize) -> FakeRemote {
2546 let mut html = String::from(
2547 "<!doctype html><html><head><link rel=\"stylesheet\" href=\"assets/style.css\"><script src=\"assets/app.js\"></script></head><body>",
2548 );
2549 for i in 0..n {
2550 html.push_str(&format!("<img src=\"assets/{i}.png\">"));
2551 }
2552 html.push_str("</body></html>");
2553
2554 let mut assets = vec!["style.css".to_string(), "app.js".to_string()];
2555 assets.extend((0..n).map(|i| format!("{i}.png")));
2556
2557 let mut remote = FakeRemote::new()
2558 .dir(
2559 "/srv",
2560 vec![
2561 ("index.html", file_attrs(html.len() as u64, 100)),
2562 ("assets", dir_attrs()),
2563 ],
2564 )
2565 .dir(
2566 "/srv/assets",
2567 assets
2568 .iter()
2569 .map(|name| (name.as_str(), file_attrs(3, 1)))
2570 .collect(),
2571 )
2572 .file("/srv/index.html", html.as_bytes());
2573 for name in &assets {
2574 remote = remote.file(&format!("/srv/assets/{name}"), b"xxx");
2575 }
2576 remote
2577 }
2578
2579 #[tokio::test]
2586 async fn a_pages_subresources_are_already_held_when_the_browser_asks_for_them() {
2587 const N: usize = 40;
2588 let origin = origin_with(page_with_subresources(N)).await;
2589
2590 let res = origin.handle(get("/index.html", None)).await;
2591 assert_eq!(res.status(), StatusCode::OK);
2592
2593 let before = trips(&origin).await;
2594 for i in 0..N {
2595 let path = format!("/assets/{i}.png");
2596 let res = origin.handle(get(&path, None)).await;
2597 assert_eq!(res.status(), StatusCode::OK, "{path}");
2598 assert_eq!(&body_of(res).await[..], b"xxx", "{path}");
2599 }
2600 for name in ["style.css", "app.js"] {
2601 let res = origin.handle(get(&format!("/assets/{name}"), None)).await;
2602 assert_eq!(res.status(), StatusCode::OK, "{name}");
2603 }
2604
2605 assert_eq!(
2606 trips(&origin).await - before,
2607 0,
2608 "reading the page's own references is what makes these free"
2609 );
2610 }
2611
2612 #[tokio::test]
2615 async fn serving_a_page_costs_the_same_however_many_subresources_it_has() {
2616 async fn cost(n: usize) -> u64 {
2617 let origin = origin_with(page_with_subresources(n)).await;
2618 let before = trips(&origin).await;
2619 let res = origin.handle(get("/index.html", None)).await;
2620 assert_eq!(res.status(), StatusCode::OK);
2621 trips(&origin).await - before
2622 }
2623 assert_eq!(cost(4).await, cost(40).await);
2624 }
2625
2626 fn page_referring_to(refs: &[&str], extra: Vec<(&'static str, Attrs)>) -> FakeRemote {
2630 let mut html = String::from("<!doctype html><html><body>");
2631 for r in refs {
2632 html.push_str(&format!("<img src=\"{r}\">"));
2633 }
2634 html.push_str("</body></html>");
2635
2636 let mut entries = vec![("index.html", file_attrs(html.len() as u64, 100))];
2637 entries.extend(extra);
2638 FakeRemote::new()
2639 .dir("/srv", entries)
2640 .file("/srv/index.html", html.as_bytes())
2641 }
2642
2643 async fn cost_of_serving(refs: &[&str], extra: Vec<(&'static str, Attrs)>) -> u64 {
2644 let origin = origin_with(page_referring_to(refs, extra)).await;
2645 let before = trips(&origin).await;
2646 let res = origin.handle(get("/index.html", None)).await;
2647 assert_eq!(res.status(), StatusCode::OK);
2648 trips(&origin).await - before
2649 }
2650
2651 #[tokio::test]
2654 async fn a_page_cannot_prefetch_its_way_out_of_the_alias_base() {
2655 let baseline = cost_of_serving(&[], vec![]).await;
2656 assert_eq!(
2657 cost_of_serving(&["../../../etc/passwd", "/../../etc/shadow"], vec![]).await,
2658 baseline,
2659 "an escaping reference is gone before anything is listed or read"
2660 );
2661 }
2662
2663 #[tokio::test]
2666 async fn a_page_cannot_prefetch_through_a_symlink() {
2667 let link = || vec![("link", symlink_attrs())];
2668 let baseline = cost_of_serving(&[], link()).await;
2669 assert_eq!(
2670 cost_of_serving(&["link/inside.png"], link()).await,
2671 baseline,
2672 "the symlink is known from the listing the page itself needed"
2673 );
2674
2675 let origin = origin_with(page_referring_to(&["link/inside.png"], link())).await;
2678 assert_eq!(
2679 origin.handle(get("/index.html", None)).await.status(),
2680 StatusCode::OK
2681 );
2682 assert_eq!(
2683 origin.handle(get("/link/inside.png", None)).await.status(),
2684 StatusCode::FORBIDDEN
2685 );
2686 }
2687
2688 #[tokio::test]
2700 async fn a_page_cannot_get_a_symlink_below_an_unlisted_directory_opened() {
2701 let html = "<!doctype html><html><body><img src=\"assets/link/secret.txt\"></body></html>";
2702 let origin = origin_with(
2703 FakeRemote::new()
2704 .dir(
2705 "/srv",
2706 vec![
2707 ("index.html", file_attrs(html.len() as u64, 100)),
2708 ("assets", dir_attrs()),
2709 ],
2710 )
2711 .dir("/srv/assets", vec![("link", symlink_attrs())])
2712 .dir("/srv/assets/link", vec![("secret.txt", file_attrs(9, 1))])
2714 .file("/srv/index.html", html.as_bytes())
2715 .file("/srv/assets/link/secret.txt", b"elsewhere"),
2716 )
2717 .await;
2718
2719 assert_eq!(
2720 origin.handle(get("/index.html", None)).await.status(),
2721 StatusCode::OK
2722 );
2723 assert!(
2724 !origin.cache.has_listing("/srv/assets/link"),
2725 "the daemon listed the directory a symlink points at"
2726 );
2727
2728 assert_eq!(
2731 origin
2732 .handle(get("/assets/link/secret.txt", None))
2733 .await
2734 .status(),
2735 StatusCode::FORBIDDEN
2736 );
2737 }
2738
2739 #[tokio::test]
2743 async fn a_reference_in_a_real_subdirectory_is_still_prefetched() {
2744 let html = "<!doctype html><html><body><img src=\"assets/x.png\"></body></html>";
2745 let origin = origin_with(
2746 FakeRemote::new()
2747 .dir(
2748 "/srv",
2749 vec![
2750 ("index.html", file_attrs(html.len() as u64, 100)),
2751 ("assets", dir_attrs()),
2752 ],
2753 )
2754 .dir("/srv/assets", vec![("x.png", file_attrs(3, 1))])
2755 .file("/srv/index.html", html.as_bytes())
2756 .file("/srv/assets/x.png", b"xxx"),
2757 )
2758 .await;
2759
2760 assert_eq!(
2761 origin.handle(get("/index.html", None)).await.status(),
2762 StatusCode::OK
2763 );
2764 let before = trips(&origin).await;
2765 let res = origin.handle(get("/assets/x.png", None)).await;
2766 assert_eq!(res.status(), StatusCode::OK);
2767 assert_eq!(&body_of(res).await[..], b"xxx");
2768 assert_eq!(
2769 trips(&origin).await - before,
2770 0,
2771 "a subdirectory one level down must still be warmed"
2772 );
2773 }
2774
2775 #[tokio::test]
2782 async fn a_large_subresource_costs_what_a_small_one_costs() {
2783 async fn cost(bytes: usize) -> u64 {
2784 let html = "<!doctype html><html><body><img src=\"assets/big.bin\"></body></html>";
2785 let origin = origin_with(
2786 FakeRemote::new()
2787 .dir(
2788 "/srv",
2789 vec![
2790 ("index.html", file_attrs(html.len() as u64, 100)),
2791 ("assets", dir_attrs()),
2792 ],
2793 )
2794 .dir(
2795 "/srv/assets",
2796 vec![("big.bin", file_attrs(bytes as u64, 1))],
2797 )
2798 .file("/srv/index.html", html.as_bytes())
2799 .file("/srv/assets/big.bin", &vec![b'x'; bytes]),
2800 )
2801 .await;
2802
2803 let before = trips(&origin).await;
2804 assert_eq!(
2805 origin.handle(get("/index.html", None)).await.status(),
2806 StatusCode::OK
2807 );
2808 let spent = trips(&origin).await - before;
2809
2810 let at = trips(&origin).await;
2813 let res = origin.handle(get("/assets/big.bin", None)).await;
2814 assert_eq!(res.status(), StatusCode::OK);
2815 assert_eq!(body_of(res).await.len(), bytes);
2816 assert_eq!(
2817 trips(&origin).await - at,
2818 0,
2819 "{bytes} bytes should have been held"
2820 );
2821
2822 spent
2823 }
2824
2825 assert_eq!(cost(1024).await, cost(200 * 1024).await);
2827 }
2828
2829 #[tokio::test]
2841 async fn a_large_file_asked_for_directly_costs_what_a_small_one_costs() {
2842 async fn cost(bytes: usize) -> u64 {
2843 let origin = origin_with(
2844 FakeRemote::new()
2845 .dir("/srv", vec![("big.bin", file_attrs(bytes as u64, 1))])
2846 .file("/srv/big.bin", &vec![b'x'; bytes]),
2847 )
2848 .await;
2849
2850 let before = trips(&origin).await;
2851 let res = origin.handle(get("/big.bin", None)).await;
2852 assert_eq!(res.status(), StatusCode::OK);
2853 assert_eq!(body_of(res).await.len(), bytes);
2856 trips(&origin).await - before
2857 }
2858
2859 assert_eq!(cost(1024).await, cost(500 * 1024).await);
2860 }
2861
2862 #[tokio::test]
2872 async fn a_subresource_the_listing_calls_empty_is_not_prefetched() {
2873 let html = "<!doctype html><html><body><img src=\"assets/x.png\"></body></html>";
2874 let sizeless = Attrs {
2875 permissions: Some(0o100644),
2876 mtime: Some(1),
2877 ..Attrs::default()
2878 };
2879 let origin = origin_with(
2880 FakeRemote::new()
2881 .dir(
2882 "/srv",
2883 vec![
2884 ("index.html", file_attrs(html.len() as u64, 100)),
2885 ("assets", dir_attrs()),
2886 ],
2887 )
2888 .dir("/srv/assets", vec![("x.png", sizeless)])
2889 .file("/srv/index.html", html.as_bytes())
2890 .file("/srv/assets/x.png", b"xxx"),
2891 )
2892 .await;
2893
2894 assert_eq!(
2895 origin.handle(get("/index.html", None)).await.status(),
2896 StatusCode::OK
2897 );
2898 let res = origin.handle(get("/assets/x.png", None)).await;
2899 assert_eq!(res.status(), StatusCode::OK);
2900 assert_eq!(
2901 &body_of(res).await[..],
2902 b"xxx",
2903 "the real request must still serve the whole file"
2904 );
2905 }
2906
2907 #[tokio::test]
2909 async fn an_oversized_subresource_is_not_prefetched() {
2910 async fn cost(size: u64) -> u64 {
2911 let html =
2912 "<!doctype html><html><body><video src=\"assets/film.mp4\"></video></body></html>";
2913 let origin = origin_with(
2914 FakeRemote::new()
2915 .dir(
2916 "/srv",
2917 vec![
2918 ("index.html", file_attrs(html.len() as u64, 100)),
2919 ("assets", dir_attrs()),
2920 ],
2921 )
2922 .dir("/srv/assets", vec![("film.mp4", file_attrs(size, 1))])
2923 .file("/srv/index.html", html.as_bytes())
2924 .file("/srv/assets/film.mp4", b"xxx"),
2925 )
2926 .await;
2927 let before = trips(&origin).await;
2928 assert_eq!(
2929 origin.handle(get("/index.html", None)).await.status(),
2930 StatusCode::OK
2931 );
2932 trips(&origin).await - before
2933 }
2934
2935 let read_it = cost(3).await;
2938 let skipped = cost(CACHE_WHOLE_MAX + 1).await;
2939 assert!(
2940 skipped < read_it,
2941 "an oversized subresource cost {skipped} against {read_it} for a small one"
2942 );
2943 }
2944
2945 #[tokio::test]
2957 async fn the_port_is_taken_before_any_host_is_connected() {
2958 let held = TcpListener::bind(("127.0.0.1", 0))
2959 .await
2960 .expect("a free port");
2961 let port = held.local_addr().expect("its address").port();
2962
2963 const NOWHERE: &str = "a-host-that-cannot-resolve.invalid";
2964 let result = Origin::bind(
2965 vec![Alias::new("docs", NOWHERE, Some("/srv")).expect("a valid alias")],
2966 reachable::Set::default(),
2967 "ssh-browser".to_string(),
2968 port,
2969 Token::from_hex(TEST_TOKEN),
2970 theme::DEFAULT.to_string(),
2971 )
2972 .await;
2973
2974 let Err(e) = result else {
2975 panic!("binding a port that is already held must fail");
2976 };
2977 let text = format!("{e:#}");
2978 assert!(
2979 text.contains(&format!("bind 127.0.0.1:{port}")),
2980 "the error should name the port, got: {text}"
2981 );
2982 assert!(
2983 !text.contains(NOWHERE),
2984 "the ssh host was reached before the port was taken: {text}"
2985 );
2986 }
2987
2988 #[tokio::test]
2991 async fn a_dot_name_is_never_served() {
2992 let origin = origin_with(
2993 FakeRemote::new()
2994 .dir(
2995 "/srv",
2996 vec![
2997 ("Vault", dir_attrs()),
2998 (".ssh", dir_attrs()),
2999 (".netrc", file_attrs(9, 1)),
3000 ],
3001 )
3002 .dir("/srv/.ssh", vec![("id_ed25519", file_attrs(9, 1))])
3003 .dir("/srv/Vault", vec![(".git", dir_attrs())])
3004 .dir("/srv/Vault/.git", vec![("config", file_attrs(9, 1))])
3005 .file("/srv/.ssh/id_ed25519", b"a-secret-")
3006 .file("/srv/.netrc", b"a-secret-")
3007 .file("/srv/Vault/.git/config", b"a-secret-"),
3008 )
3009 .await;
3010
3011 for path in [
3012 "/.ssh/id_ed25519",
3013 "/.netrc",
3014 "/Vault/.git/config",
3016 "/.ssh/",
3018 ] {
3019 assert_eq!(
3020 origin.handle(get(path, None)).await.status(),
3021 StatusCode::FORBIDDEN,
3022 "{path}"
3023 );
3024 }
3025 }
3026
3027 #[tokio::test]
3030 async fn a_listing_does_not_mention_dot_names() {
3031 let origin = origin_with(FakeRemote::new().dir(
3032 "/srv",
3033 vec![
3034 ("Vault", dir_attrs()),
3035 (".ssh", dir_attrs()),
3036 (".obsidian", dir_attrs()),
3037 ],
3038 ))
3039 .await;
3040
3041 let body = body_of(origin.handle(get("/", None)).await).await;
3042 let listing = String::from_utf8_lossy(&body);
3043 assert!(listing.contains("Vault"), "the ordinary entry is listed");
3044 assert!(!listing.contains(".ssh"), "got: {listing}");
3045 assert!(!listing.contains(".obsidian"), "got: {listing}");
3046 }
3047
3048 #[tokio::test]
3051 async fn a_page_cannot_prefetch_a_dot_name() {
3052 let html = "<!doctype html><html><body><img src=\".ssh/id_ed25519\"></body></html>";
3053 let origin = origin_with(
3054 FakeRemote::new()
3055 .dir(
3056 "/srv",
3057 vec![
3058 ("index.html", file_attrs(html.len() as u64, 100)),
3059 (".ssh", dir_attrs()),
3060 ],
3061 )
3062 .dir("/srv/.ssh", vec![("id_ed25519", file_attrs(9, 1))])
3063 .file("/srv/index.html", html.as_bytes())
3064 .file("/srv/.ssh/id_ed25519", b"a-secret-"),
3065 )
3066 .await;
3067
3068 assert_eq!(
3069 origin.handle(get("/index.html", None)).await.status(),
3070 StatusCode::OK
3071 );
3072 assert!(
3073 !origin.cache.has_listing("/srv/.ssh"),
3074 "the page got the daemon to list a directory it will not serve"
3075 );
3076 assert_eq!(
3077 origin.handle(get("/.ssh/id_ed25519", None)).await.status(),
3078 StatusCode::FORBIDDEN
3079 );
3080 }
3081
3082 fn deep_tree() -> FakeRemote {
3084 FakeRemote::new()
3085 .dir("/srv", vec![("a", dir_attrs())])
3086 .dir("/srv/a", vec![("b", dir_attrs())])
3087 .dir("/srv/a/b", vec![("c", dir_attrs())])
3088 .dir("/srv/a/b/c", vec![("d.html", file_attrs(5, 100))])
3089 .file("/srv/a/b/c/d.html", b"deep!")
3090 }
3091
3092 fn entry(name: &str, dir: bool) -> Entry {
3093 Entry {
3094 name: name.to_string(),
3095 attrs: Attrs {
3096 permissions: Some(if dir { 0o040755 } else { 0o100644 }),
3097 ..Attrs::default()
3098 },
3099 }
3100 }
3101
3102 fn listing(alias: &str, rel: &str, entries: &[Entry]) -> String {
3105 let levels = vec![(rel.to_string(), rows_of(entries, &HashSet::new()))];
3106 autoindex(alias, rel, &levels, theme::DEFAULT)
3107 }
3108
3109 #[test]
3110 fn a_hostile_filename_cannot_inject_script_into_our_origin() {
3111 let page = listing("docs", "", &[entry("<script>alert(1)</script>", false)]);
3112 assert!(!page.contains("<script>alert"));
3113 assert!(page.contains("<script>"));
3114 }
3115
3116 #[test]
3119 fn directories_come_first_and_pages_lead_the_files() {
3120 let page = listing(
3121 "docs",
3122 "",
3123 &[
3124 entry("b.txt", false),
3125 entry("z-dir", true),
3126 entry("a.txt", false),
3127 entry("report.html", false),
3128 ],
3129 );
3130 let dir = page.find("z-dir").expect("dir listed");
3131 let html = page.find("report.html").expect("page listed");
3132 let a = page.find("a.txt").expect("a listed");
3133 let b = page.find("b.txt").expect("b listed");
3134 assert!(
3135 dir < html,
3136 "directories come first, whatever they are called"
3137 );
3138 assert!(html < a, "then the pages, ahead of the other files");
3139 assert!(a < b, "and the rest by name");
3140 assert!(!page.contains("<h2"), "{page}");
3142 }
3143
3144 #[test]
3147 fn only_html_counts_as_a_page() {
3148 let page = listing(
3149 "docs",
3150 "",
3151 &[
3152 entry("a.htm", false),
3153 entry("b.html.bak", false),
3154 entry("c.xhtml", false),
3155 ],
3156 );
3157 let htm = page.find("a.htm").expect("htm listed");
3158 let bak = page.find("b.html.bak").expect("bak listed");
3159 let xhtml = page.find("c.xhtml").expect("xhtml listed");
3160 assert!(htm < bak && htm < xhtml, "only the .htm leads: {page}");
3161 assert!(
3164 page.contains("class=\"row k-page\" href=\"/a.htm\""),
3165 "{page}"
3166 );
3167 }
3168
3169 #[test]
3170 fn hrefs_are_url_escaped() {
3171 let page = listing("docs", "", &[entry("a b#c.html", false)]);
3172 assert!(page.contains("href=\"/a%20b%23c.html\""));
3173 }
3174
3175 #[test]
3178 fn the_header_names_the_alias_and_where_you_are() {
3179 let page = listing("panza", "/Vault/infra", &[]);
3180 assert!(page.contains("<b>panza</b>"), "{page}");
3181 assert!(page.contains("<span>/Vault/infra</span>"), "{page}");
3182 }
3183
3184 #[tokio::test]
3191 async fn the_whole_path_is_expanded_and_the_deepest_is_selected() {
3192 let origin = origin_with(
3193 FakeRemote::new()
3194 .dir("/srv", vec![("a", dir_attrs()), ("elsewhere", dir_attrs())])
3195 .dir("/srv/a", vec![("b", dir_attrs()), ("sibling", dir_attrs())])
3196 .dir("/srv/a/b", vec![("leaf.txt", file_attrs(3, 1))])
3197 .dir("/srv/elsewhere", vec![])
3198 .dir("/srv/a/sibling", vec![]),
3199 )
3200 .await;
3201
3202 let body = String::from_utf8(
3203 body_of(origin.handle(get("/a/b/", None)).await)
3204 .await
3205 .to_vec(),
3206 )
3207 .expect("utf-8");
3208
3209 assert!(body.contains("<li class=\"open\">"), "{body}");
3211 assert!(body.contains("href=\"/a/\""), "{body}");
3212 assert!(body.contains("row dir here\" href=\"/a/b/\""), "{body}");
3214 assert!(body.contains("leaf.txt"), "{body}");
3216 assert!(body.contains("elsewhere"), "{body}");
3219 assert!(body.contains("sibling"), "{body}");
3220 }
3221
3222 #[tokio::test]
3226 async fn the_tree_costs_what_one_directory_cost() {
3227 let deep = origin_with(deep_tree()).await;
3228 let before = trips(&deep).await;
3229 assert_eq!(
3230 deep.handle(get("/a/b/c/", None)).await.status(),
3231 StatusCode::OK
3232 );
3233 let four = trips(&deep).await - before;
3234
3235 let shallow = origin_with(one_page()).await;
3236 let before = trips(&shallow).await;
3237 assert_eq!(
3238 shallow.handle(get("/", None)).await.status(),
3239 StatusCode::OK
3240 );
3241 let one = trips(&shallow).await - before;
3242
3243 assert!(
3246 four <= one + 2,
3247 "a tree four deep cost {four} round trips against {one} for one directory"
3248 );
3249 }
3250
3251 #[tokio::test]
3254 async fn asking_for_one_level_answers_with_its_rows() {
3255 let origin = origin_with(
3256 FakeRemote::new()
3257 .dir("/srv", vec![("sub", dir_attrs())])
3258 .dir("/srv/sub", vec![("inner.md", file_attrs(4, 1))]),
3259 )
3260 .await;
3261
3262 let req = Request::builder()
3263 .uri("http://docs.ssh-browser/sub/?ls")
3264 .header(HOST, "docs.ssh-browser")
3265 .body(Empty::<Bytes>::new())
3266 .expect("request builds");
3267 let res = origin.handle(req).await;
3268 assert_eq!(res.status(), StatusCode::OK);
3269
3270 let body = String::from_utf8(body_of(res).await.to_vec()).expect("utf-8");
3271 assert!(body.starts_with("<ul>"), "{body}");
3273 assert!(!body.contains("<html"), "{body}");
3274 assert!(body.contains("href=\"/sub/inner.md\""), "{body}");
3276 }
3277
3278 #[tokio::test]
3281 async fn asking_for_one_level_does_not_mention_dot_names() {
3282 let origin = origin_with(
3283 FakeRemote::new()
3284 .dir("/srv", vec![("sub", dir_attrs())])
3285 .dir(
3286 "/srv/sub",
3287 vec![("shown.md", file_attrs(4, 1)), (".hidden", dir_attrs())],
3288 ),
3289 )
3290 .await;
3291
3292 let req = Request::builder()
3293 .uri("http://docs.ssh-browser/sub/?ls")
3294 .header(HOST, "docs.ssh-browser")
3295 .body(Empty::<Bytes>::new())
3296 .expect("request builds");
3297 let body =
3298 String::from_utf8(body_of(origin.handle(req).await).await.to_vec()).expect("utf-8");
3299 assert!(body.contains("shown.md"), "{body}");
3300 assert!(!body.contains(".hidden"), "{body}");
3301 }
3302
3303 #[test]
3304 fn sizes_read_the_way_a_file_manager_shows_them() {
3305 assert_eq!(human_size(0), "0 B");
3306 assert_eq!(human_size(999), "999 B");
3307 assert_eq!(human_size(1024), "1.0 KiB");
3308 assert_eq!(human_size(1536), "1.5 KiB");
3309 assert_eq!(human_size(10 * 1024 * 1024), "10 MiB");
3311 assert_eq!(human_size(9_961_472), "9.5 MiB");
3312 assert_eq!(human_size(3 * 1024 * 1024 * 1024), "3.0 GiB");
3313 }
3314
3315 #[test]
3318 fn timestamps_are_the_utc_civil_date() {
3319 assert_eq!(utc_stamp(0), "1970-01-01 00:00");
3320 assert_eq!(utc_stamp(86_399), "1970-01-01 23:59");
3321 assert_eq!(utc_stamp(86_400), "1970-01-02 00:00");
3322 assert_eq!(utc_stamp(951_782_400), "2000-02-29 00:00");
3324 assert_eq!(utc_stamp(4_107_456_000), "2100-02-28 00:00");
3328 assert_eq!(utc_stamp(4_107_542_400), "2100-03-01 00:00");
3329 assert_eq!(utc_stamp(1_757_745_840), "2025-09-13 06:44");
3330 }
3331
3332 #[test]
3335 fn an_empty_directory_says_it_is_empty() {
3336 let page = listing("docs", "/nothing", &[]);
3337 assert!(page.contains("This directory is empty"), "{page}");
3338 }
3339
3340 #[test]
3341 fn the_component_chain_walks_from_the_base_down() {
3342 assert_eq!(
3343 components("/srv", "/srv/a/b/c.html"),
3344 vec![
3345 ("/srv".to_string(), "a".to_string()),
3346 ("/srv/a".to_string(), "b".to_string()),
3347 ("/srv/a/b".to_string(), "c.html".to_string()),
3348 ]
3349 );
3350 assert_eq!(
3351 components("/srv", "/srv/index.html"),
3352 vec![("/srv".to_string(), "index.html".to_string())]
3353 );
3354 assert_eq!(
3356 components("/srv/", "/srv/a.html"),
3357 vec![("/srv".to_string(), "a.html".to_string())]
3358 );
3359 assert!(components("/srv", "/srv").is_empty());
3361 }
3362
3363 #[tokio::test]
3366 async fn a_revisit_costs_no_remote_round_trips() {
3367 let origin = origin_with(one_page()).await;
3368
3369 let first = origin.handle(get("/a.html", None)).await;
3370 assert_eq!(first.status(), StatusCode::OK);
3371 let after_first = trips(&origin).await;
3372 assert!(after_first > 0, "the first request has to fetch something");
3373
3374 let second = origin.handle(get("/a.html", None)).await;
3375 assert_eq!(second.status(), StatusCode::OK);
3376 assert_eq!(
3377 trips(&origin).await,
3378 after_first,
3379 "a revisit must be answered entirely from cache"
3380 );
3381 }
3382
3383 #[tokio::test]
3386 async fn a_conditional_get_is_answered_without_the_remote() {
3387 let origin = origin_with(one_page()).await;
3388
3389 let first = origin.handle(get("/a.html", None)).await;
3390 let tag = first
3391 .headers()
3392 .get(ETAG)
3393 .expect("a validator is offered")
3394 .to_str()
3395 .expect("ascii")
3396 .to_string();
3397 let after_first = trips(&origin).await;
3398
3399 let second = origin.handle(get("/a.html", Some(&tag))).await;
3400 assert_eq!(second.status(), StatusCode::NOT_MODIFIED);
3401 assert_eq!(
3402 trips(&origin).await,
3403 after_first,
3404 "a 304 must not touch the remote"
3405 );
3406 }
3407
3408 #[tokio::test]
3410 async fn a_missing_file_is_a_404_from_the_cached_listing() {
3411 let origin = origin_with(one_page()).await;
3412
3413 origin.handle(get("/a.html", None)).await;
3415 let warm = trips(&origin).await;
3416
3417 let missing = origin.handle(get("/nope.html", None)).await;
3418 assert_eq!(missing.status(), StatusCode::NOT_FOUND);
3419 assert_eq!(
3420 trips(&origin).await,
3421 warm,
3422 "a 404 for a listed-but-absent name must cost nothing"
3423 );
3424 }
3425
3426 #[tokio::test]
3429 async fn a_symlink_is_refused() {
3430 let origin = origin_with(
3431 FakeRemote::new()
3432 .dir("/srv", vec![("link.html", symlink_attrs())])
3433 .file("/srv/link.html", b"whatever the target is"),
3434 )
3435 .await;
3436
3437 let res = origin.handle(get("/link.html", None)).await;
3438 assert_eq!(res.status(), StatusCode::FORBIDDEN);
3439 }
3440
3441 #[tokio::test]
3443 async fn a_directory_without_a_trailing_slash_redirects() {
3444 let origin = origin_with(
3445 FakeRemote::new()
3446 .dir("/srv", vec![("sub", dir_attrs())])
3447 .dir("/srv/sub", vec![("b.html", file_attrs(1, 1))]),
3448 )
3449 .await;
3450
3451 let res = origin.handle(get("/sub", None)).await;
3452 assert_eq!(res.status(), StatusCode::MOVED_PERMANENTLY);
3453 assert_eq!(
3454 res.headers().get(LOCATION).and_then(|v| v.to_str().ok()),
3455 Some("/sub/")
3456 );
3457 }
3458
3459 #[tokio::test]
3462 async fn a_listing_proven_wrong_is_forgotten() {
3463 let origin =
3465 origin_with(FakeRemote::new().dir("/srv", vec![("ghost.html", file_attrs(5, 100))]))
3466 .await;
3467
3468 let res = origin.handle(get("/ghost.html", None)).await;
3469 assert_eq!(res.status(), StatusCode::NOT_FOUND);
3470 assert!(
3471 !origin.cache.has_listing("/srv"),
3472 "a listing contradicted by the remote must be dropped"
3473 );
3474 }
3475
3476 #[tokio::test]
3478 async fn a_directory_without_an_index_is_listed() {
3479 let origin =
3480 origin_with(FakeRemote::new().dir("/srv", vec![("only.txt", file_attrs(2, 1))])).await;
3481
3482 let res = origin.handle(get("/", None)).await;
3483 assert_eq!(res.status(), StatusCode::OK);
3484 assert_eq!(
3485 res.headers()
3486 .get(CONTENT_TYPE)
3487 .and_then(|v| v.to_str().ok()),
3488 Some("text/html; charset=utf-8")
3489 );
3490 }
3491
3492 #[tokio::test]
3495 async fn a_symlinked_directory_higher_up_the_path_is_refused() {
3496 let origin = origin_with(
3497 FakeRemote::new()
3498 .dir("/srv", vec![("link", symlink_attrs())])
3499 .dir("/srv/link", vec![("inside.html", file_attrs(2, 1))])
3500 .file("/srv/link/inside.html", b"hi"),
3501 )
3502 .await;
3503
3504 let res = origin.handle(get("/link/inside.html", None)).await;
3505 assert_eq!(res.status(), StatusCode::FORBIDDEN);
3506 }
3507
3508 #[tokio::test]
3511 async fn a_deep_path_costs_what_a_shallow_one_costs() {
3512 let deep = origin_with(deep_tree()).await;
3513 assert_eq!(
3514 deep.handle(get("/a/b/c/d.html", None)).await.status(),
3515 StatusCode::OK
3516 );
3517
3518 let shallow = origin_with(one_page()).await;
3519 assert_eq!(
3520 shallow.handle(get("/a.html", None)).await.status(),
3521 StatusCode::OK
3522 );
3523
3524 let (d, sh) = (trips(&deep).await, trips(&shallow).await);
3525 assert!(
3529 d <= sh + 2,
3530 "depth 4 cost {d} round trips against depth 1's {sh}"
3531 );
3532 }
3533
3534 #[tokio::test]
3536 async fn a_file_used_as_a_directory_is_a_404() {
3537 let origin = origin_with(one_page()).await;
3538 let res = origin.handle(get("/a.html/b.html", None)).await;
3539 assert_eq!(res.status(), StatusCode::NOT_FOUND);
3540 }
3541
3542 #[tokio::test]
3545 async fn a_deep_path_serves_its_body() {
3546 let origin = origin_with(deep_tree()).await;
3547 let res = origin.handle(get("/a/b/c/d.html", None)).await;
3548 assert_eq!(res.status(), StatusCode::OK);
3549 assert_eq!(
3550 res.headers()
3551 .get(CONTENT_TYPE)
3552 .and_then(|v| v.to_str().ok()),
3553 Some("text/html; charset=utf-8")
3554 );
3555 }
3556
3557 #[tokio::test]
3559 async fn a_range_is_sliced_out_of_the_cached_body() {
3560 let origin = origin_with(one_page()).await;
3561 assert_eq!(
3562 origin.handle(get("/a.html", None)).await.status(),
3563 StatusCode::OK
3564 );
3565 let warm = trips(&origin).await;
3566
3567 let res = origin.handle(ranged("/a.html", "bytes=1-3")).await;
3568 assert_eq!(res.status(), StatusCode::PARTIAL_CONTENT);
3569 assert_eq!(
3570 res.headers()
3571 .get(CONTENT_RANGE)
3572 .and_then(|v| v.to_str().ok()),
3573 Some("bytes 1-3/5")
3574 );
3575 assert_eq!(&body_of(res).await[..], b"ell");
3576 assert_eq!(
3577 trips(&origin).await,
3578 warm,
3579 "slicing a held body must cost no round trip"
3580 );
3581 }
3582
3583 #[tokio::test]
3585 async fn a_range_on_a_cold_small_file_works_and_warms_the_cache() {
3586 let origin = origin_with(one_page()).await;
3587
3588 let res = origin.handle(ranged("/a.html", "bytes=0-1")).await;
3589 assert_eq!(res.status(), StatusCode::PARTIAL_CONTENT);
3590 assert_eq!(&body_of(res).await[..], b"he");
3591
3592 let warm = trips(&origin).await;
3593 let again = origin.handle(ranged("/a.html", "bytes=2-4")).await;
3594 assert_eq!(&body_of(again).await[..], b"llo");
3595 assert_eq!(
3596 trips(&origin).await,
3597 warm,
3598 "a small file fetched for a range should be held whole"
3599 );
3600 }
3601
3602 #[tokio::test]
3604 async fn a_range_past_the_end_is_a_416_carrying_the_real_size() {
3605 let origin = origin_with(one_page()).await;
3606 let res = origin.handle(ranged("/a.html", "bytes=99-")).await;
3607 assert_eq!(res.status(), StatusCode::RANGE_NOT_SATISFIABLE);
3608 assert_eq!(
3609 res.headers()
3610 .get(CONTENT_RANGE)
3611 .and_then(|v| v.to_str().ok()),
3612 Some("bytes */5")
3613 );
3614 }
3615
3616 #[tokio::test]
3618 async fn a_full_response_advertises_ranges() {
3619 let origin = origin_with(one_page()).await;
3620 let res = origin.handle(get("/a.html", None)).await;
3621 assert_eq!(
3622 res.headers()
3623 .get(ACCEPT_RANGES)
3624 .and_then(|v| v.to_str().ok()),
3625 Some("bytes")
3626 );
3627 }
3628
3629 #[tokio::test]
3632 async fn if_range_yields_the_whole_file() {
3633 let origin = origin_with(one_page()).await;
3634 let req = Request::builder()
3635 .uri("http://docs.ssh-browser/a.html")
3636 .header(HOST, "docs.ssh-browser")
3637 .header(RANGE, "bytes=1-3")
3638 .header(IF_RANGE, "W/\"64-5\"")
3639 .body(Empty::<Bytes>::new())
3640 .expect("request builds");
3641
3642 let res = origin.handle(req).await;
3643 assert_eq!(res.status(), StatusCode::OK);
3644 assert_eq!(&body_of(res).await[..], b"hello");
3645 }
3646
3647 #[tokio::test]
3650 async fn a_large_file_is_served_by_range_and_not_held() {
3651 let body: Vec<u8> = (0..64u8).collect();
3652 let origin = origin_with(
3653 FakeRemote::new()
3654 .dir("/srv", vec![("big.bin", file_attrs(9 * 1024 * 1024, 7))])
3657 .file("/srv/big.bin", &body),
3658 )
3659 .await;
3660
3661 let res = origin.handle(ranged("/big.bin", "bytes=0-9")).await;
3662 assert_eq!(res.status(), StatusCode::PARTIAL_CONTENT);
3663 assert_eq!(&body_of(res).await[..], &body[0..10]);
3664
3665 let after = trips(&origin).await;
3666 let second = origin.handle(ranged("/big.bin", "bytes=10-19")).await;
3667 assert_eq!(&body_of(second).await[..], &body[10..20]);
3668 assert!(
3669 trips(&origin).await > after,
3670 "a file over the threshold must not be held"
3671 );
3672 }
3673
3674 #[tokio::test]
3678 async fn an_alias_origin_has_no_control_api_on_it() {
3679 let origin = origin_with(one_page()).await;
3680 let res = origin.handle(get("/_control/hello", None)).await;
3681 assert_eq!(res.status(), StatusCode::NOT_FOUND);
3682 assert_ne!(
3683 res.status(),
3684 StatusCode::UNAUTHORIZED,
3685 "a 401 would mean the control router was reached from an alias origin"
3686 );
3687 }
3688
3689 #[tokio::test]
3692 async fn an_alias_origin_with_a_valid_token_still_has_no_control_api() {
3693 let origin = origin_with(one_page()).await;
3694 let req = Request::builder()
3695 .uri("http://docs.ssh-browser/_control/hello")
3696 .header(HOST, "docs.ssh-browser")
3697 .header(control::TOKEN_HEADER, TEST_TOKEN)
3698 .body(Empty::<Bytes>::new())
3699 .expect("request builds");
3700 assert_eq!(origin.handle(req).await.status(), StatusCode::NOT_FOUND);
3701 }
3702
3703 #[tokio::test]
3706 async fn the_alias_origin_refuses_writes() {
3707 let origin = origin_with(one_page()).await;
3708 for method in [Method::POST, Method::PUT, Method::DELETE, Method::PATCH] {
3709 let req = Request::builder()
3710 .method(method.clone())
3711 .uri("http://docs.ssh-browser/a.html")
3712 .header(HOST, "docs.ssh-browser")
3713 .body(Empty::<Bytes>::new())
3714 .expect("request builds");
3715 assert_eq!(
3716 origin.handle(req).await.status(),
3717 StatusCode::METHOD_NOT_ALLOWED,
3718 "{method} should be refused on the read-only origin"
3719 );
3720 }
3721 }
3722
3723 #[tokio::test]
3724 async fn the_control_api_answers_on_loopback_with_the_token() {
3725 let origin = origin_with(one_page()).await;
3726 let res = origin
3727 .handle(loopback("/_control/hello", Some(TEST_TOKEN)))
3728 .await;
3729 assert_eq!(res.status(), StatusCode::OK);
3730 let body = body_of(res).await;
3731 let text = String::from_utf8_lossy(&body);
3732 assert!(
3733 text.contains("\"protocol\""),
3734 "hello must negotiate: {text}"
3735 );
3736 assert!(text.contains("\"docs\""), "hello must list aliases: {text}");
3737 }
3738
3739 #[tokio::test]
3740 async fn the_control_api_refuses_loopback_without_the_token() {
3741 let origin = origin_with(one_page()).await;
3742 assert_eq!(
3743 origin
3744 .handle(loopback("/_control/hello", None))
3745 .await
3746 .status(),
3747 StatusCode::UNAUTHORIZED
3748 );
3749 assert_eq!(
3750 origin
3751 .handle(loopback("/_control/hello", Some("wrong")))
3752 .await
3753 .status(),
3754 StatusCode::UNAUTHORIZED
3755 );
3756 }
3757
3758 #[tokio::test]
3760 async fn the_loopback_path_still_serves_files() {
3761 let origin = origin_with(one_page()).await;
3762 let res = origin.handle(loopback("/docs/a.html", None)).await;
3763 assert_eq!(res.status(), StatusCode::OK);
3764 assert_eq!(&body_of(res).await[..], b"hello");
3765 }
3766
3767 #[tokio::test]
3770 async fn a_base_may_be_written_relative_to_the_home_directory() {
3771 let fs = FakeRemote::new().home("/home/souta").spawn().await;
3772 assert_eq!(
3773 resolve_base(Some("~/work"), &fs).await.expect("resolves"),
3774 "/home/souta/work"
3775 );
3776 }
3777
3778 #[tokio::test]
3780 async fn a_bare_tilde_and_no_base_are_both_the_home_directory() {
3781 let fs = FakeRemote::new().home("/home/souta").spawn().await;
3782 assert_eq!(
3783 resolve_base(None, &fs).await.expect("resolves"),
3784 "/home/souta"
3785 );
3786 assert_eq!(
3787 resolve_base(Some("~"), &fs).await.expect("resolves"),
3788 "/home/souta"
3789 );
3790 }
3791
3792 #[tokio::test]
3795 async fn an_absolute_base_costs_no_round_trip() {
3796 let fs = FakeRemote::new().home("/home/souta").spawn().await;
3797 let before = fs.round_trips();
3798 assert_eq!(
3799 resolve_base(Some("/srv/docs"), &fs)
3800 .await
3801 .expect("resolves"),
3802 "/srv/docs"
3803 );
3804 assert_eq!(fs.round_trips(), before, "an absolute base must not ask");
3805 }
3806
3807 #[test]
3810 fn a_base_that_could_climb_out_of_the_home_directory_is_refused() {
3811 for bad in [
3812 "~/..",
3813 "~/../.ssh",
3814 "~/work/../..",
3815 "~/./x",
3816 "~work",
3817 "work",
3818 "",
3819 ] {
3820 assert!(!is_base(bad), "should have been refused: {bad:?}");
3821 assert!(
3822 Alias::new("docs", "h", Some(bad)).is_err(),
3823 "should have been refused: {bad:?}"
3824 );
3825 }
3826 for good in ["/", "/srv", "~", "~/work", "~/a/b/c"] {
3827 assert!(is_base(good), "should have been accepted: {good:?}");
3828 }
3829 }
3830
3831 #[tokio::test]
3834 async fn a_root_home_does_not_produce_a_doubled_slash() {
3835 let fs = FakeRemote::new().home("/").spawn().await;
3836 assert_eq!(resolve_base(None, &fs).await.expect("resolves"), "/");
3837 assert_eq!(
3838 resolve_base(Some("~/work"), &fs).await.expect("resolves"),
3839 "/work"
3840 );
3841 }
3842
3843 #[tokio::test]
3852 async fn the_host_list_is_a_control_route() {
3853 let origin = origin_with(one_page()).await;
3854 let res = origin
3855 .handle(loopback("/_control/hosts", Some(TEST_TOKEN)))
3856 .await;
3857 assert_eq!(res.status(), StatusCode::OK);
3858 let text = String::from_utf8(body_of(res).await.to_vec()).expect("utf-8");
3859 let parsed: serde_json::Value = serde_json::from_str(&text).expect("json");
3860 assert!(parsed.get("hosts").is_some_and(|h| h.is_array()), "{text}");
3861 assert!(
3862 parsed.get("unusable").is_some_and(|u| u.is_array()),
3863 "{text}"
3864 );
3865 }
3866
3867 #[tokio::test]
3880 async fn the_host_list_reports_round_trips_and_they_grow() {
3881 let origin = origin_with(one_page()).await;
3882
3883 async fn trips_now(origin: &Origin) -> u64 {
3884 let res = origin
3885 .handle(loopback("/_control/hosts", Some(TEST_TOKEN)))
3886 .await;
3887 assert_eq!(res.status(), StatusCode::OK);
3888 let text = String::from_utf8(body_of(res).await.to_vec()).expect("utf-8");
3889 let parsed: serde_json::Value = serde_json::from_str(&text).expect("json");
3890 let open = parsed["open"].as_array().expect("open is an array");
3891 assert_eq!(open.len(), 1, "{text}");
3892 open[0]["trips"].as_u64().expect("trips is a number")
3893 }
3894
3895 let before = trips_now(&origin).await;
3896 let res = origin.handle(get("/a.html", None)).await;
3897 assert_eq!(res.status(), StatusCode::OK);
3898 let after = trips_now(&origin).await;
3899
3900 assert!(
3901 after > before,
3902 "serving a page reported no round trips ({before} -> {after})"
3903 );
3904 }
3905
3906 #[test]
3913 fn a_remembered_label_finds_the_host_it_came_from() {
3914 let known = vec![
3915 ssh_config::Host {
3916 host: "Panza".to_string(),
3917 alias: "panza".to_string(),
3918 },
3919 ssh_config::Host {
3920 host: "issp-ohtaka".to_string(),
3921 alias: "issp-ohtaka".to_string(),
3922 },
3923 ];
3924
3925 assert_eq!(
3927 entry_for(&known, "panza").map(|h| h.host.as_str()),
3928 Some("Panza")
3929 );
3930 assert_eq!(
3932 entry_for(&known, "Panza").map(|h| h.host.as_str()),
3933 Some("Panza")
3934 );
3935 assert_eq!(
3936 entry_for(&known, "issp-ohtaka").map(|h| h.host.as_str()),
3937 Some("issp-ohtaka")
3938 );
3939 }
3940
3941 #[test]
3947 fn a_name_ssh_config_does_not_know_resolves_to_nothing() {
3948 let known = vec![ssh_config::Host {
3949 host: "Panza".to_string(),
3950 alias: "panza".to_string(),
3951 }];
3952 assert!(entry_for(&known, "not-a-host-anywhere").is_none());
3953 assert!(entry_for(&known, "").is_none());
3954 assert!(entry_for(&known, "panz").is_none());
3956 }
3957
3958 #[tokio::test]
3965 async fn enabling_a_host_ssh_does_not_know_is_refused() {
3966 let origin = origin_with(one_page()).await;
3967 let res = origin
3968 .handle(control_post(
3969 "/_control/enabled",
3970 Some(TEST_TOKEN),
3971 r#"{"host":"not-a-host-in-anyones-ssh-config.invalid","enabled":true}"#,
3972 ))
3973 .await;
3974 assert_eq!(res.status(), StatusCode::NOT_FOUND);
3975 }
3976
3977 #[tokio::test]
3983 async fn enabling_a_host_without_the_token_is_refused() {
3984 let origin = origin_with(one_page()).await;
3985 let res = origin
3986 .handle(control_post(
3987 "/_control/enabled",
3988 None,
3989 r#"{"host":"anything","enabled":true}"#,
3990 ))
3991 .await;
3992 assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
3993 }
3994
3995 #[tokio::test]
4000 async fn enabling_needs_to_say_which_way() {
4001 let origin = origin_with(one_page()).await;
4002 for body in [
4003 r#"{"host":"anything"}"#,
4004 r#"{"enabled":true}"#,
4005 "{}",
4006 "not json",
4007 ] {
4008 let res = origin
4009 .handle(control_post("/_control/enabled", Some(TEST_TOKEN), body))
4010 .await;
4011 assert_eq!(
4012 res.status(),
4013 StatusCode::BAD_REQUEST,
4014 "{body} should not have been accepted"
4015 );
4016 }
4017 }
4018
4019 #[tokio::test]
4025 async fn disabling_a_host_closes_it_now() {
4026 let origin = origin_with(one_page()).await;
4027 assert!(origin.session("docs").await.is_some());
4031 origin.sessions.write().await.remove("docs");
4032 assert!(
4033 origin.session("docs").await.is_none(),
4034 "removing the session is what disabling does, and a request must then 404"
4035 );
4036 let res = origin.handle(get("/a.html", None)).await;
4037 assert_eq!(res.status(), StatusCode::NOT_FOUND);
4038 }
4039
4040 #[tokio::test]
4046 async fn an_alias_origin_cannot_read_the_host_list() {
4047 let origin = origin_with(one_page()).await;
4048 for path in ["/_control/hosts", "/_control/enabled", "/_control/hello"] {
4049 let res = origin.handle(get(path, None)).await;
4050 assert_ne!(
4051 res.status(),
4052 StatusCode::OK,
4053 "{path} answered a request from an alias origin"
4054 );
4055 }
4056 }
4057
4058 #[tokio::test]
4065 async fn opening_a_host_ssh_does_not_know_is_refused() {
4066 let origin = origin_with(one_page()).await;
4067 let res = origin
4068 .handle(control_post(
4069 "/_control/open",
4070 Some(TEST_TOKEN),
4071 r#"{"host":"not-a-host-in-anyones-ssh-config.invalid"}"#,
4072 ))
4073 .await;
4074 assert_eq!(res.status(), StatusCode::NOT_FOUND);
4075 }
4076
4077 #[tokio::test]
4081 async fn an_open_request_that_is_not_one_is_refused() {
4082 let origin = origin_with(one_page()).await;
4083 for body in [
4084 "",
4085 "{}",
4086 r#"{"base":"/srv"}"#,
4087 r#"{"host":"docs","base_path":"/srv"}"#,
4088 ] {
4089 let res = origin
4090 .handle(control_post("/_control/open", Some(TEST_TOKEN), body))
4091 .await;
4092 assert_eq!(
4093 res.status(),
4094 StatusCode::BAD_REQUEST,
4095 "should have been refused: {body}"
4096 );
4097 }
4098 }
4099
4100 #[tokio::test]
4104 async fn opening_a_host_needs_the_token() {
4105 let origin = origin_with(one_page()).await;
4106 for token in [None, Some("wrong")] {
4107 let res = origin
4108 .handle(control_post("/_control/open", token, r#"{"host":"docs"}"#))
4109 .await;
4110 assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "token {token:?}");
4111 }
4112 }
4113
4114 #[tokio::test]
4121 async fn the_token_is_handed_over_to_something_that_is_not_a_page() {
4122 let origin = origin_with(one_page()).await;
4123 for site in [None, Some("none")] {
4124 let res = origin.handle(from_site("/_control/token", site)).await;
4125 assert_eq!(res.status(), StatusCode::OK, "site {site:?}");
4126 let body = String::from_utf8(body_of(res).await.to_vec()).expect("utf-8");
4127 assert_eq!(body.trim(), TEST_TOKEN, "site {site:?}");
4128 }
4129 }
4130
4131 #[tokio::test]
4132 async fn a_page_is_not_handed_the_token() {
4133 let origin = origin_with(one_page()).await;
4134 for site in ["same-origin", "same-site", "cross-site"] {
4135 let res = origin
4136 .handle(from_site("/_control/token", Some(site)))
4137 .await;
4138 assert_eq!(res.status(), StatusCode::FORBIDDEN, "site {site}");
4139 let body = String::from_utf8(body_of(res).await.to_vec()).expect("utf-8");
4140 assert!(!body.contains(TEST_TOKEN), "the refusal leaked it: {body}");
4141 }
4142 }
4143
4144 #[tokio::test]
4147 async fn a_page_with_the_token_still_cannot_use_the_control_api() {
4148 let origin = origin_with(one_page()).await;
4149 let req = Request::builder()
4150 .uri("http://127.0.0.1:7391/_control/hello")
4151 .header(HOST, "127.0.0.1:7391")
4152 .header(control::TOKEN_HEADER, TEST_TOKEN)
4153 .header(control::FETCH_SITE_HEADER, "same-origin")
4154 .body(Full::new(Bytes::new()))
4155 .expect("request builds");
4156 assert_eq!(origin.handle(req).await.status(), StatusCode::FORBIDDEN);
4157 }
4158
4159 #[tokio::test]
4161 async fn an_alias_can_be_closed_and_is_then_gone() {
4162 let origin = origin_with(one_page()).await;
4163 assert_eq!(
4164 origin.handle(get("/a.html", None)).await.status(),
4165 StatusCode::OK
4166 );
4167
4168 let res = origin
4169 .handle(control_post(
4170 "/_control/close",
4171 Some(TEST_TOKEN),
4172 r#"{"alias":"docs"}"#,
4173 ))
4174 .await;
4175 assert_eq!(res.status(), StatusCode::OK);
4176
4177 assert_eq!(
4180 origin.handle(get("/a.html", None)).await.status(),
4181 StatusCode::NOT_FOUND
4182 );
4183 }
4184
4185 #[tokio::test]
4188 async fn closing_an_alias_that_is_not_open_says_so() {
4189 let origin = origin_with(one_page()).await;
4190 let res = origin
4191 .handle(control_post(
4192 "/_control/close",
4193 Some(TEST_TOKEN),
4194 r#"{"alias":"nope"}"#,
4195 ))
4196 .await;
4197 assert_eq!(res.status(), StatusCode::NOT_FOUND);
4198 }
4199
4200 #[tokio::test]
4201 async fn closing_an_alias_needs_the_token() {
4202 let origin = origin_with(one_page()).await;
4203 let res = origin
4204 .handle(control_post("/_control/close", None, r#"{"alias":"docs"}"#))
4205 .await;
4206 assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
4207 assert_eq!(
4209 origin.handle(get("/a.html", None)).await.status(),
4210 StatusCode::OK
4211 );
4212 }
4213
4214 #[tokio::test]
4218 async fn a_directory_holding_an_index_is_listed_as_a_site() {
4219 let origin = origin_with(
4220 FakeRemote::new()
4221 .dir("/srv", vec![("ft-demo", dir_attrs()), ("src", dir_attrs())])
4222 .dir("/srv/ft-demo", vec![("index.html", file_attrs(5, 1))])
4223 .dir("/srv/src", vec![("main.jl", file_attrs(5, 1))])
4224 .file("/srv/ft-demo/index.html", b"board"),
4225 )
4226 .await;
4227
4228 let body = String::from_utf8(body_of(origin.handle(get("/", None)).await).await.to_vec())
4229 .expect("utf-8");
4230 assert!(
4233 body.contains("class=\"row site\" href=\"/ft-demo/\""),
4234 "{body}"
4235 );
4236 let demo = body.find("ft-demo/").expect("the site listed");
4237 let src = body.find("src/").expect("the folder listed");
4238 assert!(demo < src, "a site leads the other directories: {body}");
4239 }
4240
4241 #[tokio::test]
4246 async fn the_site_scan_costs_the_same_however_many_subdirectories() {
4247 async fn trips_for(n: usize) -> u64 {
4248 let names: Vec<String> = (0..n).map(|i| format!("d{i:02}")).collect();
4249 let mut remote = FakeRemote::new().dir(
4250 "/srv",
4251 names.iter().map(|s| (s.as_str(), dir_attrs())).collect(),
4252 );
4253 for name in &names {
4254 remote = remote.dir(&format!("/srv/{name}"), vec![("a.txt", file_attrs(1, 1))]);
4255 }
4256 let origin = origin_with(remote).await;
4257 let before = trips(&origin).await;
4258 assert_eq!(origin.handle(get("/", None)).await.status(), StatusCode::OK);
4259 trips(&origin).await - before
4260 }
4261
4262 let few = trips_for(2).await;
4263 let many = trips_for(20).await;
4264 assert_eq!(
4265 few, many,
4266 "{many} round trips for twenty subdirectories against {few} for two"
4267 );
4268 }
4269
4270 #[tokio::test]
4273 async fn the_scan_leaves_the_next_click_paid_for() {
4274 let origin = origin_with(
4275 FakeRemote::new()
4276 .dir("/srv", vec![("sub", dir_attrs())])
4277 .dir("/srv/sub", vec![("a.txt", file_attrs(1, 1))]),
4278 )
4279 .await;
4280 assert_eq!(origin.handle(get("/", None)).await.status(), StatusCode::OK);
4281
4282 let before = trips(&origin).await;
4283 assert_eq!(
4284 origin.handle(get("/sub/", None)).await.status(),
4285 StatusCode::OK
4286 );
4287 assert_eq!(
4288 trips(&origin).await,
4289 before,
4290 "the listing the scan fetched should still be the one that answers"
4291 );
4292 }
4293
4294 #[tokio::test]
4305 async fn a_listing_that_expires_mid_request_does_not_lose_the_path() {
4306 let origin =
4307 origin_with_cache(deep_tree(), Cache::new(std::time::Duration::ZERO, 1 << 20)).await;
4308 assert_eq!(
4309 origin.handle(get("/a/b/c/d.html", None)).await.status(),
4310 StatusCode::OK,
4311 "a path four deep must survive its own listings expiring"
4312 );
4313 assert_eq!(
4315 origin.handle(get("/a/b/c/", None)).await.status(),
4316 StatusCode::OK
4317 );
4318 }
4319
4320 #[tokio::test]
4323 async fn a_directory_the_remote_refuses_says_why() {
4324 let origin = origin_with(
4325 FakeRemote::new()
4326 .dir("/srv", vec![("locked", dir_attrs())])
4327 .refuses_listing("/srv/locked", 3),
4329 )
4330 .await;
4331
4332 let res = origin.handle(get("/locked/x.html", None)).await;
4333 assert_eq!(res.status(), StatusCode::BAD_GATEWAY);
4334 let said = String::from_utf8(body_of(res).await.to_vec()).expect("utf-8");
4335 assert!(said.contains("/srv/locked"), "{said}");
4336 assert!(
4337 !said.contains("cannot list"),
4338 "the old wording said nothing the reader could act on: {said}"
4339 );
4340 }
4341}