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::sftp::wire::Attrs;
44use crate::ssh_config;
45use crate::theme;
46
47const CACHE_WHOLE_MAX: u64 = 8 * 1024 * 1024;
51
52struct Conditions {
54 if_none_match: Option<String>,
55 range: Option<String>,
56 if_range: Option<String>,
57 control_token: Option<String>,
59 fetch_site: Option<String>,
64}
65
66#[derive(Debug)]
74pub struct Alias {
75 name: String,
76 host: String,
77 base: Option<String>,
84}
85
86impl Alias {
87 pub fn new(name: &str, host: &str, base: Option<&str>) -> Result<Self> {
88 ensure!(!host.is_empty(), "alias {name:?} has no ssh host");
89 ensure!(
95 guard::is_label(name),
96 "alias {name:?} must be lowercase letters, digits and hyphens, and may not start or end with a hyphen: it becomes a hostname label"
97 );
98 if let Some(base) = base {
99 ensure!(
100 is_base(base),
101 "alias {name:?} needs a base that is an absolute path, or `~`, or `~/` and a path under the home directory with no `..` in it, got {base:?}"
102 );
103 }
104 Ok(Self {
105 name: name.to_string(),
106 host: host.to_string(),
107 base: base.map(str::to_string),
108 })
109 }
110
111 pub fn name(&self) -> &str {
112 &self.name
113 }
114
115 pub fn host(&self) -> &str {
116 &self.host
117 }
118
119 pub fn base(&self) -> Option<&str> {
121 self.base.as_deref()
122 }
123}
124
125#[derive(serde::Serialize)]
127struct KnownHost {
128 alias: String,
129 host: String,
130 #[serde(flatten)]
131 settings: ssh_config::Settings,
132 served: bool,
137 #[serde(skip_serializing_if = "Option::is_none")]
138 unresolved: Option<String>,
139}
140
141#[derive(serde::Serialize)]
149struct OpenAlias {
150 alias: String,
151 host: String,
152 base: String,
153 url: String,
154}
155
156#[derive(serde::Serialize)]
157struct KnownHosts {
158 open: Vec<OpenAlias>,
159 hosts: Vec<KnownHost>,
160 unusable: Vec<ssh_config::Unusable>,
161}
162
163fn is_base(base: &str) -> bool {
175 if base.starts_with('/') {
176 return true;
177 }
178 let Some(rest) = base.strip_prefix('~') else {
179 return false;
180 };
181 match rest {
182 "" => true,
183 rest => match rest.strip_prefix('/') {
184 Some(under) => {
185 !under.is_empty()
186 && under
187 .split('/')
188 .all(|c| !c.is_empty() && c != "." && c != "..")
189 }
190 None => false,
191 },
192 }
193}
194
195async fn resolve_base(base: Option<&str>, fs: &SftpFs) -> Result<String> {
201 let under = match base {
202 None | Some("~") => "",
203 Some(b) => match b.strip_prefix("~/") {
204 Some(under) => under,
205 None => return Ok(b.to_string()),
208 },
209 };
210 let home = fs.home().await?;
211 let home = home.trim_end_matches('/');
212 let home = if home.is_empty() { "" } else { home };
215 Ok(match under {
216 "" if home.is_empty() => "/".to_string(),
217 "" => home.to_string(),
218 under => format!("{home}/{under}"),
219 })
220}
221
222impl Origin {
227 async fn session(&self, alias: &str) -> Option<Arc<Session>> {
228 self.sessions.read().await.get(alias).cloned()
229 }
230
231 async fn alias_names(&self) -> Vec<String> {
232 let mut names: Vec<String> = self.sessions.read().await.keys().cloned().collect();
233 names.sort();
234 names
235 }
236}
237
238struct Session {
239 host: String,
245 base: String,
246 fs: SftpFs,
247}
248
249pub struct Origin {
250 suffix: String,
251 port: u16,
252 sessions: RwLock<HashMap<String, Arc<Session>>>,
263 cache: Cache,
264 token: Token,
265 theme: RwLock<String>,
271}
272
273pub struct Bound {
279 origin: Arc<Origin>,
280 listener: TcpListener,
281 routes: Vec<String>,
282}
283
284impl Bound {
285 pub fn routes(&self) -> &[String] {
290 &self.routes
291 }
292}
293
294impl Origin {
295 pub async fn bind(
305 aliases: Vec<Alias>,
306 suffix: String,
307 port: u16,
308 token: Token,
309 theme: String,
310 ) -> Result<Bound> {
311 let addr = SocketAddr::from(([127, 0, 0, 1], port));
312 let listener = TcpListener::bind(addr)
313 .await
314 .with_context(|| format!("bind {addr}"))?;
315
316 ensure!(
320 pac::is_suffix(&suffix),
321 "suffix {suffix:?} must be lowercase letters, digits, hyphens and dots"
322 );
323 theme::check(&theme)?;
326
327 let mut sessions = HashMap::new();
328 let mut routes = Vec::new();
329 for a in aliases {
330 let fs = SftpFs::connect(&a.host)
331 .await
332 .with_context(|| format!("alias {} -> ssh host {}", a.name, a.host))?;
333 let base = resolve_base(a.base.as_deref(), &fs)
336 .await
337 .with_context(|| {
338 format!(
339 "alias {} -> ssh host {}: working out where {} is",
340 a.name,
341 a.host,
342 a.base.as_deref().unwrap_or("the home directory")
343 )
344 })?;
345 routes.push(format!(
349 " http://{}.{suffix}/ -> {}:{base}",
350 a.name, a.host
351 ));
352 ensure!(
356 sessions
357 .insert(
358 a.name.clone(),
359 Arc::new(Session {
360 host: a.host.clone(),
361 base,
362 fs,
363 }),
364 )
365 .is_none(),
366 "alias {:?} is defined twice",
367 a.name
368 );
369 }
370 Ok(Bound {
371 routes,
372 origin: Arc::new(Self {
373 suffix,
374 port,
375 sessions: RwLock::new(sessions),
376 cache: Cache::default(),
377 token,
378 theme: RwLock::new(theme),
379 }),
380 listener,
381 })
382 }
383}
384
385impl Bound {
386 pub async fn serve(self) -> Result<()> {
387 let Bound {
388 origin, listener, ..
389 } = self;
390 let self_ = origin;
391
392 loop {
393 let (stream, _) = listener.accept().await?;
394 let me = Arc::clone(&self_);
395 tokio::spawn(async move {
396 let service = service_fn(move |req| {
397 let me = Arc::clone(&me);
398 async move { Ok::<_, std::convert::Infallible>(me.handle(req).await) }
399 });
400 let _ = http1::Builder::new()
404 .serve_connection(TokioIo::new(stream), service)
405 .await;
406 });
407 }
408 }
409}
410
411impl Origin {
412 pub async fn handle<B>(&self, req: Request<B>) -> Response<Full<Bytes>>
415 where
416 B: hyper::body::Body,
417 B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
418 {
419 let Some(host) = host_of(&req) else {
420 return fail(StatusCode::BAD_REQUEST, "request carries no Host");
421 };
422 let path = req.uri().path().to_string();
423 let cond = Conditions {
424 if_none_match: header(&req, IF_NONE_MATCH),
425 range: header(&req, RANGE),
426 if_range: header(&req, IF_RANGE),
427 control_token: req
428 .headers()
429 .get(control::TOKEN_HEADER)
430 .and_then(|v| v.to_str().ok())
431 .map(str::to_string),
432 fetch_site: req
433 .headers()
434 .get(control::FETCH_SITE_HEADER)
435 .and_then(|v| v.to_str().ok())
436 .map(str::to_string),
437 };
438 let method = req.method().clone();
439 let query = req.uri().query().map(str::to_string);
440
441 let control_body = if path.starts_with(control::PATH_PREFIX) {
444 match read_body(req.into_body()).await {
445 Ok(b) => b,
446 Err(e) => return fail(StatusCode::BAD_REQUEST, e),
447 }
448 } else {
449 Bytes::new()
450 };
451
452 match guard::classify(&host, &path, &self.suffix, self.port) {
453 Err(e) => fail(StatusCode::FORBIDDEN, format!("{e:#}")),
456 Ok(guard::Target::Direct { path }) => {
457 self.direct(&method, path, &cond, query.as_deref(), &control_body)
458 .await
459 }
460 Ok(guard::Target::Alias { alias, path }) => {
461 self.alias(&method, alias, path, &cond, query.as_deref())
462 .await
463 }
464 }
465 }
466
467 async fn direct(
468 &self,
469 method: &Method,
470 path: &str,
471 cond: &Conditions,
472 query: Option<&str>,
473 body: &[u8],
474 ) -> Response<Full<Bytes>> {
475 if path.starts_with(control::PATH_PREFIX) {
478 if control::from_a_page(cond.fetch_site.as_deref()) {
481 return control::text(
482 StatusCode::FORBIDDEN,
483 "the control API is not reachable from a page",
484 );
485 }
486 if method == Method::GET && control::route_of(path) == "token" {
494 return control::text(StatusCode::OK, self.token.as_str());
495 }
496 if let Some(refusal) = control::gate(
500 method,
501 cond.fetch_site.as_deref(),
502 cond.control_token.as_deref(),
503 &self.token,
504 ) {
505 return refusal;
506 }
507 return self.control(method, path, body).await;
508 }
509
510 if path == "/proxy.pac" {
511 return match pac::script(&self.suffix, self.port) {
512 Ok(body) => plain_ok("application/x-ns-proxy-autoconfig", Bytes::from(body)),
513 Err(e) => fail(StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")),
514 };
515 }
516
517 let rest = path.trim_start_matches('/');
518 if rest.is_empty() {
519 return plain_ok(
520 "text/html; charset=utf-8",
521 Bytes::from(self.alias_index().await),
522 );
523 }
524
525 let (alias, sub) = rest.split_once('/').unwrap_or((rest, ""));
526 self.alias(method, alias, &format!("/{sub}"), cond, query)
527 .await
528 }
529
530 async fn alias(
531 &self,
532 method: &Method,
533 alias: &str,
534 path: &str,
535 cond: &Conditions,
536 query: Option<&str>,
537 ) -> Response<Full<Bytes>> {
538 if !matches!(*method, Method::GET | Method::HEAD) {
543 return fail(
544 StatusCode::METHOD_NOT_ALLOWED,
545 format!("{method} is not allowed: this origin is read-only"),
546 );
547 }
548
549 let Some(session) = self.session(alias).await else {
550 return fail(StatusCode::NOT_FOUND, format!("no alias named {alias:?}"));
551 };
552 let session = session.as_ref();
553 let resolved = match guard::resolve(&session.base, path) {
554 Ok(p) => p,
555 Err(e) => return fail(StatusCode::FORBIDDEN, format!("{e:#}")),
556 };
557
558 let wants_dir = path.ends_with('/');
559 let file = if wants_dir {
560 format!("{resolved}/index.html")
561 } else {
562 resolved.clone()
563 };
564
565 let chain = components(&session.base, &file);
569 if chain.is_empty() {
570 return self
571 .autoindex_of(session, alias, path, &resolved, query)
572 .await;
573 }
574 let last = chain.len() - 1;
575
576 if let Some((_, name)) = chain.iter().find(|(_, n)| hidden(n)) {
580 return fail(
581 StatusCode::FORBIDDEN,
582 format!("refusing {name}: names beginning with a dot are not served"),
583 );
584 }
585
586 let held = match self.listings_along(session, &chain).await {
587 Ok(held) => held,
588 Err((at, why)) => {
590 return fail(
591 StatusCode::BAD_GATEWAY,
592 format!("{path}: listing {at} failed: {why}"),
593 );
594 }
595 };
596
597 if let Some(at) = first_symlink(&held, &chain) {
601 return fail(
602 StatusCode::FORBIDDEN,
603 format!("refusing symlink at {at} (its target is not checked)"),
604 );
605 }
606
607 let mut found_last = None;
608 for (i, (dir, name)) in chain.iter().enumerate() {
609 let Some(attrs) = attrs_in(&held, dir, name) else {
610 if i == last && wants_dir {
613 return self
614 .autoindex_of(session, alias, path, &resolved, query)
615 .await;
616 }
617 return fail(StatusCode::NOT_FOUND, format!("not found: {path}"));
618 };
619
620 if i < last && !attrs.is_dir() {
621 return fail(
622 StatusCode::NOT_FOUND,
623 format!("{path}: {dir}/{name} is not a directory"),
624 );
625 }
626 if i == last {
627 found_last = Some(attrs);
628 }
629 }
630 let attrs = found_last.expect("the walk assigns on its final iteration");
631
632 if attrs.is_dir() {
633 if wants_dir {
634 return self
636 .autoindex_of(session, alias, path, &resolved, query)
637 .await;
638 }
639 return redirect(&format!("{path}/"));
642 }
643
644 let tag = cache::etag(&attrs);
645
646 if let (Some(tag), Some(header)) = (tag.as_deref(), cond.if_none_match.as_deref()) {
653 if cache::etag_matches(header, tag) {
654 return not_modified(tag);
655 }
656 }
657
658 let size = attrs.size.unwrap_or(0);
661 let wanted = match cond.range.as_deref() {
662 Some(header) => range::resolve(header, cond.if_range.as_deref(), size),
663 None => range::Resolved::Whole,
664 };
665 if wanted == range::Resolved::Unsatisfiable {
666 return unsatisfiable(size);
667 }
668
669 if let Some(body) = self.cache.body(&file, &attrs) {
671 return respond(&file, body, tag.as_deref(), &wanted, size);
672 }
673
674 if let range::Resolved::Part { start, end } = wanted {
678 if size > CACHE_WHOLE_MAX {
679 let req = RangeReq {
680 path: file.clone(),
681 offset: start,
682 len: end - start + 1,
683 };
684 let mut got = session.fs.read_ranges(std::slice::from_ref(&req)).await;
685 return match got.pop() {
686 Some(Ok(body)) => partial(
687 mime::guess(&file),
688 Bytes::from(body),
689 tag.as_deref(),
690 start,
691 end,
692 size,
693 ),
694 Some(Err(e)) => {
695 self.cache.forget_listing(&chain[last].0);
696 fail(StatusCode::NOT_FOUND, format!("{path}: {e:#}"))
697 }
698 None => fail(
699 StatusCode::INTERNAL_SERVER_ERROR,
700 "read_ranges returned no result",
701 ),
702 };
703 }
704 }
705
706 let mut got = session.fs.read_batch(std::slice::from_ref(&file)).await;
707 match got.pop() {
708 Some(Ok(body)) => {
709 let body = Bytes::from(body);
710 self.cache.put_body(&file, &attrs, body.clone());
711 if mime::guess(&file).starts_with("text/html") {
716 self.warm_subresources(session, path, &body).await;
717 }
718 respond(&file, body, tag.as_deref(), &wanted, size)
719 }
720 Some(Err(e)) => {
724 self.cache.forget_listing(&chain[last].0);
725 fail(StatusCode::NOT_FOUND, format!("{path}: {e:#}"))
726 }
727 None => fail(
728 StatusCode::INTERNAL_SERVER_ERROR,
729 "read_batch returned no result",
730 ),
731 }
732 }
733
734 async fn control(&self, method: &Method, path: &str, body: &[u8]) -> Response<Full<Bytes>> {
735 match (method, control::route_of(path)) {
736 (&Method::GET, "hello") => {
737 let aliases = self.alias_names().await;
738 control::hello(&aliases, &self.suffix)
739 }
740 (&Method::GET, "hosts") => self.list_hosts().await,
741 (&Method::POST, "open") => self.open_host(body).await,
742 (&Method::POST, "close") => self.close_alias(body).await,
743 (&Method::GET, "theme") => self.show_theme().await,
744 (&Method::POST, "theme") => self.set_theme(body).await,
745 (&Method::GET, route) => {
746 control::text(StatusCode::NOT_FOUND, format!("no control route {route:?}"))
747 }
748 (_, route) => control::text(
749 StatusCode::METHOD_NOT_ALLOWED,
750 format!("{method} is not allowed on {route:?}"),
751 ),
752 }
753 }
754
755 async fn list_hosts(&self) -> Response<Full<Bytes>> {
766 let found = match ssh_config::read() {
767 Ok(found) => found,
768 Err(e) => {
769 return control::text(
770 StatusCode::INTERNAL_SERVER_ERROR,
771 format!("reading ssh_config: {e:#}"),
772 );
773 }
774 };
775
776 let described: Vec<_> = found
780 .hosts
781 .iter()
782 .map(|h| {
783 let host = h.host.clone();
784 tokio::spawn(async move { ssh_config::describe(&host).await })
785 })
786 .collect();
787
788 let open = {
789 let sessions = self.sessions.read().await;
790 let mut open: Vec<OpenAlias> = sessions
791 .iter()
792 .map(|(alias, s)| OpenAlias {
793 alias: alias.clone(),
794 host: s.host.clone(),
795 base: s.base.clone(),
796 url: format!("http://{alias}.{}/", self.suffix),
797 })
798 .collect();
799 open.sort_by(|a, b| a.alias.cmp(&b.alias));
800 open
801 };
802 let mut hosts = Vec::with_capacity(found.hosts.len());
803 for (h, task) in found.hosts.iter().zip(described) {
804 let (settings, unresolved) = match task.await {
808 Ok(Ok(settings)) => (settings, None),
809 Ok(Err(e)) => (ssh_config::Settings::default(), Some(format!("{e:#}"))),
810 Err(e) => (ssh_config::Settings::default(), Some(e.to_string())),
811 };
812 hosts.push(KnownHost {
813 alias: h.alias.clone(),
814 host: h.host.clone(),
815 settings,
816 served: open.iter().any(|o| o.alias == h.alias),
817 unresolved,
818 });
819 }
820 control::json(&KnownHosts {
821 open,
822 hosts,
823 unusable: found.unusable,
824 })
825 }
826
827 async fn open_host(&self, body: &[u8]) -> Response<Full<Bytes>> {
837 #[derive(serde::Deserialize)]
838 #[serde(deny_unknown_fields)]
839 struct Ask {
840 host: String,
841 #[serde(default)]
843 base: Option<String>,
844 }
845
846 let ask: Ask = match serde_json::from_slice(body) {
847 Ok(ask) => ask,
848 Err(e) => {
849 return control::text(
850 StatusCode::BAD_REQUEST,
851 format!("open needs a JSON body naming a host: {e}"),
852 );
853 }
854 };
855
856 let found = match ssh_config::read() {
857 Ok(found) => found,
858 Err(e) => {
859 return control::text(
860 StatusCode::INTERNAL_SERVER_ERROR,
861 format!("reading ssh_config: {e:#}"),
862 );
863 }
864 };
865 let Some(known) = found
869 .hosts
870 .iter()
871 .find(|h| h.host.eq_ignore_ascii_case(&ask.host) || h.alias == ask.host)
872 else {
873 return control::text(
874 StatusCode::NOT_FOUND,
875 format!("{:?} is not a host in your ssh_config", ask.host),
876 );
877 };
878
879 let alias = match Alias::new(&known.alias, &known.host, ask.base.as_deref()) {
880 Ok(alias) => alias,
881 Err(e) => return control::text(StatusCode::BAD_REQUEST, format!("{e:#}")),
882 };
883
884 if let Some(open) = self.session(&known.alias).await {
889 let Some(asked) = alias.base() else {
894 return self.opened(&known.alias, &known.host, &open.base);
895 };
896 let wanted = match resolve_base(Some(asked), &open.fs).await {
902 Ok(base) => base,
903 Err(e) => {
904 return control::text(
905 StatusCode::BAD_GATEWAY,
906 format!("working out where to root {}: {e:#}", known.alias),
907 );
908 }
909 };
910 if wanted != open.base {
911 return control::text(
912 StatusCode::CONFLICT,
913 format!(
914 "{} is already open at {}, and {} is not the same place; a second base would change what that origin means underneath any page open in it",
915 known.alias, open.base, wanted
916 ),
917 );
918 }
919 return self.opened(&known.alias, &known.host, &open.base);
920 }
921
922 let fs = match SftpFs::connect(&known.host).await {
923 Ok(fs) => fs,
924 Err(e) => {
925 return control::text(
930 StatusCode::BAD_GATEWAY,
931 format!("ssh to {}: {e:#}", known.host),
932 );
933 }
934 };
935 let base = match resolve_base(alias.base(), &fs).await {
936 Ok(base) => base,
937 Err(e) => {
938 return control::text(
939 StatusCode::BAD_GATEWAY,
940 format!("working out where to root {}: {e:#}", known.alias),
941 );
942 }
943 };
944
945 let session = {
950 let mut sessions = self.sessions.write().await;
951 Arc::clone(sessions.entry(known.alias.clone()).or_insert_with(|| {
952 Arc::new(Session {
953 host: known.host.clone(),
954 base,
955 fs,
956 })
957 }))
958 };
959 self.opened(&known.alias, &known.host, &session.base)
960 }
961
962 async fn close_alias(&self, body: &[u8]) -> Response<Full<Bytes>> {
971 #[derive(serde::Deserialize)]
972 #[serde(deny_unknown_fields)]
973 struct Ask {
974 alias: String,
975 }
976
977 let ask: Ask = match serde_json::from_slice(body) {
978 Ok(ask) => ask,
979 Err(e) => {
980 return control::text(
981 StatusCode::BAD_REQUEST,
982 format!("close needs a JSON body naming an alias: {e}"),
983 );
984 }
985 };
986
987 let gone = self.sessions.write().await.remove(&ask.alias);
992 match gone {
993 Some(session) => {
994 #[derive(serde::Serialize)]
995 struct Closed<'a> {
996 alias: &'a str,
997 host: &'a str,
998 base: &'a str,
999 }
1000 control::json(&Closed {
1001 alias: &ask.alias,
1002 host: &session.host,
1003 base: &session.base,
1004 })
1005 }
1006 None => control::text(
1010 StatusCode::NOT_FOUND,
1011 format!("no alias named {:?} is open", ask.alias),
1012 ),
1013 }
1014 }
1015
1016 fn opened(&self, alias: &str, host: &str, base: &str) -> Response<Full<Bytes>> {
1017 #[derive(serde::Serialize)]
1018 struct Opened<'a> {
1019 alias: &'a str,
1020 host: &'a str,
1021 base: &'a str,
1022 url: String,
1023 }
1024 control::json(&Opened {
1025 alias,
1026 host,
1027 base,
1028 url: format!("http://{alias}.{}/", self.suffix),
1029 })
1030 }
1031
1032 async fn show_theme(&self) -> Response<Full<Bytes>> {
1034 #[derive(serde::Serialize)]
1035 struct Choice<'a> {
1036 name: &'a str,
1037 label: &'a str,
1038 variant: &'a str,
1040 }
1041 #[derive(serde::Serialize)]
1042 struct Themes<'a> {
1043 current: &'a str,
1044 themes: Vec<Choice<'a>>,
1045 }
1046 control::json(&Themes {
1049 current: &self.theme.read().await,
1050 themes: theme::all()
1051 .iter()
1052 .map(|t| Choice {
1053 name: &t.name,
1054 label: &t.label,
1055 variant: t.variant,
1056 })
1057 .collect(),
1058 })
1059 }
1060
1061 async fn set_theme(&self, body: &[u8]) -> Response<Full<Bytes>> {
1063 #[derive(serde::Deserialize)]
1064 #[serde(deny_unknown_fields)]
1065 struct Ask {
1066 name: String,
1067 }
1068 let ask: Ask = match serde_json::from_slice(body) {
1069 Ok(ask) => ask,
1070 Err(e) => {
1071 return control::text(
1072 StatusCode::BAD_REQUEST,
1073 format!("theme needs a JSON body naming one: {e}"),
1074 );
1075 }
1076 };
1077 if let Err(e) = theme::check(&ask.name) {
1080 return control::text(StatusCode::BAD_REQUEST, format!("{e:#}"));
1081 }
1082
1083 *self.theme.write().await = ask.name.clone();
1084 let remembered = theme::remember(&ask.name).is_ok();
1088 #[derive(serde::Serialize)]
1089 struct Chose<'a> {
1090 current: &'a str,
1091 remembered: bool,
1092 }
1093 control::json(&Chose {
1094 current: &ask.name,
1095 remembered,
1096 })
1097 }
1098
1099 async fn autoindex_of(
1100 &self,
1101 session: &Session,
1102 alias: &str,
1103 path: &str,
1104 resolved: &str,
1105 query: Option<&str>,
1106 ) -> Response<Full<Bytes>> {
1107 let rel = resolved
1111 .strip_prefix(&session.base)
1112 .unwrap_or("")
1113 .to_string();
1114 let entries = match self.listing_of(session, resolved).await {
1115 Ok(entries) => entries,
1116 Err(e) => return fail(StatusCode::NOT_FOUND, format!("{path}: {e:#}")),
1117 };
1118 let sites = self.sites_among(session, resolved, &entries).await;
1119
1120 if query == Some("ls") {
1131 let mut out = String::new();
1132 render_level(&mut out, &rel, &rows_of(&entries, &sites), &[]);
1133 return plain_ok("text/html; charset=utf-8", Bytes::from(out));
1134 }
1135
1136 let mut levels = Vec::new();
1140 let mut at = session.base.clone();
1141 for part in rel.split('/').filter(|p| !p.is_empty()) {
1142 if let Some(entries) = self.cache.listing_entries(&at) {
1143 let here = at.strip_prefix(&session.base).unwrap_or("").to_string();
1144 levels.push((here, rows_of(&entries, &HashSet::new())));
1149 }
1150 at.push('/');
1151 at.push_str(part);
1152 }
1153 levels.push((rel.clone(), rows_of(&entries, &sites)));
1154
1155 plain_ok(
1156 "text/html; charset=utf-8",
1157 Bytes::from(autoindex(alias, &rel, &levels, &self.theme.read().await)),
1158 )
1159 }
1160
1161 async fn listing_of(&self, session: &Session, dir: &str) -> Result<Vec<Entry>> {
1163 if let Some(entries) = self.cache.listing_entries(dir) {
1164 return Ok(entries);
1165 }
1166 let entries = session.fs.list_dir(dir).await?;
1167 self.cache.put_listing(dir, &entries);
1168 Ok(entries)
1169 }
1170
1171 async fn sites_among(
1185 &self,
1186 session: &Session,
1187 dir: &str,
1188 entries: &[Entry],
1189 ) -> HashSet<String> {
1190 const MAX_SCAN: usize = 64;
1193
1194 let names: Vec<&str> = entries
1195 .iter()
1196 .filter(|e| e.attrs.is_dir() && e.name != "." && e.name != ".." && !hidden(&e.name))
1197 .map(|e| e.name.as_str())
1198 .take(MAX_SCAN)
1199 .collect();
1200 if names.is_empty() {
1201 return HashSet::new();
1202 }
1203
1204 let paths: Vec<String> = names.iter().map(|n| format!("{dir}/{n}")).collect();
1205 let missing: Vec<String> = paths
1208 .iter()
1209 .filter(|p| self.cache.listing_entries(p).is_none())
1210 .cloned()
1211 .collect();
1212 if !missing.is_empty() {
1213 for (path, got) in missing.iter().zip(session.fs.list_dirs(&missing).await) {
1214 if let Ok(entries) = got {
1215 self.cache.put_listing(path, &entries);
1216 }
1217 }
1221 }
1222
1223 names
1224 .iter()
1225 .zip(paths.iter())
1226 .filter(|(_, path)| {
1227 self.cache.listing_entries(path).is_some_and(|listing| {
1228 listing
1229 .iter()
1230 .any(|e| e.name == "index.html" && !e.attrs.is_dir())
1231 })
1232 })
1233 .map(|(name, _)| (*name).to_string())
1234 .collect()
1235 }
1236
1237 async fn warm_subresources(&self, session: &Session, doc_path: &str, html: &[u8]) {
1259 let refs = prefetch::scan(html, prefetch::MAX_SUBRESOURCES);
1260 if refs.is_empty() {
1261 return;
1262 }
1263 let dir_of_doc = match doc_path.rsplit_once('/') {
1266 Some((head, _)) => head,
1267 None => "",
1268 };
1269
1270 let mut wanted: Vec<(String, Vec<(String, String)>)> = Vec::new();
1273 for r in &refs {
1274 let url = if r.starts_with('/') {
1275 r.clone()
1276 } else {
1277 format!("{dir_of_doc}/{r}")
1278 };
1279 let Ok(resolved) = guard::resolve(&session.base, &url) else {
1280 continue;
1281 };
1282 let chain = components(&session.base, &resolved);
1283 if chain.is_empty() {
1284 continue;
1285 }
1286 if chain.iter().any(|(_, n)| hidden(n)) {
1290 continue;
1291 }
1292 if self.first_symlink_cached(&chain).is_some() {
1297 continue;
1298 }
1299 if !chain
1305 .iter()
1306 .all(|(dir, _)| self.listable(&session.base, dir))
1307 {
1308 continue;
1309 }
1310 wanted.push((resolved, chain));
1311 }
1312
1313 let all: Vec<(String, String)> = wanted.iter().flat_map(|(_, c)| c.clone()).collect();
1314 let held = self.held_listings(session, &all).await;
1318
1319 let mut to_read = Vec::new();
1320 for (resolved, chain) in &wanted {
1321 if first_symlink(&held, chain).is_some() {
1322 continue;
1323 }
1324 let (dir, name) = &chain[chain.len() - 1];
1325 let Some(attrs) = attrs_in(&held, dir, name) else {
1326 continue;
1327 };
1328 if attrs.is_dir() {
1329 continue;
1330 }
1331 let Some(size) = attrs.size else {
1336 continue;
1337 };
1338 if size == 0 || size > CACHE_WHOLE_MAX {
1346 continue;
1347 }
1348 if self.cache.body(resolved, &attrs).is_some() {
1349 continue;
1350 }
1351 to_read.push((resolved.clone(), attrs, size));
1352 }
1353 if to_read.is_empty() {
1354 return;
1355 }
1356
1357 let reqs: Vec<RangeReq> = to_read
1365 .iter()
1366 .map(|(path, _, size)| RangeReq {
1367 path: path.clone(),
1368 offset: 0,
1369 len: *size,
1370 })
1371 .collect();
1372
1373 for ((path, attrs, size), got) in to_read.iter().zip(session.fs.read_ranges(&reqs).await) {
1374 let Ok(body) = got else {
1375 continue;
1376 };
1377 if body.len() as u64 != *size {
1382 continue;
1383 }
1384 self.cache.put_body(path, attrs, Bytes::from(body));
1385 }
1386 }
1387
1388 async fn listings_along(
1406 &self,
1407 session: &Session,
1408 chain: &[(String, String)],
1409 ) -> Result<HashMap<String, Vec<Entry>>, (String, String)> {
1410 let mut held: HashMap<String, Vec<Entry>> = HashMap::new();
1411 let mut missing: Vec<String> = Vec::new();
1412 for (dir, _) in chain {
1413 if held.contains_key(dir) {
1414 continue;
1415 }
1416 match self.cache.listing_entries(dir) {
1417 Some(entries) => {
1418 held.insert(dir.clone(), entries);
1419 }
1420 None if !missing.contains(dir) => missing.push(dir.clone()),
1424 None => {}
1425 }
1426 }
1427 if missing.is_empty() {
1428 return Ok(held);
1429 }
1430 for (dir, result) in missing.iter().zip(session.fs.list_dirs(&missing).await) {
1431 match result {
1432 Ok(entries) => {
1433 self.cache.put_listing(dir, &entries);
1434 held.insert(dir.clone(), entries);
1435 }
1436 Err(e) if crate::fs::is_absent(&e) => {}
1441 Err(e) => return Err((dir.clone(), format!("{e:#}"))),
1442 }
1443 }
1444 Ok(held)
1445 }
1446
1447 fn listable(&self, base: &str, dir: &str) -> bool {
1456 if dir.trim_end_matches('/') == base.trim_end_matches('/') {
1457 return true;
1458 }
1459 components(base, dir).iter().all(|(parent, name)| {
1460 self.cache
1461 .attrs_of(parent, name)
1462 .is_some_and(|a| a.is_dir() && !a.is_symlink())
1463 })
1464 }
1465
1466 async fn held_listings(
1476 &self,
1477 session: &Session,
1478 chain: &[(String, String)],
1479 ) -> HashMap<String, Vec<Entry>> {
1480 self.listings_along(session, chain)
1481 .await
1482 .unwrap_or_default()
1483 }
1484}
1485
1486impl Origin {
1487 fn first_symlink_cached(&self, chain: &[(String, String)]) -> Option<String> {
1493 chain.iter().find_map(|(dir, name)| {
1494 self.cache
1495 .attrs_of(dir, name)
1496 .filter(Attrs::is_symlink)
1497 .map(|_| format!("{dir}/{name}"))
1498 })
1499 }
1500}
1501
1502fn attrs_in(held: &HashMap<String, Vec<Entry>>, dir: &str, name: &str) -> Option<Attrs> {
1504 held.get(dir)
1505 .and_then(|entries| entries.iter().find(|e| e.name == name))
1506 .map(|e| e.attrs)
1507}
1508
1509fn first_symlink(held: &HashMap<String, Vec<Entry>>, chain: &[(String, String)]) -> Option<String> {
1510 chain.iter().find_map(|(dir, name)| {
1511 attrs_in(held, dir, name)
1512 .filter(Attrs::is_symlink)
1513 .map(|_| format!("{dir}/{name}"))
1514 })
1515}
1516
1517impl Origin {
1518 async fn alias_index(&self) -> String {
1519 let names = self.alias_names().await;
1520 let mut s = String::from(
1521 "<!doctype html><html><head><meta charset=\"utf-8\"><title>ssh-browser</title></head><body><h1>ssh-browser</h1><ul>",
1522 );
1523 for name in names {
1524 let href = format!("http://{name}.{}/", self.suffix);
1525 s.push_str("<li><a href=\"");
1526 s.push_str(&escape(&href));
1527 s.push_str("\">");
1528 s.push_str(&escape(&href));
1529 s.push_str("</a></li>");
1530 }
1531 s.push_str("</ul></body></html>");
1532 s
1533 }
1534}
1535
1536fn components(base: &str, file: &str) -> Vec<(String, String)> {
1542 let base = base.trim_end_matches('/');
1543 let relative = file
1544 .strip_prefix(base)
1545 .unwrap_or("")
1546 .trim_start_matches('/');
1547
1548 let mut out = Vec::new();
1549 let mut dir = base.to_string();
1550 for name in relative.split('/').filter(|s| !s.is_empty()) {
1551 out.push((dir.clone(), name.to_string()));
1552 dir = format!("{dir}/{name}");
1553 }
1554 out
1555}
1556
1557const MAX_CONTROL_BODY: usize = 256 * 1024;
1562
1563async fn read_body<B>(body: B) -> Result<Bytes, String>
1564where
1565 B: hyper::body::Body,
1566 B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
1567{
1568 use http_body_util::{BodyExt, Limited};
1569 Limited::new(body, MAX_CONTROL_BODY)
1570 .collect()
1571 .await
1572 .map(|collected| collected.to_bytes())
1573 .map_err(|e| format!("reading the request body: {e}"))
1574}
1575
1576fn header<B>(req: &Request<B>, name: HeaderName) -> Option<String> {
1577 req.headers()
1578 .get(name)
1579 .and_then(|v| v.to_str().ok())
1580 .map(str::to_string)
1581}
1582
1583fn respond(
1585 file: &str,
1586 body: Bytes,
1587 tag: Option<&str>,
1588 wanted: &range::Resolved,
1589 size: u64,
1590) -> Response<Full<Bytes>> {
1591 match wanted {
1592 range::Resolved::Part { start, end } => {
1593 let lo = usize::try_from(*start)
1596 .unwrap_or(usize::MAX)
1597 .min(body.len());
1598 let hi = usize::try_from(end.saturating_add(1))
1599 .unwrap_or(usize::MAX)
1600 .min(body.len())
1601 .max(lo);
1602 partial(
1603 mime::guess(file),
1604 body.slice(lo..hi),
1605 tag,
1606 *start,
1607 *end,
1608 size,
1609 )
1610 }
1611 _ => served(mime::guess(file), body, tag),
1612 }
1613}
1614
1615fn partial(
1616 content_type: &str,
1617 body: Bytes,
1618 tag: Option<&str>,
1619 start: u64,
1620 end: u64,
1621 size: u64,
1622) -> Response<Full<Bytes>> {
1623 let mut b = Response::builder()
1624 .status(StatusCode::PARTIAL_CONTENT)
1625 .header(CONTENT_TYPE, content_type)
1626 .header(CACHE_CONTROL, "no-cache")
1627 .header(ACCEPT_RANGES, "bytes")
1628 .header(CONTENT_RANGE, format!("bytes {start}-{end}/{size}"));
1629 if let Some(tag) = tag {
1630 b = b.header(ETAG, tag);
1631 }
1632 b.body(Full::new(body))
1633 .unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR, "malformed 206"))
1634}
1635
1636fn unsatisfiable(size: u64) -> Response<Full<Bytes>> {
1639 Response::builder()
1640 .status(StatusCode::RANGE_NOT_SATISFIABLE)
1641 .header(CONTENT_TYPE, "text/plain; charset=utf-8")
1642 .header(CONTENT_RANGE, format!("bytes */{size}"))
1643 .body(Full::new(Bytes::from_static(b"range not satisfiable")))
1644 .unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR, "malformed 416"))
1645}
1646
1647fn host_of<B>(req: &Request<B>) -> Option<String> {
1648 req.headers()
1651 .get(HOST)
1652 .and_then(|v| v.to_str().ok())
1653 .map(str::to_string)
1654 .or_else(|| req.uri().host().map(str::to_string))
1655}
1656
1657fn served(content_type: &str, body: Bytes, tag: Option<&str>) -> Response<Full<Bytes>> {
1658 let mut b = Response::builder()
1659 .status(StatusCode::OK)
1660 .header(CONTENT_TYPE, content_type)
1661 .header(CACHE_CONTROL, "no-cache")
1665 .header(ACCEPT_RANGES, "bytes");
1668 if let Some(tag) = tag {
1669 b = b.header(ETAG, tag);
1670 }
1671 b.body(Full::new(body))
1672 .unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR, "malformed response"))
1673}
1674
1675fn plain_ok(content_type: &str, body: Bytes) -> Response<Full<Bytes>> {
1677 served(content_type, body, None)
1678}
1679
1680fn not_modified(tag: &str) -> Response<Full<Bytes>> {
1687 Response::builder()
1688 .status(StatusCode::NOT_MODIFIED)
1689 .header(ETAG, tag)
1690 .header(CACHE_CONTROL, "no-cache")
1691 .body(Full::new(Bytes::new()))
1692 .unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR, "malformed 304"))
1693}
1694
1695fn fail(status: StatusCode, detail: impl Into<String>) -> Response<Full<Bytes>> {
1696 Response::builder()
1697 .status(status)
1698 .header(CONTENT_TYPE, "text/plain; charset=utf-8")
1699 .body(Full::new(Bytes::from(detail.into())))
1700 .expect("a plain-text body with static headers always builds")
1701}
1702
1703fn redirect(to: &str) -> Response<Full<Bytes>> {
1704 Response::builder()
1705 .status(StatusCode::MOVED_PERMANENTLY)
1706 .header(LOCATION, to)
1707 .body(Full::new(Bytes::new()))
1708 .unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR, "bad redirect target"))
1709}
1710
1711fn hidden(name: &str) -> bool {
1723 name.starts_with('.')
1724}
1725
1726struct Row {
1728 name: String,
1729 dir: bool,
1730 site: bool,
1732 size: Option<String>,
1734 modified: Option<String>,
1735 kind: &'static str,
1737}
1738
1739fn rows_of(entries: &[Entry], sites: &HashSet<String>) -> Vec<Row> {
1741 let mut visible: Vec<&Entry> = entries
1742 .iter()
1743 .filter(|e| e.name != "." && e.name != ".." && !hidden(&e.name))
1746 .collect();
1747 visible.sort_by(|a, b| (rank(a, sites), &a.name).cmp(&(rank(b, sites), &b.name)));
1748
1749 visible
1750 .into_iter()
1751 .map(|e| {
1752 let dir = e.attrs.is_dir();
1753 Row {
1754 name: e.name.clone(),
1755 dir,
1756 site: dir && sites.contains(&e.name),
1757 size: if dir {
1758 None
1759 } else {
1760 e.attrs.size.map(human_size)
1761 },
1762 modified: e.attrs.mtime.map(utc_stamp),
1763 kind: if dir { "dir" } else { family(&e.name) },
1764 }
1765 })
1766 .collect()
1767}
1768
1769fn rank(e: &Entry, sites: &HashSet<String>) -> (u8, u8) {
1779 if e.attrs.is_dir() {
1780 (0, u8::from(!sites.contains(&e.name)))
1781 } else {
1782 (1, u8::from(!is_page(&e.name)))
1783 }
1784}
1785
1786fn is_page(name: &str) -> bool {
1787 matches!(extension_of(name).as_deref(), Some("html" | "htm"))
1788}
1789
1790fn family(name: &str) -> &'static str {
1796 match extension_of(name).as_deref() {
1797 Some("html" | "htm") => "k-page",
1798 Some("md" | "txt" | "rst" | "tex" | "bib" | "pdf" | "org" | "adoc") => "k-doc",
1799 Some("json" | "toml" | "yaml" | "yml" | "csv" | "tsv" | "xml" | "ini" | "lock") => "k-data",
1800 Some(
1801 "rs" | "jl" | "py" | "ts" | "js" | "mjs" | "sh" | "c" | "h" | "cpp" | "go" | "rb"
1802 | "lua" | "css" | "scss" | "lean" | "hs" | "java" | "kt" | "swift" | "sql",
1803 ) => "k-code",
1804 Some(
1805 "png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "avif" | "ico" | "mp4" | "webm"
1806 | "mov" | "mp3" | "wav",
1807 ) => "k-media",
1808 _ => "k-plain",
1809 }
1810}
1811
1812fn extension_of(name: &str) -> Option<String> {
1814 let dot = name.rfind('.')?;
1815 if dot == 0 || dot + 1 == name.len() {
1818 return None;
1819 }
1820 Some(name[dot + 1..].to_ascii_lowercase())
1821}
1822
1823fn human_size(n: u64) -> String {
1828 const UNITS: [&str; 5] = ["KiB", "MiB", "GiB", "TiB", "PiB"];
1829 if n < 1024 {
1830 return format!("{n} B");
1831 }
1832 let mut v = n as f64 / 1024.0;
1833 let mut unit = 0;
1834 while v >= 1024.0 && unit + 1 < UNITS.len() {
1835 v /= 1024.0;
1836 unit += 1;
1837 }
1838 if v < 10.0 {
1841 format!("{v:.1} {}", UNITS[unit])
1842 } else {
1843 format!("{v:.0} {}", UNITS[unit])
1844 }
1845}
1846
1847fn utc_stamp(secs: u32) -> String {
1854 let secs = i64::from(secs);
1855 let (y, m, d) = civil_from_days(secs.div_euclid(86_400));
1856 let rest = secs.rem_euclid(86_400);
1857 let (hh, mm) = (rest / 3600, (rest % 3600) / 60);
1858 format!("{y:04}-{m:02}-{d:02} {hh:02}:{mm:02}")
1859}
1860
1861fn civil_from_days(z: i64) -> (i64, u32, u32) {
1865 let z = z + 719_468;
1866 let era = if z >= 0 { z } else { z - 146_096 }.div_euclid(146_097);
1867 let doe = (z - era * 146_097) as u64;
1868 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
1869 let y = yoe as i64 + era * 400;
1870 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1871 let mp = (5 * doy + 2) / 153;
1872 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
1873 let m = u32::try_from(if mp < 10 { mp + 3 } else { mp - 9 }).unwrap_or(1);
1874 (if m <= 2 { y + 1 } else { y }, m, d)
1875}
1876
1877const LISTING_CSS: &str = "\
1886*{box-sizing:border-box}\
1887html{background:var(--bg)}\
1888body{color:var(--fg);font:13px/1.5 system-ui,-apple-system,Segoe UI,sans-serif;margin:0}\
1889header{align-items:baseline;background:var(--bg);border-bottom:1px solid var(--line);\
1890display:flex;gap:6px;padding:7px 12px;position:sticky;top:0;z-index:1}\
1891header b{font-size:12px;font-weight:600;letter-spacing:.04em}\
1892header span{color:var(--dim);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;\
1893font-size:11px;overflow-wrap:anywhere}\
1894#tree{padding:4px 0 40px}\
1895ul{list-style:none;margin:0;padding:0}\
1896li ul{border-left:1px solid var(--line);margin-left:15px}\
1897li>ul{display:none}\
1898li.open>ul{display:block}\
1899.row{align-items:center;color:inherit;display:grid;gap:6px;\
1900grid-template-columns:14px 14px 1fr auto auto;line-height:22px;padding-right:12px;\
1901text-decoration:none;white-space:nowrap}\
1902.row:hover{background:var(--hover)}\
1903.row.here{background:var(--sel)}\
1904.row.here .size,.row.here .when{color:var(--dim)}\
1905.row:focus-visible{outline:1px solid var(--accent);outline-offset:-1px}\
1906.tw{color:var(--dim);font-size:11px;line-height:22px;text-align:center;\
1907transition:transform .1s linear}\
1908li.open>.row .tw{transform:rotate(90deg)}\
1909.ico{border-radius:2px;height:9px;justify-self:center;width:9px}\
1910.dir>.ico{background:var(--dim);border-radius:1px 3px 3px 3px}\
1911.site>.ico{background:var(--accent);border-radius:1px 3px 3px 3px}\
1912.site>.name{color:var(--accent)}\
1913.k-page>.ico{background:var(--k-page)}\
1914.k-page>.name{color:var(--k-page)}\
1915.k-doc>.ico{background:var(--k-doc)}\
1916.k-data>.ico{background:var(--k-data)}\
1917.k-code>.ico{background:var(--k-code)}\
1918.k-media>.ico{background:var(--k-media)}\
1919.k-plain>.ico{background:var(--k-plain)}\
1920.name{overflow:hidden;text-overflow:ellipsis}\
1921.size,.when{color:var(--faint);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;\
1922font-size:11px;font-variant-numeric:tabular-nums}\
1923.size{text-align:right}\
1924.row.busy .tw{opacity:.4}\
1925.row.failed .when{color:var(--k-page)}\
1926.empty{color:var(--faint);padding:10px 16px}\
1927@media(max-width:620px){.when{display:none}}";
1928
1929const LISTING_JS: &str = "\
1939const tree=document.getElementById('tree');\
1940tree.addEventListener('click',async e=>{\
1941const row=e.target.closest('a.row');\
1942if(!row||row.dataset.dir!=='1')return;\
1943e.preventDefault();\
1944const li=row.parentElement;\
1945if(li.querySelector(':scope>ul')){li.classList.toggle('open');mark(row);return;}\
1946row.classList.add('busy');\
1947try{\
1948const res=await fetch(row.getAttribute('href')+'?ls');\
1949if(!res.ok)throw new Error(res.status);\
1950li.insertAdjacentHTML('beforeend',await res.text());\
1951li.classList.add('open');mark(row);\
1952}catch(err){row.classList.add('failed');\
1953row.querySelector('.when').textContent='could not be listed: '+err.message;}\
1954finally{row.classList.remove('busy');}\
1955});\
1956function mark(row){\
1957for(const other of tree.querySelectorAll('a.row.here'))other.classList.remove('here');\
1958row.classList.add('here');\
1959history.replaceState(null,'',row.getAttribute('href'));\
1960document.querySelector('header span').textContent=\
1961decodeURIComponent(new URL(row.href).pathname);\
1962}";
1963
1964fn render_level(out: &mut String, path: &str, rows: &[Row], open: &[(String, Vec<Row>)]) {
1970 out.push_str("<ul>");
1971 for row in rows {
1972 let here = format!("{path}/{}", row.name);
1973 let deeper = open.first().filter(|(next, _)| *next == here);
1974
1975 out.push_str(if deeper.is_some() {
1976 "<li class=\"open\">"
1977 } else {
1978 "<li>"
1979 });
1980 out.push_str("<a class=\"row ");
1981 out.push_str(match (row.dir, row.site) {
1982 (true, true) => "site",
1985 (true, false) => "dir",
1986 (false, _) => row.kind,
1987 });
1988 if deeper.is_some() && open.len() == 1 {
1991 out.push_str(" here");
1992 }
1993 out.push_str("\" href=\"");
1994 out.push_str(path);
1995 out.push('/');
1996 out.push_str(&url_escape(&row.name));
1997 if row.dir {
1998 out.push('/');
1999 }
2000 out.push_str(if row.dir {
2002 "\" data-dir=\"1\"><span class=\"tw\">\u{25b8}</span>"
2003 } else {
2004 "\"><span class=\"tw\"></span>"
2005 });
2006 out.push_str("<span class=\"ico\"></span><span class=\"name\">");
2007 out.push_str(&escape(&row.name));
2008 out.push_str("</span><span class=\"size\">");
2009 out.push_str(row.size.as_deref().unwrap_or(""));
2010 out.push_str("</span><span class=\"when\">");
2011 out.push_str(row.modified.as_deref().unwrap_or(""));
2012 out.push_str("</span></a>");
2013
2014 if let Some((next, rows)) = deeper {
2015 render_level(out, next, rows, &open[1..]);
2016 }
2017 out.push_str("</li>");
2018 }
2019 out.push_str("</ul>");
2020}
2021
2022fn autoindex(alias: &str, rel: &str, levels: &[(String, Vec<Row>)], theme: &str) -> String {
2028 let shown = if rel.is_empty() { "/" } else { rel };
2029 let mut s = String::from("<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">");
2030 s.push_str("<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>");
2031 s.push_str(&escape(&format!("{shown} \u{b7} {alias}")));
2032 s.push_str("</title><style>");
2033 s.push_str(&theme::css_for(theme));
2035 s.push_str(LISTING_CSS);
2036 s.push_str("</style></head><body><header><b>");
2037 s.push_str(&escape(alias));
2038 s.push_str("</b><span>");
2039 s.push_str(&escape(shown));
2040 s.push_str("</span></header><div id=\"tree\">");
2041
2042 match levels.split_first() {
2043 Some(((path, rows), rest)) if !rows.is_empty() => render_level(&mut s, path, rows, rest),
2044 _ => s.push_str("<p class=\"empty\">This directory is empty.</p>"),
2047 }
2048
2049 s.push_str("</div><script>");
2050 s.push_str(LISTING_JS);
2051 s.push_str("</script></body></html>");
2052 s
2053}
2054
2055fn escape(s: &str) -> String {
2058 s.replace('&', "&")
2059 .replace('<', "<")
2060 .replace('>', ">")
2061 .replace('"', """)
2062}
2063
2064fn url_escape(s: &str) -> String {
2067 let mut out = String::with_capacity(s.len());
2068 for b in s.bytes() {
2069 match b {
2070 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
2071 out.push(b as char);
2072 }
2073 _ => out.push_str(&format!("%{b:02X}")),
2074 }
2075 }
2076 out
2077}
2078
2079#[cfg(test)]
2080mod tests {
2081 use super::*;
2082 use crate::sftp::wire::Attrs;
2083 use crate::testing::{FakeRemote, dir_attrs, file_attrs, symlink_attrs};
2084 use http_body_util::{BodyExt, Empty};
2085
2086 const TEST_TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
2087
2088 async fn body_of(res: Response<Full<Bytes>>) -> Bytes {
2089 res.into_body()
2090 .collect()
2091 .await
2092 .expect("a Full body always collects")
2093 .to_bytes()
2094 }
2095
2096 fn loopback(path: &str, token: Option<&str>) -> Request<Empty<Bytes>> {
2098 let mut b = Request::builder().uri(path).header(HOST, "127.0.0.1:7391");
2099 if let Some(t) = token {
2100 b = b.header(control::TOKEN_HEADER, t);
2101 }
2102 b.body(Empty::<Bytes>::new()).expect("request builds")
2103 }
2104
2105 fn from_site(path: &str, site: Option<&str>) -> Request<Empty<Bytes>> {
2108 let mut b = Request::builder().uri(path).header(HOST, "127.0.0.1:7391");
2109 if let Some(site) = site {
2110 b = b.header(control::FETCH_SITE_HEADER, site);
2111 }
2112 b.body(Empty::<Bytes>::new()).expect("request builds")
2113 }
2114
2115 fn control_post(path: &str, token: Option<&str>, body: &str) -> Request<Full<Bytes>> {
2116 let mut b = Request::builder()
2117 .method(Method::POST)
2118 .uri(path)
2119 .header(HOST, "127.0.0.1:7391");
2120 if let Some(t) = token {
2121 b = b.header(control::TOKEN_HEADER, t);
2122 }
2123 b.body(Full::new(Bytes::from(body.to_string())))
2124 .expect("request builds")
2125 }
2126
2127 fn ranged(path: &str, range: &str) -> Request<Empty<Bytes>> {
2128 Request::builder()
2129 .uri(format!("http://docs.ssh-browser{path}"))
2130 .header(HOST, "docs.ssh-browser")
2131 .header(RANGE, range)
2132 .body(Empty::new())
2133 .expect("request builds")
2134 }
2135
2136 async fn origin_with(remote: FakeRemote) -> Origin {
2139 origin_with_cache(remote, Cache::default()).await
2140 }
2141
2142 async fn origin_with_cache(remote: FakeRemote, cache: Cache) -> Origin {
2143 let fs = remote.spawn().await;
2144 let mut sessions = HashMap::new();
2145 sessions.insert(
2146 "docs".to_string(),
2147 Arc::new(Session {
2148 host: "nowhere".to_string(),
2149 base: "/srv".to_string(),
2150 fs,
2151 }),
2152 );
2153 Origin {
2154 suffix: "ssh-browser".to_string(),
2155 port: 7391,
2156 sessions: RwLock::new(sessions),
2157 cache,
2158 theme: RwLock::new(theme::DEFAULT.to_string()),
2159 token: Token::from_hex(TEST_TOKEN),
2160 }
2161 }
2162
2163 fn get(path: &str, if_none_match: Option<&str>) -> Request<Empty<Bytes>> {
2164 let mut b = Request::builder()
2165 .uri(format!("http://docs.ssh-browser{path}"))
2166 .header(HOST, "docs.ssh-browser");
2167 if let Some(tag) = if_none_match {
2168 b = b.header(IF_NONE_MATCH, tag);
2169 }
2170 b.body(Empty::new()).expect("request builds")
2171 }
2172
2173 async fn trips(origin: &Origin) -> u64 {
2174 origin
2175 .sessions
2176 .read()
2177 .await
2178 .values()
2179 .map(|s| s.fs.round_trips())
2180 .sum()
2181 }
2182
2183 fn one_page() -> FakeRemote {
2184 FakeRemote::new()
2185 .dir("/srv", vec![("a.html", file_attrs(5, 100))])
2186 .file("/srv/a.html", b"hello")
2187 }
2188
2189 fn page_with_subresources(n: usize) -> FakeRemote {
2192 let mut html = String::from(
2193 "<!doctype html><html><head><link rel=\"stylesheet\" href=\"assets/style.css\"><script src=\"assets/app.js\"></script></head><body>",
2194 );
2195 for i in 0..n {
2196 html.push_str(&format!("<img src=\"assets/{i}.png\">"));
2197 }
2198 html.push_str("</body></html>");
2199
2200 let mut assets = vec!["style.css".to_string(), "app.js".to_string()];
2201 assets.extend((0..n).map(|i| format!("{i}.png")));
2202
2203 let mut remote = FakeRemote::new()
2204 .dir(
2205 "/srv",
2206 vec![
2207 ("index.html", file_attrs(html.len() as u64, 100)),
2208 ("assets", dir_attrs()),
2209 ],
2210 )
2211 .dir(
2212 "/srv/assets",
2213 assets
2214 .iter()
2215 .map(|name| (name.as_str(), file_attrs(3, 1)))
2216 .collect(),
2217 )
2218 .file("/srv/index.html", html.as_bytes());
2219 for name in &assets {
2220 remote = remote.file(&format!("/srv/assets/{name}"), b"xxx");
2221 }
2222 remote
2223 }
2224
2225 #[tokio::test]
2232 async fn a_pages_subresources_are_already_held_when_the_browser_asks_for_them() {
2233 const N: usize = 40;
2234 let origin = origin_with(page_with_subresources(N)).await;
2235
2236 let res = origin.handle(get("/index.html", None)).await;
2237 assert_eq!(res.status(), StatusCode::OK);
2238
2239 let before = trips(&origin).await;
2240 for i in 0..N {
2241 let path = format!("/assets/{i}.png");
2242 let res = origin.handle(get(&path, None)).await;
2243 assert_eq!(res.status(), StatusCode::OK, "{path}");
2244 assert_eq!(&body_of(res).await[..], b"xxx", "{path}");
2245 }
2246 for name in ["style.css", "app.js"] {
2247 let res = origin.handle(get(&format!("/assets/{name}"), None)).await;
2248 assert_eq!(res.status(), StatusCode::OK, "{name}");
2249 }
2250
2251 assert_eq!(
2252 trips(&origin).await - before,
2253 0,
2254 "reading the page's own references is what makes these free"
2255 );
2256 }
2257
2258 #[tokio::test]
2261 async fn serving_a_page_costs_the_same_however_many_subresources_it_has() {
2262 async fn cost(n: usize) -> u64 {
2263 let origin = origin_with(page_with_subresources(n)).await;
2264 let before = trips(&origin).await;
2265 let res = origin.handle(get("/index.html", None)).await;
2266 assert_eq!(res.status(), StatusCode::OK);
2267 trips(&origin).await - before
2268 }
2269 assert_eq!(cost(4).await, cost(40).await);
2270 }
2271
2272 fn page_referring_to(refs: &[&str], extra: Vec<(&'static str, Attrs)>) -> FakeRemote {
2276 let mut html = String::from("<!doctype html><html><body>");
2277 for r in refs {
2278 html.push_str(&format!("<img src=\"{r}\">"));
2279 }
2280 html.push_str("</body></html>");
2281
2282 let mut entries = vec![("index.html", file_attrs(html.len() as u64, 100))];
2283 entries.extend(extra);
2284 FakeRemote::new()
2285 .dir("/srv", entries)
2286 .file("/srv/index.html", html.as_bytes())
2287 }
2288
2289 async fn cost_of_serving(refs: &[&str], extra: Vec<(&'static str, Attrs)>) -> u64 {
2290 let origin = origin_with(page_referring_to(refs, extra)).await;
2291 let before = trips(&origin).await;
2292 let res = origin.handle(get("/index.html", None)).await;
2293 assert_eq!(res.status(), StatusCode::OK);
2294 trips(&origin).await - before
2295 }
2296
2297 #[tokio::test]
2300 async fn a_page_cannot_prefetch_its_way_out_of_the_alias_base() {
2301 let baseline = cost_of_serving(&[], vec![]).await;
2302 assert_eq!(
2303 cost_of_serving(&["../../../etc/passwd", "/../../etc/shadow"], vec![]).await,
2304 baseline,
2305 "an escaping reference is gone before anything is listed or read"
2306 );
2307 }
2308
2309 #[tokio::test]
2312 async fn a_page_cannot_prefetch_through_a_symlink() {
2313 let link = || vec![("link", symlink_attrs())];
2314 let baseline = cost_of_serving(&[], link()).await;
2315 assert_eq!(
2316 cost_of_serving(&["link/inside.png"], link()).await,
2317 baseline,
2318 "the symlink is known from the listing the page itself needed"
2319 );
2320
2321 let origin = origin_with(page_referring_to(&["link/inside.png"], link())).await;
2324 assert_eq!(
2325 origin.handle(get("/index.html", None)).await.status(),
2326 StatusCode::OK
2327 );
2328 assert_eq!(
2329 origin.handle(get("/link/inside.png", None)).await.status(),
2330 StatusCode::FORBIDDEN
2331 );
2332 }
2333
2334 #[tokio::test]
2346 async fn a_page_cannot_get_a_symlink_below_an_unlisted_directory_opened() {
2347 let html = "<!doctype html><html><body><img src=\"assets/link/secret.txt\"></body></html>";
2348 let origin = origin_with(
2349 FakeRemote::new()
2350 .dir(
2351 "/srv",
2352 vec![
2353 ("index.html", file_attrs(html.len() as u64, 100)),
2354 ("assets", dir_attrs()),
2355 ],
2356 )
2357 .dir("/srv/assets", vec![("link", symlink_attrs())])
2358 .dir("/srv/assets/link", vec![("secret.txt", file_attrs(9, 1))])
2360 .file("/srv/index.html", html.as_bytes())
2361 .file("/srv/assets/link/secret.txt", b"elsewhere"),
2362 )
2363 .await;
2364
2365 assert_eq!(
2366 origin.handle(get("/index.html", None)).await.status(),
2367 StatusCode::OK
2368 );
2369 assert!(
2370 !origin.cache.has_listing("/srv/assets/link"),
2371 "the daemon listed the directory a symlink points at"
2372 );
2373
2374 assert_eq!(
2377 origin
2378 .handle(get("/assets/link/secret.txt", None))
2379 .await
2380 .status(),
2381 StatusCode::FORBIDDEN
2382 );
2383 }
2384
2385 #[tokio::test]
2389 async fn a_reference_in_a_real_subdirectory_is_still_prefetched() {
2390 let html = "<!doctype html><html><body><img src=\"assets/x.png\"></body></html>";
2391 let origin = origin_with(
2392 FakeRemote::new()
2393 .dir(
2394 "/srv",
2395 vec![
2396 ("index.html", file_attrs(html.len() as u64, 100)),
2397 ("assets", dir_attrs()),
2398 ],
2399 )
2400 .dir("/srv/assets", vec![("x.png", file_attrs(3, 1))])
2401 .file("/srv/index.html", html.as_bytes())
2402 .file("/srv/assets/x.png", b"xxx"),
2403 )
2404 .await;
2405
2406 assert_eq!(
2407 origin.handle(get("/index.html", None)).await.status(),
2408 StatusCode::OK
2409 );
2410 let before = trips(&origin).await;
2411 let res = origin.handle(get("/assets/x.png", None)).await;
2412 assert_eq!(res.status(), StatusCode::OK);
2413 assert_eq!(&body_of(res).await[..], b"xxx");
2414 assert_eq!(
2415 trips(&origin).await - before,
2416 0,
2417 "a subdirectory one level down must still be warmed"
2418 );
2419 }
2420
2421 #[tokio::test]
2428 async fn a_large_subresource_costs_what_a_small_one_costs() {
2429 async fn cost(bytes: usize) -> u64 {
2430 let html = "<!doctype html><html><body><img src=\"assets/big.bin\"></body></html>";
2431 let origin = origin_with(
2432 FakeRemote::new()
2433 .dir(
2434 "/srv",
2435 vec![
2436 ("index.html", file_attrs(html.len() as u64, 100)),
2437 ("assets", dir_attrs()),
2438 ],
2439 )
2440 .dir(
2441 "/srv/assets",
2442 vec![("big.bin", file_attrs(bytes as u64, 1))],
2443 )
2444 .file("/srv/index.html", html.as_bytes())
2445 .file("/srv/assets/big.bin", &vec![b'x'; bytes]),
2446 )
2447 .await;
2448
2449 let before = trips(&origin).await;
2450 assert_eq!(
2451 origin.handle(get("/index.html", None)).await.status(),
2452 StatusCode::OK
2453 );
2454 let spent = trips(&origin).await - before;
2455
2456 let at = trips(&origin).await;
2459 let res = origin.handle(get("/assets/big.bin", None)).await;
2460 assert_eq!(res.status(), StatusCode::OK);
2461 assert_eq!(body_of(res).await.len(), bytes);
2462 assert_eq!(
2463 trips(&origin).await - at,
2464 0,
2465 "{bytes} bytes should have been held"
2466 );
2467
2468 spent
2469 }
2470
2471 assert_eq!(cost(1024).await, cost(200 * 1024).await);
2473 }
2474
2475 #[tokio::test]
2485 async fn a_subresource_the_listing_calls_empty_is_not_prefetched() {
2486 let html = "<!doctype html><html><body><img src=\"assets/x.png\"></body></html>";
2487 let sizeless = Attrs {
2488 permissions: Some(0o100644),
2489 mtime: Some(1),
2490 ..Attrs::default()
2491 };
2492 let origin = origin_with(
2493 FakeRemote::new()
2494 .dir(
2495 "/srv",
2496 vec![
2497 ("index.html", file_attrs(html.len() as u64, 100)),
2498 ("assets", dir_attrs()),
2499 ],
2500 )
2501 .dir("/srv/assets", vec![("x.png", sizeless)])
2502 .file("/srv/index.html", html.as_bytes())
2503 .file("/srv/assets/x.png", b"xxx"),
2504 )
2505 .await;
2506
2507 assert_eq!(
2508 origin.handle(get("/index.html", None)).await.status(),
2509 StatusCode::OK
2510 );
2511 let res = origin.handle(get("/assets/x.png", None)).await;
2512 assert_eq!(res.status(), StatusCode::OK);
2513 assert_eq!(
2514 &body_of(res).await[..],
2515 b"xxx",
2516 "the real request must still serve the whole file"
2517 );
2518 }
2519
2520 #[tokio::test]
2522 async fn an_oversized_subresource_is_not_prefetched() {
2523 async fn cost(size: u64) -> u64 {
2524 let html =
2525 "<!doctype html><html><body><video src=\"assets/film.mp4\"></video></body></html>";
2526 let origin = origin_with(
2527 FakeRemote::new()
2528 .dir(
2529 "/srv",
2530 vec![
2531 ("index.html", file_attrs(html.len() as u64, 100)),
2532 ("assets", dir_attrs()),
2533 ],
2534 )
2535 .dir("/srv/assets", vec![("film.mp4", file_attrs(size, 1))])
2536 .file("/srv/index.html", html.as_bytes())
2537 .file("/srv/assets/film.mp4", b"xxx"),
2538 )
2539 .await;
2540 let before = trips(&origin).await;
2541 assert_eq!(
2542 origin.handle(get("/index.html", None)).await.status(),
2543 StatusCode::OK
2544 );
2545 trips(&origin).await - before
2546 }
2547
2548 let read_it = cost(3).await;
2551 let skipped = cost(CACHE_WHOLE_MAX + 1).await;
2552 assert!(
2553 skipped < read_it,
2554 "an oversized subresource cost {skipped} against {read_it} for a small one"
2555 );
2556 }
2557
2558 #[tokio::test]
2570 async fn the_port_is_taken_before_any_host_is_connected() {
2571 let held = TcpListener::bind(("127.0.0.1", 0))
2572 .await
2573 .expect("a free port");
2574 let port = held.local_addr().expect("its address").port();
2575
2576 const NOWHERE: &str = "a-host-that-cannot-resolve.invalid";
2577 let result = Origin::bind(
2578 vec![Alias::new("docs", NOWHERE, Some("/srv")).expect("a valid alias")],
2579 "ssh-browser".to_string(),
2580 port,
2581 Token::from_hex(TEST_TOKEN),
2582 theme::DEFAULT.to_string(),
2583 )
2584 .await;
2585
2586 let Err(e) = result else {
2587 panic!("binding a port that is already held must fail");
2588 };
2589 let text = format!("{e:#}");
2590 assert!(
2591 text.contains(&format!("bind 127.0.0.1:{port}")),
2592 "the error should name the port, got: {text}"
2593 );
2594 assert!(
2595 !text.contains(NOWHERE),
2596 "the ssh host was reached before the port was taken: {text}"
2597 );
2598 }
2599
2600 #[tokio::test]
2603 async fn a_dot_name_is_never_served() {
2604 let origin = origin_with(
2605 FakeRemote::new()
2606 .dir(
2607 "/srv",
2608 vec![
2609 ("Vault", dir_attrs()),
2610 (".ssh", dir_attrs()),
2611 (".netrc", file_attrs(9, 1)),
2612 ],
2613 )
2614 .dir("/srv/.ssh", vec![("id_ed25519", file_attrs(9, 1))])
2615 .dir("/srv/Vault", vec![(".git", dir_attrs())])
2616 .dir("/srv/Vault/.git", vec![("config", file_attrs(9, 1))])
2617 .file("/srv/.ssh/id_ed25519", b"a-secret-")
2618 .file("/srv/.netrc", b"a-secret-")
2619 .file("/srv/Vault/.git/config", b"a-secret-"),
2620 )
2621 .await;
2622
2623 for path in [
2624 "/.ssh/id_ed25519",
2625 "/.netrc",
2626 "/Vault/.git/config",
2628 "/.ssh/",
2630 ] {
2631 assert_eq!(
2632 origin.handle(get(path, None)).await.status(),
2633 StatusCode::FORBIDDEN,
2634 "{path}"
2635 );
2636 }
2637 }
2638
2639 #[tokio::test]
2642 async fn a_listing_does_not_mention_dot_names() {
2643 let origin = origin_with(FakeRemote::new().dir(
2644 "/srv",
2645 vec![
2646 ("Vault", dir_attrs()),
2647 (".ssh", dir_attrs()),
2648 (".obsidian", dir_attrs()),
2649 ],
2650 ))
2651 .await;
2652
2653 let body = body_of(origin.handle(get("/", None)).await).await;
2654 let listing = String::from_utf8_lossy(&body);
2655 assert!(listing.contains("Vault"), "the ordinary entry is listed");
2656 assert!(!listing.contains(".ssh"), "got: {listing}");
2657 assert!(!listing.contains(".obsidian"), "got: {listing}");
2658 }
2659
2660 #[tokio::test]
2663 async fn a_page_cannot_prefetch_a_dot_name() {
2664 let html = "<!doctype html><html><body><img src=\".ssh/id_ed25519\"></body></html>";
2665 let origin = origin_with(
2666 FakeRemote::new()
2667 .dir(
2668 "/srv",
2669 vec![
2670 ("index.html", file_attrs(html.len() as u64, 100)),
2671 (".ssh", dir_attrs()),
2672 ],
2673 )
2674 .dir("/srv/.ssh", vec![("id_ed25519", file_attrs(9, 1))])
2675 .file("/srv/index.html", html.as_bytes())
2676 .file("/srv/.ssh/id_ed25519", b"a-secret-"),
2677 )
2678 .await;
2679
2680 assert_eq!(
2681 origin.handle(get("/index.html", None)).await.status(),
2682 StatusCode::OK
2683 );
2684 assert!(
2685 !origin.cache.has_listing("/srv/.ssh"),
2686 "the page got the daemon to list a directory it will not serve"
2687 );
2688 assert_eq!(
2689 origin.handle(get("/.ssh/id_ed25519", None)).await.status(),
2690 StatusCode::FORBIDDEN
2691 );
2692 }
2693
2694 fn deep_tree() -> FakeRemote {
2696 FakeRemote::new()
2697 .dir("/srv", vec![("a", dir_attrs())])
2698 .dir("/srv/a", vec![("b", dir_attrs())])
2699 .dir("/srv/a/b", vec![("c", dir_attrs())])
2700 .dir("/srv/a/b/c", vec![("d.html", file_attrs(5, 100))])
2701 .file("/srv/a/b/c/d.html", b"deep!")
2702 }
2703
2704 fn entry(name: &str, dir: bool) -> Entry {
2705 Entry {
2706 name: name.to_string(),
2707 attrs: Attrs {
2708 permissions: Some(if dir { 0o040755 } else { 0o100644 }),
2709 ..Attrs::default()
2710 },
2711 }
2712 }
2713
2714 fn listing(alias: &str, rel: &str, entries: &[Entry]) -> String {
2717 let levels = vec![(rel.to_string(), rows_of(entries, &HashSet::new()))];
2718 autoindex(alias, rel, &levels, theme::DEFAULT)
2719 }
2720
2721 #[test]
2722 fn a_hostile_filename_cannot_inject_script_into_our_origin() {
2723 let page = listing("docs", "", &[entry("<script>alert(1)</script>", false)]);
2724 assert!(!page.contains("<script>alert"));
2725 assert!(page.contains("<script>"));
2726 }
2727
2728 #[test]
2731 fn directories_come_first_and_pages_lead_the_files() {
2732 let page = listing(
2733 "docs",
2734 "",
2735 &[
2736 entry("b.txt", false),
2737 entry("z-dir", true),
2738 entry("a.txt", false),
2739 entry("report.html", false),
2740 ],
2741 );
2742 let dir = page.find("z-dir").expect("dir listed");
2743 let html = page.find("report.html").expect("page listed");
2744 let a = page.find("a.txt").expect("a listed");
2745 let b = page.find("b.txt").expect("b listed");
2746 assert!(
2747 dir < html,
2748 "directories come first, whatever they are called"
2749 );
2750 assert!(html < a, "then the pages, ahead of the other files");
2751 assert!(a < b, "and the rest by name");
2752 assert!(!page.contains("<h2"), "{page}");
2754 }
2755
2756 #[test]
2759 fn only_html_counts_as_a_page() {
2760 let page = listing(
2761 "docs",
2762 "",
2763 &[
2764 entry("a.htm", false),
2765 entry("b.html.bak", false),
2766 entry("c.xhtml", false),
2767 ],
2768 );
2769 let htm = page.find("a.htm").expect("htm listed");
2770 let bak = page.find("b.html.bak").expect("bak listed");
2771 let xhtml = page.find("c.xhtml").expect("xhtml listed");
2772 assert!(htm < bak && htm < xhtml, "only the .htm leads: {page}");
2773 assert!(
2776 page.contains("class=\"row k-page\" href=\"/a.htm\""),
2777 "{page}"
2778 );
2779 }
2780
2781 #[test]
2782 fn hrefs_are_url_escaped() {
2783 let page = listing("docs", "", &[entry("a b#c.html", false)]);
2784 assert!(page.contains("href=\"/a%20b%23c.html\""));
2785 }
2786
2787 #[test]
2790 fn the_header_names_the_alias_and_where_you_are() {
2791 let page = listing("panza", "/Vault/infra", &[]);
2792 assert!(page.contains("<b>panza</b>"), "{page}");
2793 assert!(page.contains("<span>/Vault/infra</span>"), "{page}");
2794 }
2795
2796 #[tokio::test]
2803 async fn the_whole_path_is_expanded_and_the_deepest_is_selected() {
2804 let origin = origin_with(
2805 FakeRemote::new()
2806 .dir("/srv", vec![("a", dir_attrs()), ("elsewhere", dir_attrs())])
2807 .dir("/srv/a", vec![("b", dir_attrs()), ("sibling", dir_attrs())])
2808 .dir("/srv/a/b", vec![("leaf.txt", file_attrs(3, 1))])
2809 .dir("/srv/elsewhere", vec![])
2810 .dir("/srv/a/sibling", vec![]),
2811 )
2812 .await;
2813
2814 let body = String::from_utf8(
2815 body_of(origin.handle(get("/a/b/", None)).await)
2816 .await
2817 .to_vec(),
2818 )
2819 .expect("utf-8");
2820
2821 assert!(body.contains("<li class=\"open\">"), "{body}");
2823 assert!(body.contains("href=\"/a/\""), "{body}");
2824 assert!(body.contains("row dir here\" href=\"/a/b/\""), "{body}");
2826 assert!(body.contains("leaf.txt"), "{body}");
2828 assert!(body.contains("elsewhere"), "{body}");
2831 assert!(body.contains("sibling"), "{body}");
2832 }
2833
2834 #[tokio::test]
2838 async fn the_tree_costs_what_one_directory_cost() {
2839 let deep = origin_with(deep_tree()).await;
2840 let before = trips(&deep).await;
2841 assert_eq!(
2842 deep.handle(get("/a/b/c/", None)).await.status(),
2843 StatusCode::OK
2844 );
2845 let four = trips(&deep).await - before;
2846
2847 let shallow = origin_with(one_page()).await;
2848 let before = trips(&shallow).await;
2849 assert_eq!(
2850 shallow.handle(get("/", None)).await.status(),
2851 StatusCode::OK
2852 );
2853 let one = trips(&shallow).await - before;
2854
2855 assert!(
2858 four <= one + 2,
2859 "a tree four deep cost {four} round trips against {one} for one directory"
2860 );
2861 }
2862
2863 #[tokio::test]
2866 async fn asking_for_one_level_answers_with_its_rows() {
2867 let origin = origin_with(
2868 FakeRemote::new()
2869 .dir("/srv", vec![("sub", dir_attrs())])
2870 .dir("/srv/sub", vec![("inner.md", file_attrs(4, 1))]),
2871 )
2872 .await;
2873
2874 let req = Request::builder()
2875 .uri("http://docs.ssh-browser/sub/?ls")
2876 .header(HOST, "docs.ssh-browser")
2877 .body(Empty::<Bytes>::new())
2878 .expect("request builds");
2879 let res = origin.handle(req).await;
2880 assert_eq!(res.status(), StatusCode::OK);
2881
2882 let body = String::from_utf8(body_of(res).await.to_vec()).expect("utf-8");
2883 assert!(body.starts_with("<ul>"), "{body}");
2885 assert!(!body.contains("<html"), "{body}");
2886 assert!(body.contains("href=\"/sub/inner.md\""), "{body}");
2888 }
2889
2890 #[tokio::test]
2893 async fn asking_for_one_level_does_not_mention_dot_names() {
2894 let origin = origin_with(
2895 FakeRemote::new()
2896 .dir("/srv", vec![("sub", dir_attrs())])
2897 .dir(
2898 "/srv/sub",
2899 vec![("shown.md", file_attrs(4, 1)), (".hidden", dir_attrs())],
2900 ),
2901 )
2902 .await;
2903
2904 let req = Request::builder()
2905 .uri("http://docs.ssh-browser/sub/?ls")
2906 .header(HOST, "docs.ssh-browser")
2907 .body(Empty::<Bytes>::new())
2908 .expect("request builds");
2909 let body =
2910 String::from_utf8(body_of(origin.handle(req).await).await.to_vec()).expect("utf-8");
2911 assert!(body.contains("shown.md"), "{body}");
2912 assert!(!body.contains(".hidden"), "{body}");
2913 }
2914
2915 #[test]
2916 fn sizes_read_the_way_a_file_manager_shows_them() {
2917 assert_eq!(human_size(0), "0 B");
2918 assert_eq!(human_size(999), "999 B");
2919 assert_eq!(human_size(1024), "1.0 KiB");
2920 assert_eq!(human_size(1536), "1.5 KiB");
2921 assert_eq!(human_size(10 * 1024 * 1024), "10 MiB");
2923 assert_eq!(human_size(9_961_472), "9.5 MiB");
2924 assert_eq!(human_size(3 * 1024 * 1024 * 1024), "3.0 GiB");
2925 }
2926
2927 #[test]
2930 fn timestamps_are_the_utc_civil_date() {
2931 assert_eq!(utc_stamp(0), "1970-01-01 00:00");
2932 assert_eq!(utc_stamp(86_399), "1970-01-01 23:59");
2933 assert_eq!(utc_stamp(86_400), "1970-01-02 00:00");
2934 assert_eq!(utc_stamp(951_782_400), "2000-02-29 00:00");
2936 assert_eq!(utc_stamp(4_107_456_000), "2100-02-28 00:00");
2940 assert_eq!(utc_stamp(4_107_542_400), "2100-03-01 00:00");
2941 assert_eq!(utc_stamp(1_757_745_840), "2025-09-13 06:44");
2942 }
2943
2944 #[test]
2947 fn an_empty_directory_says_it_is_empty() {
2948 let page = listing("docs", "/nothing", &[]);
2949 assert!(page.contains("This directory is empty"), "{page}");
2950 }
2951
2952 #[test]
2953 fn the_component_chain_walks_from_the_base_down() {
2954 assert_eq!(
2955 components("/srv", "/srv/a/b/c.html"),
2956 vec![
2957 ("/srv".to_string(), "a".to_string()),
2958 ("/srv/a".to_string(), "b".to_string()),
2959 ("/srv/a/b".to_string(), "c.html".to_string()),
2960 ]
2961 );
2962 assert_eq!(
2963 components("/srv", "/srv/index.html"),
2964 vec![("/srv".to_string(), "index.html".to_string())]
2965 );
2966 assert_eq!(
2968 components("/srv/", "/srv/a.html"),
2969 vec![("/srv".to_string(), "a.html".to_string())]
2970 );
2971 assert!(components("/srv", "/srv").is_empty());
2973 }
2974
2975 #[tokio::test]
2978 async fn a_revisit_costs_no_remote_round_trips() {
2979 let origin = origin_with(one_page()).await;
2980
2981 let first = origin.handle(get("/a.html", None)).await;
2982 assert_eq!(first.status(), StatusCode::OK);
2983 let after_first = trips(&origin).await;
2984 assert!(after_first > 0, "the first request has to fetch something");
2985
2986 let second = origin.handle(get("/a.html", None)).await;
2987 assert_eq!(second.status(), StatusCode::OK);
2988 assert_eq!(
2989 trips(&origin).await,
2990 after_first,
2991 "a revisit must be answered entirely from cache"
2992 );
2993 }
2994
2995 #[tokio::test]
2998 async fn a_conditional_get_is_answered_without_the_remote() {
2999 let origin = origin_with(one_page()).await;
3000
3001 let first = origin.handle(get("/a.html", None)).await;
3002 let tag = first
3003 .headers()
3004 .get(ETAG)
3005 .expect("a validator is offered")
3006 .to_str()
3007 .expect("ascii")
3008 .to_string();
3009 let after_first = trips(&origin).await;
3010
3011 let second = origin.handle(get("/a.html", Some(&tag))).await;
3012 assert_eq!(second.status(), StatusCode::NOT_MODIFIED);
3013 assert_eq!(
3014 trips(&origin).await,
3015 after_first,
3016 "a 304 must not touch the remote"
3017 );
3018 }
3019
3020 #[tokio::test]
3022 async fn a_missing_file_is_a_404_from_the_cached_listing() {
3023 let origin = origin_with(one_page()).await;
3024
3025 origin.handle(get("/a.html", None)).await;
3027 let warm = trips(&origin).await;
3028
3029 let missing = origin.handle(get("/nope.html", None)).await;
3030 assert_eq!(missing.status(), StatusCode::NOT_FOUND);
3031 assert_eq!(
3032 trips(&origin).await,
3033 warm,
3034 "a 404 for a listed-but-absent name must cost nothing"
3035 );
3036 }
3037
3038 #[tokio::test]
3041 async fn a_symlink_is_refused() {
3042 let origin = origin_with(
3043 FakeRemote::new()
3044 .dir("/srv", vec![("link.html", symlink_attrs())])
3045 .file("/srv/link.html", b"whatever the target is"),
3046 )
3047 .await;
3048
3049 let res = origin.handle(get("/link.html", None)).await;
3050 assert_eq!(res.status(), StatusCode::FORBIDDEN);
3051 }
3052
3053 #[tokio::test]
3055 async fn a_directory_without_a_trailing_slash_redirects() {
3056 let origin = origin_with(
3057 FakeRemote::new()
3058 .dir("/srv", vec![("sub", dir_attrs())])
3059 .dir("/srv/sub", vec![("b.html", file_attrs(1, 1))]),
3060 )
3061 .await;
3062
3063 let res = origin.handle(get("/sub", None)).await;
3064 assert_eq!(res.status(), StatusCode::MOVED_PERMANENTLY);
3065 assert_eq!(
3066 res.headers().get(LOCATION).and_then(|v| v.to_str().ok()),
3067 Some("/sub/")
3068 );
3069 }
3070
3071 #[tokio::test]
3074 async fn a_listing_proven_wrong_is_forgotten() {
3075 let origin =
3077 origin_with(FakeRemote::new().dir("/srv", vec![("ghost.html", file_attrs(5, 100))]))
3078 .await;
3079
3080 let res = origin.handle(get("/ghost.html", None)).await;
3081 assert_eq!(res.status(), StatusCode::NOT_FOUND);
3082 assert!(
3083 !origin.cache.has_listing("/srv"),
3084 "a listing contradicted by the remote must be dropped"
3085 );
3086 }
3087
3088 #[tokio::test]
3090 async fn a_directory_without_an_index_is_listed() {
3091 let origin =
3092 origin_with(FakeRemote::new().dir("/srv", vec![("only.txt", file_attrs(2, 1))])).await;
3093
3094 let res = origin.handle(get("/", None)).await;
3095 assert_eq!(res.status(), StatusCode::OK);
3096 assert_eq!(
3097 res.headers()
3098 .get(CONTENT_TYPE)
3099 .and_then(|v| v.to_str().ok()),
3100 Some("text/html; charset=utf-8")
3101 );
3102 }
3103
3104 #[tokio::test]
3107 async fn a_symlinked_directory_higher_up_the_path_is_refused() {
3108 let origin = origin_with(
3109 FakeRemote::new()
3110 .dir("/srv", vec![("link", symlink_attrs())])
3111 .dir("/srv/link", vec![("inside.html", file_attrs(2, 1))])
3112 .file("/srv/link/inside.html", b"hi"),
3113 )
3114 .await;
3115
3116 let res = origin.handle(get("/link/inside.html", None)).await;
3117 assert_eq!(res.status(), StatusCode::FORBIDDEN);
3118 }
3119
3120 #[tokio::test]
3123 async fn a_deep_path_costs_what_a_shallow_one_costs() {
3124 let deep = origin_with(deep_tree()).await;
3125 assert_eq!(
3126 deep.handle(get("/a/b/c/d.html", None)).await.status(),
3127 StatusCode::OK
3128 );
3129
3130 let shallow = origin_with(one_page()).await;
3131 assert_eq!(
3132 shallow.handle(get("/a.html", None)).await.status(),
3133 StatusCode::OK
3134 );
3135
3136 let (d, sh) = (trips(&deep).await, trips(&shallow).await);
3137 assert!(
3141 d <= sh + 2,
3142 "depth 4 cost {d} round trips against depth 1's {sh}"
3143 );
3144 }
3145
3146 #[tokio::test]
3148 async fn a_file_used_as_a_directory_is_a_404() {
3149 let origin = origin_with(one_page()).await;
3150 let res = origin.handle(get("/a.html/b.html", None)).await;
3151 assert_eq!(res.status(), StatusCode::NOT_FOUND);
3152 }
3153
3154 #[tokio::test]
3157 async fn a_deep_path_serves_its_body() {
3158 let origin = origin_with(deep_tree()).await;
3159 let res = origin.handle(get("/a/b/c/d.html", None)).await;
3160 assert_eq!(res.status(), StatusCode::OK);
3161 assert_eq!(
3162 res.headers()
3163 .get(CONTENT_TYPE)
3164 .and_then(|v| v.to_str().ok()),
3165 Some("text/html; charset=utf-8")
3166 );
3167 }
3168
3169 #[tokio::test]
3171 async fn a_range_is_sliced_out_of_the_cached_body() {
3172 let origin = origin_with(one_page()).await;
3173 assert_eq!(
3174 origin.handle(get("/a.html", None)).await.status(),
3175 StatusCode::OK
3176 );
3177 let warm = trips(&origin).await;
3178
3179 let res = origin.handle(ranged("/a.html", "bytes=1-3")).await;
3180 assert_eq!(res.status(), StatusCode::PARTIAL_CONTENT);
3181 assert_eq!(
3182 res.headers()
3183 .get(CONTENT_RANGE)
3184 .and_then(|v| v.to_str().ok()),
3185 Some("bytes 1-3/5")
3186 );
3187 assert_eq!(&body_of(res).await[..], b"ell");
3188 assert_eq!(
3189 trips(&origin).await,
3190 warm,
3191 "slicing a held body must cost no round trip"
3192 );
3193 }
3194
3195 #[tokio::test]
3197 async fn a_range_on_a_cold_small_file_works_and_warms_the_cache() {
3198 let origin = origin_with(one_page()).await;
3199
3200 let res = origin.handle(ranged("/a.html", "bytes=0-1")).await;
3201 assert_eq!(res.status(), StatusCode::PARTIAL_CONTENT);
3202 assert_eq!(&body_of(res).await[..], b"he");
3203
3204 let warm = trips(&origin).await;
3205 let again = origin.handle(ranged("/a.html", "bytes=2-4")).await;
3206 assert_eq!(&body_of(again).await[..], b"llo");
3207 assert_eq!(
3208 trips(&origin).await,
3209 warm,
3210 "a small file fetched for a range should be held whole"
3211 );
3212 }
3213
3214 #[tokio::test]
3216 async fn a_range_past_the_end_is_a_416_carrying_the_real_size() {
3217 let origin = origin_with(one_page()).await;
3218 let res = origin.handle(ranged("/a.html", "bytes=99-")).await;
3219 assert_eq!(res.status(), StatusCode::RANGE_NOT_SATISFIABLE);
3220 assert_eq!(
3221 res.headers()
3222 .get(CONTENT_RANGE)
3223 .and_then(|v| v.to_str().ok()),
3224 Some("bytes */5")
3225 );
3226 }
3227
3228 #[tokio::test]
3230 async fn a_full_response_advertises_ranges() {
3231 let origin = origin_with(one_page()).await;
3232 let res = origin.handle(get("/a.html", None)).await;
3233 assert_eq!(
3234 res.headers()
3235 .get(ACCEPT_RANGES)
3236 .and_then(|v| v.to_str().ok()),
3237 Some("bytes")
3238 );
3239 }
3240
3241 #[tokio::test]
3244 async fn if_range_yields_the_whole_file() {
3245 let origin = origin_with(one_page()).await;
3246 let req = Request::builder()
3247 .uri("http://docs.ssh-browser/a.html")
3248 .header(HOST, "docs.ssh-browser")
3249 .header(RANGE, "bytes=1-3")
3250 .header(IF_RANGE, "W/\"64-5\"")
3251 .body(Empty::<Bytes>::new())
3252 .expect("request builds");
3253
3254 let res = origin.handle(req).await;
3255 assert_eq!(res.status(), StatusCode::OK);
3256 assert_eq!(&body_of(res).await[..], b"hello");
3257 }
3258
3259 #[tokio::test]
3262 async fn a_large_file_is_served_by_range_and_not_held() {
3263 let body: Vec<u8> = (0..64u8).collect();
3264 let origin = origin_with(
3265 FakeRemote::new()
3266 .dir("/srv", vec![("big.bin", file_attrs(9 * 1024 * 1024, 7))])
3269 .file("/srv/big.bin", &body),
3270 )
3271 .await;
3272
3273 let res = origin.handle(ranged("/big.bin", "bytes=0-9")).await;
3274 assert_eq!(res.status(), StatusCode::PARTIAL_CONTENT);
3275 assert_eq!(&body_of(res).await[..], &body[0..10]);
3276
3277 let after = trips(&origin).await;
3278 let second = origin.handle(ranged("/big.bin", "bytes=10-19")).await;
3279 assert_eq!(&body_of(second).await[..], &body[10..20]);
3280 assert!(
3281 trips(&origin).await > after,
3282 "a file over the threshold must not be held"
3283 );
3284 }
3285
3286 #[tokio::test]
3290 async fn an_alias_origin_has_no_control_api_on_it() {
3291 let origin = origin_with(one_page()).await;
3292 let res = origin.handle(get("/_control/hello", None)).await;
3293 assert_eq!(res.status(), StatusCode::NOT_FOUND);
3294 assert_ne!(
3295 res.status(),
3296 StatusCode::UNAUTHORIZED,
3297 "a 401 would mean the control router was reached from an alias origin"
3298 );
3299 }
3300
3301 #[tokio::test]
3304 async fn an_alias_origin_with_a_valid_token_still_has_no_control_api() {
3305 let origin = origin_with(one_page()).await;
3306 let req = Request::builder()
3307 .uri("http://docs.ssh-browser/_control/hello")
3308 .header(HOST, "docs.ssh-browser")
3309 .header(control::TOKEN_HEADER, TEST_TOKEN)
3310 .body(Empty::<Bytes>::new())
3311 .expect("request builds");
3312 assert_eq!(origin.handle(req).await.status(), StatusCode::NOT_FOUND);
3313 }
3314
3315 #[tokio::test]
3318 async fn the_alias_origin_refuses_writes() {
3319 let origin = origin_with(one_page()).await;
3320 for method in [Method::POST, Method::PUT, Method::DELETE, Method::PATCH] {
3321 let req = Request::builder()
3322 .method(method.clone())
3323 .uri("http://docs.ssh-browser/a.html")
3324 .header(HOST, "docs.ssh-browser")
3325 .body(Empty::<Bytes>::new())
3326 .expect("request builds");
3327 assert_eq!(
3328 origin.handle(req).await.status(),
3329 StatusCode::METHOD_NOT_ALLOWED,
3330 "{method} should be refused on the read-only origin"
3331 );
3332 }
3333 }
3334
3335 #[tokio::test]
3336 async fn the_control_api_answers_on_loopback_with_the_token() {
3337 let origin = origin_with(one_page()).await;
3338 let res = origin
3339 .handle(loopback("/_control/hello", Some(TEST_TOKEN)))
3340 .await;
3341 assert_eq!(res.status(), StatusCode::OK);
3342 let body = body_of(res).await;
3343 let text = String::from_utf8_lossy(&body);
3344 assert!(
3345 text.contains("\"protocol\""),
3346 "hello must negotiate: {text}"
3347 );
3348 assert!(text.contains("\"docs\""), "hello must list aliases: {text}");
3349 }
3350
3351 #[tokio::test]
3352 async fn the_control_api_refuses_loopback_without_the_token() {
3353 let origin = origin_with(one_page()).await;
3354 assert_eq!(
3355 origin
3356 .handle(loopback("/_control/hello", None))
3357 .await
3358 .status(),
3359 StatusCode::UNAUTHORIZED
3360 );
3361 assert_eq!(
3362 origin
3363 .handle(loopback("/_control/hello", Some("wrong")))
3364 .await
3365 .status(),
3366 StatusCode::UNAUTHORIZED
3367 );
3368 }
3369
3370 #[tokio::test]
3372 async fn the_loopback_path_still_serves_files() {
3373 let origin = origin_with(one_page()).await;
3374 let res = origin.handle(loopback("/docs/a.html", None)).await;
3375 assert_eq!(res.status(), StatusCode::OK);
3376 assert_eq!(&body_of(res).await[..], b"hello");
3377 }
3378
3379 #[tokio::test]
3382 async fn a_base_may_be_written_relative_to_the_home_directory() {
3383 let fs = FakeRemote::new().home("/home/souta").spawn().await;
3384 assert_eq!(
3385 resolve_base(Some("~/work"), &fs).await.expect("resolves"),
3386 "/home/souta/work"
3387 );
3388 }
3389
3390 #[tokio::test]
3392 async fn a_bare_tilde_and_no_base_are_both_the_home_directory() {
3393 let fs = FakeRemote::new().home("/home/souta").spawn().await;
3394 assert_eq!(
3395 resolve_base(None, &fs).await.expect("resolves"),
3396 "/home/souta"
3397 );
3398 assert_eq!(
3399 resolve_base(Some("~"), &fs).await.expect("resolves"),
3400 "/home/souta"
3401 );
3402 }
3403
3404 #[tokio::test]
3407 async fn an_absolute_base_costs_no_round_trip() {
3408 let fs = FakeRemote::new().home("/home/souta").spawn().await;
3409 let before = fs.round_trips();
3410 assert_eq!(
3411 resolve_base(Some("/srv/docs"), &fs)
3412 .await
3413 .expect("resolves"),
3414 "/srv/docs"
3415 );
3416 assert_eq!(fs.round_trips(), before, "an absolute base must not ask");
3417 }
3418
3419 #[test]
3422 fn a_base_that_could_climb_out_of_the_home_directory_is_refused() {
3423 for bad in [
3424 "~/..",
3425 "~/../.ssh",
3426 "~/work/../..",
3427 "~/./x",
3428 "~work",
3429 "work",
3430 "",
3431 ] {
3432 assert!(!is_base(bad), "should have been refused: {bad:?}");
3433 assert!(
3434 Alias::new("docs", "h", Some(bad)).is_err(),
3435 "should have been refused: {bad:?}"
3436 );
3437 }
3438 for good in ["/", "/srv", "~", "~/work", "~/a/b/c"] {
3439 assert!(is_base(good), "should have been accepted: {good:?}");
3440 }
3441 }
3442
3443 #[tokio::test]
3446 async fn a_root_home_does_not_produce_a_doubled_slash() {
3447 let fs = FakeRemote::new().home("/").spawn().await;
3448 assert_eq!(resolve_base(None, &fs).await.expect("resolves"), "/");
3449 assert_eq!(
3450 resolve_base(Some("~/work"), &fs).await.expect("resolves"),
3451 "/work"
3452 );
3453 }
3454
3455 #[tokio::test]
3464 async fn the_host_list_is_a_control_route() {
3465 let origin = origin_with(one_page()).await;
3466 let res = origin
3467 .handle(loopback("/_control/hosts", Some(TEST_TOKEN)))
3468 .await;
3469 assert_eq!(res.status(), StatusCode::OK);
3470 let text = String::from_utf8(body_of(res).await.to_vec()).expect("utf-8");
3471 let parsed: serde_json::Value = serde_json::from_str(&text).expect("json");
3472 assert!(parsed.get("hosts").is_some_and(|h| h.is_array()), "{text}");
3473 assert!(
3474 parsed.get("unusable").is_some_and(|u| u.is_array()),
3475 "{text}"
3476 );
3477 }
3478
3479 #[tokio::test]
3486 async fn opening_a_host_ssh_does_not_know_is_refused() {
3487 let origin = origin_with(one_page()).await;
3488 let res = origin
3489 .handle(control_post(
3490 "/_control/open",
3491 Some(TEST_TOKEN),
3492 r#"{"host":"not-a-host-in-anyones-ssh-config.invalid"}"#,
3493 ))
3494 .await;
3495 assert_eq!(res.status(), StatusCode::NOT_FOUND);
3496 }
3497
3498 #[tokio::test]
3502 async fn an_open_request_that_is_not_one_is_refused() {
3503 let origin = origin_with(one_page()).await;
3504 for body in [
3505 "",
3506 "{}",
3507 r#"{"base":"/srv"}"#,
3508 r#"{"host":"docs","base_path":"/srv"}"#,
3509 ] {
3510 let res = origin
3511 .handle(control_post("/_control/open", Some(TEST_TOKEN), body))
3512 .await;
3513 assert_eq!(
3514 res.status(),
3515 StatusCode::BAD_REQUEST,
3516 "should have been refused: {body}"
3517 );
3518 }
3519 }
3520
3521 #[tokio::test]
3525 async fn opening_a_host_needs_the_token() {
3526 let origin = origin_with(one_page()).await;
3527 for token in [None, Some("wrong")] {
3528 let res = origin
3529 .handle(control_post("/_control/open", token, r#"{"host":"docs"}"#))
3530 .await;
3531 assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "token {token:?}");
3532 }
3533 }
3534
3535 #[tokio::test]
3542 async fn the_token_is_handed_over_to_something_that_is_not_a_page() {
3543 let origin = origin_with(one_page()).await;
3544 for site in [None, Some("none")] {
3545 let res = origin.handle(from_site("/_control/token", site)).await;
3546 assert_eq!(res.status(), StatusCode::OK, "site {site:?}");
3547 let body = String::from_utf8(body_of(res).await.to_vec()).expect("utf-8");
3548 assert_eq!(body.trim(), TEST_TOKEN, "site {site:?}");
3549 }
3550 }
3551
3552 #[tokio::test]
3553 async fn a_page_is_not_handed_the_token() {
3554 let origin = origin_with(one_page()).await;
3555 for site in ["same-origin", "same-site", "cross-site"] {
3556 let res = origin
3557 .handle(from_site("/_control/token", Some(site)))
3558 .await;
3559 assert_eq!(res.status(), StatusCode::FORBIDDEN, "site {site}");
3560 let body = String::from_utf8(body_of(res).await.to_vec()).expect("utf-8");
3561 assert!(!body.contains(TEST_TOKEN), "the refusal leaked it: {body}");
3562 }
3563 }
3564
3565 #[tokio::test]
3568 async fn a_page_with_the_token_still_cannot_use_the_control_api() {
3569 let origin = origin_with(one_page()).await;
3570 let req = Request::builder()
3571 .uri("http://127.0.0.1:7391/_control/hello")
3572 .header(HOST, "127.0.0.1:7391")
3573 .header(control::TOKEN_HEADER, TEST_TOKEN)
3574 .header(control::FETCH_SITE_HEADER, "same-origin")
3575 .body(Full::new(Bytes::new()))
3576 .expect("request builds");
3577 assert_eq!(origin.handle(req).await.status(), StatusCode::FORBIDDEN);
3578 }
3579
3580 #[tokio::test]
3582 async fn an_alias_can_be_closed_and_is_then_gone() {
3583 let origin = origin_with(one_page()).await;
3584 assert_eq!(
3585 origin.handle(get("/a.html", None)).await.status(),
3586 StatusCode::OK
3587 );
3588
3589 let res = origin
3590 .handle(control_post(
3591 "/_control/close",
3592 Some(TEST_TOKEN),
3593 r#"{"alias":"docs"}"#,
3594 ))
3595 .await;
3596 assert_eq!(res.status(), StatusCode::OK);
3597
3598 assert_eq!(
3601 origin.handle(get("/a.html", None)).await.status(),
3602 StatusCode::NOT_FOUND
3603 );
3604 }
3605
3606 #[tokio::test]
3609 async fn closing_an_alias_that_is_not_open_says_so() {
3610 let origin = origin_with(one_page()).await;
3611 let res = origin
3612 .handle(control_post(
3613 "/_control/close",
3614 Some(TEST_TOKEN),
3615 r#"{"alias":"nope"}"#,
3616 ))
3617 .await;
3618 assert_eq!(res.status(), StatusCode::NOT_FOUND);
3619 }
3620
3621 #[tokio::test]
3622 async fn closing_an_alias_needs_the_token() {
3623 let origin = origin_with(one_page()).await;
3624 let res = origin
3625 .handle(control_post("/_control/close", None, r#"{"alias":"docs"}"#))
3626 .await;
3627 assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
3628 assert_eq!(
3630 origin.handle(get("/a.html", None)).await.status(),
3631 StatusCode::OK
3632 );
3633 }
3634
3635 #[tokio::test]
3639 async fn a_directory_holding_an_index_is_listed_as_a_site() {
3640 let origin = origin_with(
3641 FakeRemote::new()
3642 .dir("/srv", vec![("ft-demo", dir_attrs()), ("src", dir_attrs())])
3643 .dir("/srv/ft-demo", vec![("index.html", file_attrs(5, 1))])
3644 .dir("/srv/src", vec![("main.jl", file_attrs(5, 1))])
3645 .file("/srv/ft-demo/index.html", b"board"),
3646 )
3647 .await;
3648
3649 let body = String::from_utf8(body_of(origin.handle(get("/", None)).await).await.to_vec())
3650 .expect("utf-8");
3651 assert!(
3654 body.contains("class=\"row site\" href=\"/ft-demo/\""),
3655 "{body}"
3656 );
3657 let demo = body.find("ft-demo/").expect("the site listed");
3658 let src = body.find("src/").expect("the folder listed");
3659 assert!(demo < src, "a site leads the other directories: {body}");
3660 }
3661
3662 #[tokio::test]
3667 async fn the_site_scan_costs_the_same_however_many_subdirectories() {
3668 async fn trips_for(n: usize) -> u64 {
3669 let names: Vec<String> = (0..n).map(|i| format!("d{i:02}")).collect();
3670 let mut remote = FakeRemote::new().dir(
3671 "/srv",
3672 names.iter().map(|s| (s.as_str(), dir_attrs())).collect(),
3673 );
3674 for name in &names {
3675 remote = remote.dir(&format!("/srv/{name}"), vec![("a.txt", file_attrs(1, 1))]);
3676 }
3677 let origin = origin_with(remote).await;
3678 let before = trips(&origin).await;
3679 assert_eq!(origin.handle(get("/", None)).await.status(), StatusCode::OK);
3680 trips(&origin).await - before
3681 }
3682
3683 let few = trips_for(2).await;
3684 let many = trips_for(20).await;
3685 assert_eq!(
3686 few, many,
3687 "{many} round trips for twenty subdirectories against {few} for two"
3688 );
3689 }
3690
3691 #[tokio::test]
3694 async fn the_scan_leaves_the_next_click_paid_for() {
3695 let origin = origin_with(
3696 FakeRemote::new()
3697 .dir("/srv", vec![("sub", dir_attrs())])
3698 .dir("/srv/sub", vec![("a.txt", file_attrs(1, 1))]),
3699 )
3700 .await;
3701 assert_eq!(origin.handle(get("/", None)).await.status(), StatusCode::OK);
3702
3703 let before = trips(&origin).await;
3704 assert_eq!(
3705 origin.handle(get("/sub/", None)).await.status(),
3706 StatusCode::OK
3707 );
3708 assert_eq!(
3709 trips(&origin).await,
3710 before,
3711 "the listing the scan fetched should still be the one that answers"
3712 );
3713 }
3714
3715 #[tokio::test]
3726 async fn a_listing_that_expires_mid_request_does_not_lose_the_path() {
3727 let origin =
3728 origin_with_cache(deep_tree(), Cache::new(std::time::Duration::ZERO, 1 << 20)).await;
3729 assert_eq!(
3730 origin.handle(get("/a/b/c/d.html", None)).await.status(),
3731 StatusCode::OK,
3732 "a path four deep must survive its own listings expiring"
3733 );
3734 assert_eq!(
3736 origin.handle(get("/a/b/c/", None)).await.status(),
3737 StatusCode::OK
3738 );
3739 }
3740
3741 #[tokio::test]
3744 async fn a_directory_the_remote_refuses_says_why() {
3745 let origin = origin_with(
3746 FakeRemote::new()
3747 .dir("/srv", vec![("locked", dir_attrs())])
3748 .refuses_listing("/srv/locked", 3),
3750 )
3751 .await;
3752
3753 let res = origin.handle(get("/locked/x.html", None)).await;
3754 assert_eq!(res.status(), StatusCode::BAD_GATEWAY);
3755 let said = String::from_utf8(body_of(res).await.to_vec()).expect("utf-8");
3756 assert!(said.contains("/srv/locked"), "{said}");
3757 assert!(
3758 !said.contains("cannot list"),
3759 "the old wording said nothing the reader could act on: {said}"
3760 );
3761 }
3762}