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 serde::{Deserialize, Serialize};
37use tokio::net::TcpListener;
38
39use crate::annot;
40use crate::cache::{self, Cache};
41use crate::control::{self, Token};
42use crate::fs::sftp::SftpFs;
43use crate::fs::{Entry, RangeReq, RemoteFs};
44use crate::prefetch;
45use crate::sftp::wire::Attrs;
46use crate::ssh_config;
47use crate::theme;
48
49const CACHE_WHOLE_MAX: u64 = 8 * 1024 * 1024;
53
54struct Conditions {
56 if_none_match: Option<String>,
57 range: Option<String>,
58 if_range: Option<String>,
59 control_token: Option<String>,
61 fetch_site: Option<String>,
66}
67
68#[derive(Debug)]
76pub struct Alias {
77 name: String,
78 host: String,
79 base: Option<String>,
86}
87
88impl Alias {
89 pub fn new(name: &str, host: &str, base: Option<&str>) -> Result<Self> {
90 ensure!(!host.is_empty(), "alias {name:?} has no ssh host");
91 ensure!(
97 guard::is_label(name),
98 "alias {name:?} must be lowercase letters, digits and hyphens, and may not start or end with a hyphen: it becomes a hostname label"
99 );
100 if let Some(base) = base {
101 ensure!(
102 is_base(base),
103 "alias {name:?} needs a base that is an absolute path, or `~`, or `~/` and a path under the home directory with no `..` in it, got {base:?}"
104 );
105 }
106 Ok(Self {
107 name: name.to_string(),
108 host: host.to_string(),
109 base: base.map(str::to_string),
110 })
111 }
112
113 pub fn name(&self) -> &str {
114 &self.name
115 }
116
117 pub fn host(&self) -> &str {
118 &self.host
119 }
120
121 pub fn base(&self) -> Option<&str> {
123 self.base.as_deref()
124 }
125}
126
127#[derive(serde::Serialize)]
129struct KnownHost {
130 alias: String,
131 host: String,
132 #[serde(flatten)]
133 settings: ssh_config::Settings,
134 served: bool,
139 #[serde(skip_serializing_if = "Option::is_none")]
140 unresolved: Option<String>,
141}
142
143#[derive(serde::Serialize)]
151struct OpenAlias {
152 alias: String,
153 host: String,
154 base: String,
155 url: String,
156}
157
158#[derive(serde::Serialize)]
159struct KnownHosts {
160 open: Vec<OpenAlias>,
161 hosts: Vec<KnownHost>,
162 unusable: Vec<ssh_config::Unusable>,
163}
164
165fn is_base(base: &str) -> bool {
177 if base.starts_with('/') {
178 return true;
179 }
180 let Some(rest) = base.strip_prefix('~') else {
181 return false;
182 };
183 match rest {
184 "" => true,
185 rest => match rest.strip_prefix('/') {
186 Some(under) => {
187 !under.is_empty()
188 && under
189 .split('/')
190 .all(|c| !c.is_empty() && c != "." && c != "..")
191 }
192 None => false,
193 },
194 }
195}
196
197async fn resolve_base(base: Option<&str>, fs: &SftpFs) -> Result<String> {
203 let under = match base {
204 None | Some("~") => "",
205 Some(b) => match b.strip_prefix("~/") {
206 Some(under) => under,
207 None => return Ok(b.to_string()),
210 },
211 };
212 let home = fs.home().await?;
213 let home = home.trim_end_matches('/');
214 let home = if home.is_empty() { "" } else { home };
217 Ok(match under {
218 "" if home.is_empty() => "/".to_string(),
219 "" => home.to_string(),
220 under => format!("{home}/{under}"),
221 })
222}
223
224impl Origin {
229 async fn session(&self, alias: &str) -> Option<Arc<Session>> {
230 self.sessions.read().await.get(alias).cloned()
231 }
232
233 async fn alias_names(&self) -> Vec<String> {
234 let mut names: Vec<String> = self.sessions.read().await.keys().cloned().collect();
235 names.sort();
236 names
237 }
238}
239
240struct Session {
241 host: String,
247 base: String,
248 fs: SftpFs,
249}
250
251pub struct Origin {
252 suffix: String,
253 port: u16,
254 sessions: RwLock<HashMap<String, Arc<Session>>>,
265 cache: Cache,
266 token: Token,
267 theme: RwLock<String>,
273 author: String,
281}
282
283pub struct Bound {
289 origin: Arc<Origin>,
290 listener: TcpListener,
291 routes: Vec<String>,
292}
293
294impl Bound {
295 pub fn routes(&self) -> &[String] {
300 &self.routes
301 }
302}
303
304impl Origin {
305 pub async fn bind(
315 aliases: Vec<Alias>,
316 suffix: String,
317 port: u16,
318 token: Token,
319 author: String,
320 theme: String,
321 ) -> Result<Bound> {
322 let addr = SocketAddr::from(([127, 0, 0, 1], port));
323 let listener = TcpListener::bind(addr)
324 .await
325 .with_context(|| format!("bind {addr}"))?;
326
327 ensure!(
331 pac::is_suffix(&suffix),
332 "suffix {suffix:?} must be lowercase letters, digits, hyphens and dots"
333 );
334 theme::check(&theme)?;
341 ensure!(
342 annot::is_safe_name(&author),
343 "author {author:?} must be letters, digits, dots, dashes or underscores: it becomes a filename"
344 );
345
346 let mut sessions = HashMap::new();
347 let mut routes = Vec::new();
348 for a in aliases {
349 let fs = SftpFs::connect(&a.host)
350 .await
351 .with_context(|| format!("alias {} -> ssh host {}", a.name, a.host))?;
352 let base = resolve_base(a.base.as_deref(), &fs)
355 .await
356 .with_context(|| {
357 format!(
358 "alias {} -> ssh host {}: working out where {} is",
359 a.name,
360 a.host,
361 a.base.as_deref().unwrap_or("the home directory")
362 )
363 })?;
364 routes.push(format!(
368 " http://{}.{suffix}/ -> {}:{base}",
369 a.name, a.host
370 ));
371 ensure!(
375 sessions
376 .insert(
377 a.name.clone(),
378 Arc::new(Session {
379 host: a.host.clone(),
380 base,
381 fs,
382 }),
383 )
384 .is_none(),
385 "alias {:?} is defined twice",
386 a.name
387 );
388 }
389 Ok(Bound {
390 routes,
391 origin: Arc::new(Self {
392 suffix,
393 port,
394 sessions: RwLock::new(sessions),
395 cache: Cache::default(),
396 token,
397 theme: RwLock::new(theme),
398 author,
399 }),
400 listener,
401 })
402 }
403}
404
405impl Bound {
406 pub async fn serve(self) -> Result<()> {
407 let Bound {
408 origin, listener, ..
409 } = self;
410 let self_ = origin;
411
412 loop {
413 let (stream, _) = listener.accept().await?;
414 let me = Arc::clone(&self_);
415 tokio::spawn(async move {
416 let service = service_fn(move |req| {
417 let me = Arc::clone(&me);
418 async move { Ok::<_, std::convert::Infallible>(me.handle(req).await) }
419 });
420 let _ = http1::Builder::new()
424 .serve_connection(TokioIo::new(stream), service)
425 .await;
426 });
427 }
428 }
429}
430
431impl Origin {
432 pub async fn handle<B>(&self, req: Request<B>) -> Response<Full<Bytes>>
435 where
436 B: hyper::body::Body,
437 B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
438 {
439 let Some(host) = host_of(&req) else {
440 return fail(StatusCode::BAD_REQUEST, "request carries no Host");
441 };
442 let path = req.uri().path().to_string();
443 let cond = Conditions {
444 if_none_match: header(&req, IF_NONE_MATCH),
445 range: header(&req, RANGE),
446 if_range: header(&req, IF_RANGE),
447 control_token: req
448 .headers()
449 .get(control::TOKEN_HEADER)
450 .and_then(|v| v.to_str().ok())
451 .map(str::to_string),
452 fetch_site: req
453 .headers()
454 .get(control::FETCH_SITE_HEADER)
455 .and_then(|v| v.to_str().ok())
456 .map(str::to_string),
457 };
458 let method = req.method().clone();
459 let query = req.uri().query().map(str::to_string);
460
461 let control_body = if path.starts_with(control::PATH_PREFIX) {
464 match read_body(req.into_body()).await {
465 Ok(b) => b,
466 Err(e) => return fail(StatusCode::BAD_REQUEST, e),
467 }
468 } else {
469 Bytes::new()
470 };
471
472 match guard::classify(&host, &path, &self.suffix, self.port) {
473 Err(e) => fail(StatusCode::FORBIDDEN, format!("{e:#}")),
476 Ok(guard::Target::Direct { path }) => {
477 self.direct(&method, path, &cond, query.as_deref(), &control_body)
478 .await
479 }
480 Ok(guard::Target::Alias { alias, path }) => {
481 self.alias(&method, alias, path, &cond, query.as_deref())
482 .await
483 }
484 }
485 }
486
487 async fn direct(
488 &self,
489 method: &Method,
490 path: &str,
491 cond: &Conditions,
492 query: Option<&str>,
493 body: &[u8],
494 ) -> Response<Full<Bytes>> {
495 if path.starts_with(control::PATH_PREFIX) {
498 if control::from_a_page(cond.fetch_site.as_deref()) {
501 return control::text(
502 StatusCode::FORBIDDEN,
503 "the control API is not reachable from a page",
504 );
505 }
506 if method == Method::GET && control::route_of(path) == "token" {
514 return control::text(StatusCode::OK, self.token.as_str());
515 }
516 if let Some(refusal) = control::gate(
520 method,
521 cond.fetch_site.as_deref(),
522 cond.control_token.as_deref(),
523 &self.token,
524 ) {
525 return refusal;
526 }
527 return self.control(method, path, query, body).await;
528 }
529
530 if path == "/proxy.pac" {
531 return match pac::script(&self.suffix, self.port) {
532 Ok(body) => plain_ok("application/x-ns-proxy-autoconfig", Bytes::from(body)),
533 Err(e) => fail(StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")),
534 };
535 }
536
537 let rest = path.trim_start_matches('/');
538 if rest.is_empty() {
539 return plain_ok(
540 "text/html; charset=utf-8",
541 Bytes::from(self.alias_index().await),
542 );
543 }
544
545 let (alias, sub) = rest.split_once('/').unwrap_or((rest, ""));
546 self.alias(method, alias, &format!("/{sub}"), cond, query)
547 .await
548 }
549
550 async fn alias(
551 &self,
552 method: &Method,
553 alias: &str,
554 path: &str,
555 cond: &Conditions,
556 query: Option<&str>,
557 ) -> Response<Full<Bytes>> {
558 if !matches!(*method, Method::GET | Method::HEAD) {
563 return fail(
564 StatusCode::METHOD_NOT_ALLOWED,
565 format!("{method} is not allowed: this origin is read-only"),
566 );
567 }
568
569 let Some(session) = self.session(alias).await else {
570 return fail(StatusCode::NOT_FOUND, format!("no alias named {alias:?}"));
571 };
572 let session = session.as_ref();
573 let resolved = match guard::resolve(&session.base, path) {
574 Ok(p) => p,
575 Err(e) => return fail(StatusCode::FORBIDDEN, format!("{e:#}")),
576 };
577
578 let wants_dir = path.ends_with('/');
579 let file = if wants_dir {
580 format!("{resolved}/index.html")
581 } else {
582 resolved.clone()
583 };
584
585 let chain = components(&session.base, &file);
589 if chain.is_empty() {
590 return self
591 .autoindex_of(session, alias, path, &resolved, query)
592 .await;
593 }
594 let last = chain.len() - 1;
595
596 if let Some((_, name)) = chain.iter().find(|(_, n)| hidden(n)) {
600 return fail(
601 StatusCode::FORBIDDEN,
602 format!("refusing {name}: names beginning with a dot are not served"),
603 );
604 }
605
606 let held = match self.listings_along(session, &chain).await {
607 Ok(held) => held,
608 Err((at, why)) => {
610 return fail(
611 StatusCode::BAD_GATEWAY,
612 format!("{path}: listing {at} failed: {why}"),
613 );
614 }
615 };
616
617 if let Some(at) = first_symlink(&held, &chain) {
621 return fail(
622 StatusCode::FORBIDDEN,
623 format!("refusing symlink at {at} (its target is not checked)"),
624 );
625 }
626
627 let mut found_last = None;
628 for (i, (dir, name)) in chain.iter().enumerate() {
629 let Some(attrs) = attrs_in(&held, dir, name) else {
630 if i == last && wants_dir {
633 return self
634 .autoindex_of(session, alias, path, &resolved, query)
635 .await;
636 }
637 return fail(StatusCode::NOT_FOUND, format!("not found: {path}"));
638 };
639
640 if i < last && !attrs.is_dir() {
641 return fail(
642 StatusCode::NOT_FOUND,
643 format!("{path}: {dir}/{name} is not a directory"),
644 );
645 }
646 if i == last {
647 found_last = Some(attrs);
648 }
649 }
650 let attrs = found_last.expect("the walk assigns on its final iteration");
651
652 if attrs.is_dir() {
653 if wants_dir {
654 return self
656 .autoindex_of(session, alias, path, &resolved, query)
657 .await;
658 }
659 return redirect(&format!("{path}/"));
662 }
663
664 let tag = cache::etag(&attrs);
665
666 if let (Some(tag), Some(header)) = (tag.as_deref(), cond.if_none_match.as_deref()) {
673 if cache::etag_matches(header, tag) {
674 return not_modified(tag);
675 }
676 }
677
678 let size = attrs.size.unwrap_or(0);
681 let wanted = match cond.range.as_deref() {
682 Some(header) => range::resolve(header, cond.if_range.as_deref(), size),
683 None => range::Resolved::Whole,
684 };
685 if wanted == range::Resolved::Unsatisfiable {
686 return unsatisfiable(size);
687 }
688
689 if let Some(body) = self.cache.body(&file, &attrs) {
691 return respond(&file, body, tag.as_deref(), &wanted, size);
692 }
693
694 if let range::Resolved::Part { start, end } = wanted {
698 if size > CACHE_WHOLE_MAX {
699 let req = RangeReq {
700 path: file.clone(),
701 offset: start,
702 len: end - start + 1,
703 };
704 let mut got = session.fs.read_ranges(std::slice::from_ref(&req)).await;
705 return match got.pop() {
706 Some(Ok(body)) => partial(
707 mime::guess(&file),
708 Bytes::from(body),
709 tag.as_deref(),
710 start,
711 end,
712 size,
713 ),
714 Some(Err(e)) => {
715 self.cache.forget_listing(&chain[last].0);
716 fail(StatusCode::NOT_FOUND, format!("{path}: {e:#}"))
717 }
718 None => fail(
719 StatusCode::INTERNAL_SERVER_ERROR,
720 "read_ranges returned no result",
721 ),
722 };
723 }
724 }
725
726 let mut got = session.fs.read_batch(std::slice::from_ref(&file)).await;
727 match got.pop() {
728 Some(Ok(body)) => {
729 let body = Bytes::from(body);
730 self.cache.put_body(&file, &attrs, body.clone());
731 if mime::guess(&file).starts_with("text/html") {
736 self.warm_subresources(session, path, &body).await;
737 }
738 respond(&file, body, tag.as_deref(), &wanted, size)
739 }
740 Some(Err(e)) => {
744 self.cache.forget_listing(&chain[last].0);
745 fail(StatusCode::NOT_FOUND, format!("{path}: {e:#}"))
746 }
747 None => fail(
748 StatusCode::INTERNAL_SERVER_ERROR,
749 "read_batch returned no result",
750 ),
751 }
752 }
753
754 async fn control(
755 &self,
756 method: &Method,
757 path: &str,
758 query: Option<&str>,
759 body: &[u8],
760 ) -> Response<Full<Bytes>> {
761 match (method, control::route_of(path)) {
762 (&Method::GET, "hello") => {
763 let aliases = self.alias_names().await;
764 control::hello(&aliases, &self.suffix)
765 }
766 (&Method::GET, "hosts") => self.list_hosts().await,
767 (&Method::POST, "open") => self.open_host(body).await,
768 (&Method::POST, "close") => self.close_alias(body).await,
769 (&Method::GET, "theme") => self.show_theme().await,
770 (&Method::POST, "theme") => self.set_theme(body).await,
771 (&Method::GET, "annotations") => self.list_annotations(query).await,
772 (&Method::POST, "annotations") => self.add_annotation(body).await,
773 (&Method::GET, route) => {
774 control::text(StatusCode::NOT_FOUND, format!("no control route {route:?}"))
775 }
776 (_, route) => control::text(
777 StatusCode::METHOD_NOT_ALLOWED,
778 format!("{method} is not allowed on {route:?}"),
779 ),
780 }
781 }
782
783 async fn resolve_doc(&self, doc: &str) -> Result<(Arc<Session>, String), (StatusCode, String)> {
789 let (alias, rest) = doc.split_once('/').unwrap_or((doc, ""));
790 let Some(session) = self.session(alias).await else {
791 return Err((StatusCode::NOT_FOUND, format!("no alias named {alias:?}")));
792 };
793 let resolved = match guard::resolve(&session.base, &format!("/{rest}")) {
794 Ok(p) => p,
795 Err(e) => return Err((StatusCode::FORBIDDEN, format!("{e:#}"))),
796 };
797
798 let chain = components(&session.base, &resolved);
799 let held = self.held_listings(&session, &chain).await;
803 if let Some(at) = first_symlink(&held, &chain) {
804 return Err((StatusCode::FORBIDDEN, format!("refusing symlink at {at}")));
805 }
806 Ok((session, resolved))
807 }
808
809 async fn list_hosts(&self) -> Response<Full<Bytes>> {
820 let found = match ssh_config::read() {
821 Ok(found) => found,
822 Err(e) => {
823 return control::text(
824 StatusCode::INTERNAL_SERVER_ERROR,
825 format!("reading ssh_config: {e:#}"),
826 );
827 }
828 };
829
830 let described: Vec<_> = found
834 .hosts
835 .iter()
836 .map(|h| {
837 let host = h.host.clone();
838 tokio::spawn(async move { ssh_config::describe(&host).await })
839 })
840 .collect();
841
842 let open = {
843 let sessions = self.sessions.read().await;
844 let mut open: Vec<OpenAlias> = sessions
845 .iter()
846 .map(|(alias, s)| OpenAlias {
847 alias: alias.clone(),
848 host: s.host.clone(),
849 base: s.base.clone(),
850 url: format!("http://{alias}.{}/", self.suffix),
851 })
852 .collect();
853 open.sort_by(|a, b| a.alias.cmp(&b.alias));
854 open
855 };
856 let mut hosts = Vec::with_capacity(found.hosts.len());
857 for (h, task) in found.hosts.iter().zip(described) {
858 let (settings, unresolved) = match task.await {
862 Ok(Ok(settings)) => (settings, None),
863 Ok(Err(e)) => (ssh_config::Settings::default(), Some(format!("{e:#}"))),
864 Err(e) => (ssh_config::Settings::default(), Some(e.to_string())),
865 };
866 hosts.push(KnownHost {
867 alias: h.alias.clone(),
868 host: h.host.clone(),
869 settings,
870 served: open.iter().any(|o| o.alias == h.alias),
871 unresolved,
872 });
873 }
874 control::json(&KnownHosts {
875 open,
876 hosts,
877 unusable: found.unusable,
878 })
879 }
880
881 async fn open_host(&self, body: &[u8]) -> Response<Full<Bytes>> {
891 #[derive(serde::Deserialize)]
892 #[serde(deny_unknown_fields)]
893 struct Ask {
894 host: String,
895 #[serde(default)]
897 base: Option<String>,
898 }
899
900 let ask: Ask = match serde_json::from_slice(body) {
901 Ok(ask) => ask,
902 Err(e) => {
903 return control::text(
904 StatusCode::BAD_REQUEST,
905 format!("open needs a JSON body naming a host: {e}"),
906 );
907 }
908 };
909
910 let found = match ssh_config::read() {
911 Ok(found) => found,
912 Err(e) => {
913 return control::text(
914 StatusCode::INTERNAL_SERVER_ERROR,
915 format!("reading ssh_config: {e:#}"),
916 );
917 }
918 };
919 let Some(known) = found
923 .hosts
924 .iter()
925 .find(|h| h.host.eq_ignore_ascii_case(&ask.host) || h.alias == ask.host)
926 else {
927 return control::text(
928 StatusCode::NOT_FOUND,
929 format!("{:?} is not a host in your ssh_config", ask.host),
930 );
931 };
932
933 let alias = match Alias::new(&known.alias, &known.host, ask.base.as_deref()) {
934 Ok(alias) => alias,
935 Err(e) => return control::text(StatusCode::BAD_REQUEST, format!("{e:#}")),
936 };
937
938 if let Some(open) = self.session(&known.alias).await {
943 let Some(asked) = alias.base() else {
948 return self.opened(&known.alias, &known.host, &open.base);
949 };
950 let wanted = match resolve_base(Some(asked), &open.fs).await {
956 Ok(base) => base,
957 Err(e) => {
958 return control::text(
959 StatusCode::BAD_GATEWAY,
960 format!("working out where to root {}: {e:#}", known.alias),
961 );
962 }
963 };
964 if wanted != open.base {
965 return control::text(
966 StatusCode::CONFLICT,
967 format!(
968 "{} is already open at {}, and {} is not the same place; a second base would change what that origin means underneath any page open in it",
969 known.alias, open.base, wanted
970 ),
971 );
972 }
973 return self.opened(&known.alias, &known.host, &open.base);
974 }
975
976 let fs = match SftpFs::connect(&known.host).await {
977 Ok(fs) => fs,
978 Err(e) => {
979 return control::text(
984 StatusCode::BAD_GATEWAY,
985 format!("ssh to {}: {e:#}", known.host),
986 );
987 }
988 };
989 let base = match resolve_base(alias.base(), &fs).await {
990 Ok(base) => base,
991 Err(e) => {
992 return control::text(
993 StatusCode::BAD_GATEWAY,
994 format!("working out where to root {}: {e:#}", known.alias),
995 );
996 }
997 };
998
999 let session = {
1004 let mut sessions = self.sessions.write().await;
1005 Arc::clone(sessions.entry(known.alias.clone()).or_insert_with(|| {
1006 Arc::new(Session {
1007 host: known.host.clone(),
1008 base,
1009 fs,
1010 })
1011 }))
1012 };
1013 self.opened(&known.alias, &known.host, &session.base)
1014 }
1015
1016 async fn close_alias(&self, body: &[u8]) -> Response<Full<Bytes>> {
1025 #[derive(serde::Deserialize)]
1026 #[serde(deny_unknown_fields)]
1027 struct Ask {
1028 alias: String,
1029 }
1030
1031 let ask: Ask = match serde_json::from_slice(body) {
1032 Ok(ask) => ask,
1033 Err(e) => {
1034 return control::text(
1035 StatusCode::BAD_REQUEST,
1036 format!("close needs a JSON body naming an alias: {e}"),
1037 );
1038 }
1039 };
1040
1041 let gone = self.sessions.write().await.remove(&ask.alias);
1046 match gone {
1047 Some(session) => {
1048 #[derive(serde::Serialize)]
1049 struct Closed<'a> {
1050 alias: &'a str,
1051 host: &'a str,
1052 base: &'a str,
1053 }
1054 control::json(&Closed {
1055 alias: &ask.alias,
1056 host: &session.host,
1057 base: &session.base,
1058 })
1059 }
1060 None => control::text(
1064 StatusCode::NOT_FOUND,
1065 format!("no alias named {:?} is open", ask.alias),
1066 ),
1067 }
1068 }
1069
1070 fn opened(&self, alias: &str, host: &str, base: &str) -> Response<Full<Bytes>> {
1071 #[derive(serde::Serialize)]
1072 struct Opened<'a> {
1073 alias: &'a str,
1074 host: &'a str,
1075 base: &'a str,
1076 url: String,
1077 }
1078 control::json(&Opened {
1079 alias,
1080 host,
1081 base,
1082 url: format!("http://{alias}.{}/", self.suffix),
1083 })
1084 }
1085
1086 async fn show_theme(&self) -> Response<Full<Bytes>> {
1088 #[derive(serde::Serialize)]
1089 struct Choice<'a> {
1090 name: &'a str,
1091 label: &'a str,
1092 variant: &'a str,
1094 }
1095 #[derive(serde::Serialize)]
1096 struct Themes<'a> {
1097 current: &'a str,
1098 themes: Vec<Choice<'a>>,
1099 }
1100 control::json(&Themes {
1103 current: &self.theme.read().await,
1104 themes: theme::all()
1105 .iter()
1106 .map(|t| Choice {
1107 name: &t.name,
1108 label: &t.label,
1109 variant: t.variant,
1110 })
1111 .collect(),
1112 })
1113 }
1114
1115 async fn set_theme(&self, body: &[u8]) -> Response<Full<Bytes>> {
1117 #[derive(serde::Deserialize)]
1118 #[serde(deny_unknown_fields)]
1119 struct Ask {
1120 name: String,
1121 }
1122 let ask: Ask = match serde_json::from_slice(body) {
1123 Ok(ask) => ask,
1124 Err(e) => {
1125 return control::text(
1126 StatusCode::BAD_REQUEST,
1127 format!("theme needs a JSON body naming one: {e}"),
1128 );
1129 }
1130 };
1131 if let Err(e) = theme::check(&ask.name) {
1134 return control::text(StatusCode::BAD_REQUEST, format!("{e:#}"));
1135 }
1136
1137 *self.theme.write().await = ask.name.clone();
1138 let remembered = theme::remember(&ask.name).is_ok();
1142 #[derive(serde::Serialize)]
1143 struct Chose<'a> {
1144 current: &'a str,
1145 remembered: bool,
1146 }
1147 control::json(&Chose {
1148 current: &ask.name,
1149 remembered,
1150 })
1151 }
1152
1153 async fn list_annotations(&self, query: Option<&str>) -> Response<Full<Bytes>> {
1155 let Some(doc) = param(query, "doc") else {
1156 return control::text(
1157 StatusCode::BAD_REQUEST,
1158 "annotations needs a doc parameter, e.g. ?doc=docs/index.html",
1159 );
1160 };
1161 let (session, resolved) = match self.resolve_doc(doc).await {
1162 Ok(v) => v,
1163 Err((status, detail)) => return control::text(status, detail),
1164 };
1165
1166 match annot::Store::new(&session.fs).load(&resolved).await {
1167 Ok(loaded) => control::json(&AnnotationsBody {
1168 doc: resolved,
1169 annotations: loaded.annotations,
1170 skipped: loaded.skipped,
1171 }),
1172 Err(e) => control::text(StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")),
1173 }
1174 }
1175
1176 async fn add_annotation(&self, body: &[u8]) -> Response<Full<Bytes>> {
1181 let request: AddBody = match serde_json::from_slice(body) {
1182 Ok(r) => r,
1183 Err(e) => {
1184 return control::text(StatusCode::BAD_REQUEST, format!("malformed request: {e}"));
1185 }
1186 };
1187
1188 let (session, resolved) = match self.resolve_doc(&request.doc).await {
1189 Ok(v) => v,
1190 Err((status, detail)) => return control::text(status, detail),
1191 };
1192
1193 let id = match (request.op, request.id) {
1198 (annot::Op::Add, None) => match annot::new_id(&self.author) {
1199 Ok(id) => id,
1200 Err(e) => {
1201 return control::text(StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}"));
1202 }
1203 },
1204 (annot::Op::Add, Some(_)) => {
1205 return control::text(
1206 StatusCode::BAD_REQUEST,
1207 "an id is minted by the daemon; do not send one when adding",
1208 );
1209 }
1210 (_, Some(id)) => id,
1211 (_, None) => {
1212 return control::text(StatusCode::BAD_REQUEST, "an update or a delete needs an id");
1213 }
1214 };
1215
1216 let at = std::time::SystemTime::now()
1219 .duration_since(std::time::UNIX_EPOCH)
1220 .map_or(0, |d| d.as_secs());
1221
1222 let record = annot::Record {
1223 op: request.op,
1224 id: id.clone(),
1225 at,
1226 body: request.body,
1227 selectors: request.selectors,
1228 reply_to: request.reply_to,
1229 };
1230
1231 match annot::Store::new(&session.fs)
1232 .append(&resolved, &self.author, &record)
1233 .await
1234 {
1235 Ok(()) => control::json(&AddedBody {
1236 id,
1237 at,
1238 author: &self.author,
1239 }),
1240 Err(e) => control::text(StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")),
1241 }
1242 }
1243
1244 async fn autoindex_of(
1245 &self,
1246 session: &Session,
1247 alias: &str,
1248 path: &str,
1249 resolved: &str,
1250 query: Option<&str>,
1251 ) -> Response<Full<Bytes>> {
1252 let rel = resolved
1256 .strip_prefix(&session.base)
1257 .unwrap_or("")
1258 .to_string();
1259 let entries = match self.listing_of(session, resolved).await {
1260 Ok(entries) => entries,
1261 Err(e) => return fail(StatusCode::NOT_FOUND, format!("{path}: {e:#}")),
1262 };
1263 let sites = self.sites_among(session, resolved, &entries).await;
1264
1265 if query == Some("ls") {
1276 let mut out = String::new();
1277 render_level(&mut out, &rel, &rows_of(&entries, &sites), &[]);
1278 return plain_ok("text/html; charset=utf-8", Bytes::from(out));
1279 }
1280
1281 let mut levels = Vec::new();
1285 let mut at = session.base.clone();
1286 for part in rel.split('/').filter(|p| !p.is_empty()) {
1287 if let Some(entries) = self.cache.listing_entries(&at) {
1288 let here = at.strip_prefix(&session.base).unwrap_or("").to_string();
1289 levels.push((here, rows_of(&entries, &HashSet::new())));
1294 }
1295 at.push('/');
1296 at.push_str(part);
1297 }
1298 levels.push((rel.clone(), rows_of(&entries, &sites)));
1299
1300 plain_ok(
1301 "text/html; charset=utf-8",
1302 Bytes::from(autoindex(alias, &rel, &levels, &self.theme.read().await)),
1303 )
1304 }
1305
1306 async fn listing_of(&self, session: &Session, dir: &str) -> Result<Vec<Entry>> {
1308 if let Some(entries) = self.cache.listing_entries(dir) {
1309 return Ok(entries);
1310 }
1311 let entries = session.fs.list_dir(dir).await?;
1312 self.cache.put_listing(dir, &entries);
1313 Ok(entries)
1314 }
1315
1316 async fn sites_among(
1330 &self,
1331 session: &Session,
1332 dir: &str,
1333 entries: &[Entry],
1334 ) -> HashSet<String> {
1335 const MAX_SCAN: usize = 64;
1338
1339 let names: Vec<&str> = entries
1340 .iter()
1341 .filter(|e| e.attrs.is_dir() && e.name != "." && e.name != ".." && !hidden(&e.name))
1342 .map(|e| e.name.as_str())
1343 .take(MAX_SCAN)
1344 .collect();
1345 if names.is_empty() {
1346 return HashSet::new();
1347 }
1348
1349 let paths: Vec<String> = names.iter().map(|n| format!("{dir}/{n}")).collect();
1350 let missing: Vec<String> = paths
1353 .iter()
1354 .filter(|p| self.cache.listing_entries(p).is_none())
1355 .cloned()
1356 .collect();
1357 if !missing.is_empty() {
1358 for (path, got) in missing.iter().zip(session.fs.list_dirs(&missing).await) {
1359 if let Ok(entries) = got {
1360 self.cache.put_listing(path, &entries);
1361 }
1362 }
1366 }
1367
1368 names
1369 .iter()
1370 .zip(paths.iter())
1371 .filter(|(_, path)| {
1372 self.cache.listing_entries(path).is_some_and(|listing| {
1373 listing
1374 .iter()
1375 .any(|e| e.name == "index.html" && !e.attrs.is_dir())
1376 })
1377 })
1378 .map(|(name, _)| (*name).to_string())
1379 .collect()
1380 }
1381
1382 async fn warm_subresources(&self, session: &Session, doc_path: &str, html: &[u8]) {
1404 let refs = prefetch::scan(html, prefetch::MAX_SUBRESOURCES);
1405 if refs.is_empty() {
1406 return;
1407 }
1408 let dir_of_doc = match doc_path.rsplit_once('/') {
1411 Some((head, _)) => head,
1412 None => "",
1413 };
1414
1415 let mut wanted: Vec<(String, Vec<(String, String)>)> = Vec::new();
1418 for r in &refs {
1419 let url = if r.starts_with('/') {
1420 r.clone()
1421 } else {
1422 format!("{dir_of_doc}/{r}")
1423 };
1424 let Ok(resolved) = guard::resolve(&session.base, &url) else {
1425 continue;
1426 };
1427 let chain = components(&session.base, &resolved);
1428 if chain.is_empty() {
1429 continue;
1430 }
1431 if chain.iter().any(|(_, n)| hidden(n)) {
1435 continue;
1436 }
1437 if self.first_symlink_cached(&chain).is_some() {
1442 continue;
1443 }
1444 if !chain
1450 .iter()
1451 .all(|(dir, _)| self.listable(&session.base, dir))
1452 {
1453 continue;
1454 }
1455 wanted.push((resolved, chain));
1456 }
1457
1458 let all: Vec<(String, String)> = wanted.iter().flat_map(|(_, c)| c.clone()).collect();
1459 let held = self.held_listings(session, &all).await;
1463
1464 let mut to_read = Vec::new();
1465 for (resolved, chain) in &wanted {
1466 if first_symlink(&held, chain).is_some() {
1467 continue;
1468 }
1469 let (dir, name) = &chain[chain.len() - 1];
1470 let Some(attrs) = attrs_in(&held, dir, name) else {
1471 continue;
1472 };
1473 if attrs.is_dir() {
1474 continue;
1475 }
1476 let Some(size) = attrs.size else {
1481 continue;
1482 };
1483 if size == 0 || size > CACHE_WHOLE_MAX {
1491 continue;
1492 }
1493 if self.cache.body(resolved, &attrs).is_some() {
1494 continue;
1495 }
1496 to_read.push((resolved.clone(), attrs, size));
1497 }
1498 if to_read.is_empty() {
1499 return;
1500 }
1501
1502 let reqs: Vec<RangeReq> = to_read
1510 .iter()
1511 .map(|(path, _, size)| RangeReq {
1512 path: path.clone(),
1513 offset: 0,
1514 len: *size,
1515 })
1516 .collect();
1517
1518 for ((path, attrs, size), got) in to_read.iter().zip(session.fs.read_ranges(&reqs).await) {
1519 let Ok(body) = got else {
1520 continue;
1521 };
1522 if body.len() as u64 != *size {
1527 continue;
1528 }
1529 self.cache.put_body(path, attrs, Bytes::from(body));
1530 }
1531 }
1532
1533 async fn listings_along(
1551 &self,
1552 session: &Session,
1553 chain: &[(String, String)],
1554 ) -> Result<HashMap<String, Vec<Entry>>, (String, String)> {
1555 let mut held: HashMap<String, Vec<Entry>> = HashMap::new();
1556 let mut missing: Vec<String> = Vec::new();
1557 for (dir, _) in chain {
1558 if held.contains_key(dir) {
1559 continue;
1560 }
1561 match self.cache.listing_entries(dir) {
1562 Some(entries) => {
1563 held.insert(dir.clone(), entries);
1564 }
1565 None if !missing.contains(dir) => missing.push(dir.clone()),
1569 None => {}
1570 }
1571 }
1572 if missing.is_empty() {
1573 return Ok(held);
1574 }
1575 for (dir, result) in missing.iter().zip(session.fs.list_dirs(&missing).await) {
1576 match result {
1577 Ok(entries) => {
1578 self.cache.put_listing(dir, &entries);
1579 held.insert(dir.clone(), entries);
1580 }
1581 Err(e) if crate::fs::is_absent(&e) => {}
1586 Err(e) => return Err((dir.clone(), format!("{e:#}"))),
1587 }
1588 }
1589 Ok(held)
1590 }
1591
1592 fn listable(&self, base: &str, dir: &str) -> bool {
1601 if dir.trim_end_matches('/') == base.trim_end_matches('/') {
1602 return true;
1603 }
1604 components(base, dir).iter().all(|(parent, name)| {
1605 self.cache
1606 .attrs_of(parent, name)
1607 .is_some_and(|a| a.is_dir() && !a.is_symlink())
1608 })
1609 }
1610
1611 async fn held_listings(
1621 &self,
1622 session: &Session,
1623 chain: &[(String, String)],
1624 ) -> HashMap<String, Vec<Entry>> {
1625 self.listings_along(session, chain)
1626 .await
1627 .unwrap_or_default()
1628 }
1629}
1630
1631impl Origin {
1632 fn first_symlink_cached(&self, chain: &[(String, String)]) -> Option<String> {
1638 chain.iter().find_map(|(dir, name)| {
1639 self.cache
1640 .attrs_of(dir, name)
1641 .filter(Attrs::is_symlink)
1642 .map(|_| format!("{dir}/{name}"))
1643 })
1644 }
1645}
1646
1647fn attrs_in(held: &HashMap<String, Vec<Entry>>, dir: &str, name: &str) -> Option<Attrs> {
1649 held.get(dir)
1650 .and_then(|entries| entries.iter().find(|e| e.name == name))
1651 .map(|e| e.attrs)
1652}
1653
1654fn first_symlink(held: &HashMap<String, Vec<Entry>>, chain: &[(String, String)]) -> Option<String> {
1655 chain.iter().find_map(|(dir, name)| {
1656 attrs_in(held, dir, name)
1657 .filter(Attrs::is_symlink)
1658 .map(|_| format!("{dir}/{name}"))
1659 })
1660}
1661
1662impl Origin {
1663 async fn alias_index(&self) -> String {
1664 let names = self.alias_names().await;
1665 let mut s = String::from(
1666 "<!doctype html><html><head><meta charset=\"utf-8\"><title>ssh-browser</title></head><body><h1>ssh-browser</h1><ul>",
1667 );
1668 for name in names {
1669 let href = format!("http://{name}.{}/", self.suffix);
1670 s.push_str("<li><a href=\"");
1671 s.push_str(&escape(&href));
1672 s.push_str("\">");
1673 s.push_str(&escape(&href));
1674 s.push_str("</a></li>");
1675 }
1676 s.push_str("</ul></body></html>");
1677 s
1678 }
1679}
1680
1681#[derive(Serialize)]
1682struct AnnotationsBody {
1683 doc: String,
1684 annotations: Vec<annot::Annotation>,
1685 skipped: usize,
1687}
1688
1689#[derive(Serialize)]
1690struct AddedBody<'a> {
1691 id: String,
1692 at: u64,
1693 author: &'a str,
1694}
1695
1696#[derive(Deserialize)]
1698struct AddBody {
1699 doc: String,
1700 op: annot::Op,
1701 #[serde(default)]
1703 id: Option<String>,
1704 #[serde(default)]
1705 body: Option<String>,
1706 #[serde(default)]
1707 selectors: Option<serde_json::Value>,
1708 #[serde(default)]
1709 reply_to: Option<String>,
1710}
1711
1712fn param<'q>(query: Option<&'q str>, want: &str) -> Option<&'q str> {
1717 query?.split('&').find_map(|pair| {
1718 let (key, value) = pair.split_once('=')?;
1719 (key == want).then_some(value)
1720 })
1721}
1722
1723fn components(base: &str, file: &str) -> Vec<(String, String)> {
1729 let base = base.trim_end_matches('/');
1730 let relative = file
1731 .strip_prefix(base)
1732 .unwrap_or("")
1733 .trim_start_matches('/');
1734
1735 let mut out = Vec::new();
1736 let mut dir = base.to_string();
1737 for name in relative.split('/').filter(|s| !s.is_empty()) {
1738 out.push((dir.clone(), name.to_string()));
1739 dir = format!("{dir}/{name}");
1740 }
1741 out
1742}
1743
1744const MAX_CONTROL_BODY: usize = 256 * 1024;
1749
1750async fn read_body<B>(body: B) -> Result<Bytes, String>
1751where
1752 B: hyper::body::Body,
1753 B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
1754{
1755 use http_body_util::{BodyExt, Limited};
1756 Limited::new(body, MAX_CONTROL_BODY)
1757 .collect()
1758 .await
1759 .map(|collected| collected.to_bytes())
1760 .map_err(|e| format!("reading the request body: {e}"))
1761}
1762
1763fn header<B>(req: &Request<B>, name: HeaderName) -> Option<String> {
1764 req.headers()
1765 .get(name)
1766 .and_then(|v| v.to_str().ok())
1767 .map(str::to_string)
1768}
1769
1770fn respond(
1772 file: &str,
1773 body: Bytes,
1774 tag: Option<&str>,
1775 wanted: &range::Resolved,
1776 size: u64,
1777) -> Response<Full<Bytes>> {
1778 match wanted {
1779 range::Resolved::Part { start, end } => {
1780 let lo = usize::try_from(*start)
1783 .unwrap_or(usize::MAX)
1784 .min(body.len());
1785 let hi = usize::try_from(end.saturating_add(1))
1786 .unwrap_or(usize::MAX)
1787 .min(body.len())
1788 .max(lo);
1789 partial(
1790 mime::guess(file),
1791 body.slice(lo..hi),
1792 tag,
1793 *start,
1794 *end,
1795 size,
1796 )
1797 }
1798 _ => served(mime::guess(file), body, tag),
1799 }
1800}
1801
1802fn partial(
1803 content_type: &str,
1804 body: Bytes,
1805 tag: Option<&str>,
1806 start: u64,
1807 end: u64,
1808 size: u64,
1809) -> Response<Full<Bytes>> {
1810 let mut b = Response::builder()
1811 .status(StatusCode::PARTIAL_CONTENT)
1812 .header(CONTENT_TYPE, content_type)
1813 .header(CACHE_CONTROL, "no-cache")
1814 .header(ACCEPT_RANGES, "bytes")
1815 .header(CONTENT_RANGE, format!("bytes {start}-{end}/{size}"));
1816 if let Some(tag) = tag {
1817 b = b.header(ETAG, tag);
1818 }
1819 b.body(Full::new(body))
1820 .unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR, "malformed 206"))
1821}
1822
1823fn unsatisfiable(size: u64) -> Response<Full<Bytes>> {
1826 Response::builder()
1827 .status(StatusCode::RANGE_NOT_SATISFIABLE)
1828 .header(CONTENT_TYPE, "text/plain; charset=utf-8")
1829 .header(CONTENT_RANGE, format!("bytes */{size}"))
1830 .body(Full::new(Bytes::from_static(b"range not satisfiable")))
1831 .unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR, "malformed 416"))
1832}
1833
1834fn host_of<B>(req: &Request<B>) -> Option<String> {
1835 req.headers()
1838 .get(HOST)
1839 .and_then(|v| v.to_str().ok())
1840 .map(str::to_string)
1841 .or_else(|| req.uri().host().map(str::to_string))
1842}
1843
1844fn served(content_type: &str, body: Bytes, tag: Option<&str>) -> Response<Full<Bytes>> {
1845 let mut b = Response::builder()
1846 .status(StatusCode::OK)
1847 .header(CONTENT_TYPE, content_type)
1848 .header(CACHE_CONTROL, "no-cache")
1852 .header(ACCEPT_RANGES, "bytes");
1855 if let Some(tag) = tag {
1856 b = b.header(ETAG, tag);
1857 }
1858 b.body(Full::new(body))
1859 .unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR, "malformed response"))
1860}
1861
1862fn plain_ok(content_type: &str, body: Bytes) -> Response<Full<Bytes>> {
1864 served(content_type, body, None)
1865}
1866
1867fn not_modified(tag: &str) -> Response<Full<Bytes>> {
1874 Response::builder()
1875 .status(StatusCode::NOT_MODIFIED)
1876 .header(ETAG, tag)
1877 .header(CACHE_CONTROL, "no-cache")
1878 .body(Full::new(Bytes::new()))
1879 .unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR, "malformed 304"))
1880}
1881
1882fn fail(status: StatusCode, detail: impl Into<String>) -> Response<Full<Bytes>> {
1883 Response::builder()
1884 .status(status)
1885 .header(CONTENT_TYPE, "text/plain; charset=utf-8")
1886 .body(Full::new(Bytes::from(detail.into())))
1887 .expect("a plain-text body with static headers always builds")
1888}
1889
1890fn redirect(to: &str) -> Response<Full<Bytes>> {
1891 Response::builder()
1892 .status(StatusCode::MOVED_PERMANENTLY)
1893 .header(LOCATION, to)
1894 .body(Full::new(Bytes::new()))
1895 .unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR, "bad redirect target"))
1896}
1897
1898fn hidden(name: &str) -> bool {
1910 name.starts_with('.')
1911}
1912
1913struct Row {
1915 name: String,
1916 dir: bool,
1917 site: bool,
1919 size: Option<String>,
1921 modified: Option<String>,
1922 kind: &'static str,
1924}
1925
1926fn rows_of(entries: &[Entry], sites: &HashSet<String>) -> Vec<Row> {
1928 let mut visible: Vec<&Entry> = entries
1929 .iter()
1930 .filter(|e| e.name != "." && e.name != ".." && !hidden(&e.name))
1933 .collect();
1934 visible.sort_by(|a, b| (rank(a, sites), &a.name).cmp(&(rank(b, sites), &b.name)));
1935
1936 visible
1937 .into_iter()
1938 .map(|e| {
1939 let dir = e.attrs.is_dir();
1940 Row {
1941 name: e.name.clone(),
1942 dir,
1943 site: dir && sites.contains(&e.name),
1944 size: if dir {
1945 None
1946 } else {
1947 e.attrs.size.map(human_size)
1948 },
1949 modified: e.attrs.mtime.map(utc_stamp),
1950 kind: if dir { "dir" } else { family(&e.name) },
1951 }
1952 })
1953 .collect()
1954}
1955
1956fn rank(e: &Entry, sites: &HashSet<String>) -> (u8, u8) {
1966 if e.attrs.is_dir() {
1967 (0, u8::from(!sites.contains(&e.name)))
1968 } else {
1969 (1, u8::from(!is_page(&e.name)))
1970 }
1971}
1972
1973fn is_page(name: &str) -> bool {
1974 matches!(extension_of(name).as_deref(), Some("html" | "htm"))
1975}
1976
1977fn family(name: &str) -> &'static str {
1983 match extension_of(name).as_deref() {
1984 Some("html" | "htm") => "k-page",
1985 Some("md" | "txt" | "rst" | "tex" | "bib" | "pdf" | "org" | "adoc") => "k-doc",
1986 Some("json" | "toml" | "yaml" | "yml" | "csv" | "tsv" | "xml" | "ini" | "lock") => "k-data",
1987 Some(
1988 "rs" | "jl" | "py" | "ts" | "js" | "mjs" | "sh" | "c" | "h" | "cpp" | "go" | "rb"
1989 | "lua" | "css" | "scss" | "lean" | "hs" | "java" | "kt" | "swift" | "sql",
1990 ) => "k-code",
1991 Some(
1992 "png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "avif" | "ico" | "mp4" | "webm"
1993 | "mov" | "mp3" | "wav",
1994 ) => "k-media",
1995 _ => "k-plain",
1996 }
1997}
1998
1999fn extension_of(name: &str) -> Option<String> {
2001 let dot = name.rfind('.')?;
2002 if dot == 0 || dot + 1 == name.len() {
2005 return None;
2006 }
2007 Some(name[dot + 1..].to_ascii_lowercase())
2008}
2009
2010fn human_size(n: u64) -> String {
2015 const UNITS: [&str; 5] = ["KiB", "MiB", "GiB", "TiB", "PiB"];
2016 if n < 1024 {
2017 return format!("{n} B");
2018 }
2019 let mut v = n as f64 / 1024.0;
2020 let mut unit = 0;
2021 while v >= 1024.0 && unit + 1 < UNITS.len() {
2022 v /= 1024.0;
2023 unit += 1;
2024 }
2025 if v < 10.0 {
2028 format!("{v:.1} {}", UNITS[unit])
2029 } else {
2030 format!("{v:.0} {}", UNITS[unit])
2031 }
2032}
2033
2034fn utc_stamp(secs: u32) -> String {
2041 let secs = i64::from(secs);
2042 let (y, m, d) = civil_from_days(secs.div_euclid(86_400));
2043 let rest = secs.rem_euclid(86_400);
2044 let (hh, mm) = (rest / 3600, (rest % 3600) / 60);
2045 format!("{y:04}-{m:02}-{d:02} {hh:02}:{mm:02}")
2046}
2047
2048fn civil_from_days(z: i64) -> (i64, u32, u32) {
2052 let z = z + 719_468;
2053 let era = if z >= 0 { z } else { z - 146_096 }.div_euclid(146_097);
2054 let doe = (z - era * 146_097) as u64;
2055 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
2056 let y = yoe as i64 + era * 400;
2057 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
2058 let mp = (5 * doy + 2) / 153;
2059 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
2060 let m = u32::try_from(if mp < 10 { mp + 3 } else { mp - 9 }).unwrap_or(1);
2061 (if m <= 2 { y + 1 } else { y }, m, d)
2062}
2063
2064const LISTING_CSS: &str = "\
2073*{box-sizing:border-box}\
2074html{background:var(--bg)}\
2075body{color:var(--fg);font:13px/1.5 system-ui,-apple-system,Segoe UI,sans-serif;margin:0}\
2076header{align-items:baseline;background:var(--bg);border-bottom:1px solid var(--line);\
2077display:flex;gap:6px;padding:7px 12px;position:sticky;top:0;z-index:1}\
2078header b{font-size:12px;font-weight:600;letter-spacing:.04em}\
2079header span{color:var(--dim);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;\
2080font-size:11px;overflow-wrap:anywhere}\
2081#tree{padding:4px 0 40px}\
2082ul{list-style:none;margin:0;padding:0}\
2083li ul{border-left:1px solid var(--line);margin-left:15px}\
2084li>ul{display:none}\
2085li.open>ul{display:block}\
2086.row{align-items:center;color:inherit;display:grid;gap:6px;\
2087grid-template-columns:14px 14px 1fr auto auto;line-height:22px;padding-right:12px;\
2088text-decoration:none;white-space:nowrap}\
2089.row:hover{background:var(--hover)}\
2090.row.here{background:var(--sel)}\
2091.row.here .size,.row.here .when{color:var(--dim)}\
2092.row:focus-visible{outline:1px solid var(--accent);outline-offset:-1px}\
2093.tw{color:var(--dim);font-size:11px;line-height:22px;text-align:center;\
2094transition:transform .1s linear}\
2095li.open>.row .tw{transform:rotate(90deg)}\
2096.ico{border-radius:2px;height:9px;justify-self:center;width:9px}\
2097.dir>.ico{background:var(--dim);border-radius:1px 3px 3px 3px}\
2098.site>.ico{background:var(--accent);border-radius:1px 3px 3px 3px}\
2099.site>.name{color:var(--accent)}\
2100.k-page>.ico{background:var(--k-page)}\
2101.k-page>.name{color:var(--k-page)}\
2102.k-doc>.ico{background:var(--k-doc)}\
2103.k-data>.ico{background:var(--k-data)}\
2104.k-code>.ico{background:var(--k-code)}\
2105.k-media>.ico{background:var(--k-media)}\
2106.k-plain>.ico{background:var(--k-plain)}\
2107.name{overflow:hidden;text-overflow:ellipsis}\
2108.size,.when{color:var(--faint);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;\
2109font-size:11px;font-variant-numeric:tabular-nums}\
2110.size{text-align:right}\
2111.row.busy .tw{opacity:.4}\
2112.row.failed .when{color:var(--k-page)}\
2113.empty{color:var(--faint);padding:10px 16px}\
2114@media(max-width:620px){.when{display:none}}";
2115
2116const LISTING_JS: &str = "\
2126const tree=document.getElementById('tree');\
2127tree.addEventListener('click',async e=>{\
2128const row=e.target.closest('a.row');\
2129if(!row||row.dataset.dir!=='1')return;\
2130e.preventDefault();\
2131const li=row.parentElement;\
2132if(li.querySelector(':scope>ul')){li.classList.toggle('open');mark(row);return;}\
2133row.classList.add('busy');\
2134try{\
2135const res=await fetch(row.getAttribute('href')+'?ls');\
2136if(!res.ok)throw new Error(res.status);\
2137li.insertAdjacentHTML('beforeend',await res.text());\
2138li.classList.add('open');mark(row);\
2139}catch(err){row.classList.add('failed');\
2140row.querySelector('.when').textContent='could not be listed: '+err.message;}\
2141finally{row.classList.remove('busy');}\
2142});\
2143function mark(row){\
2144for(const other of tree.querySelectorAll('a.row.here'))other.classList.remove('here');\
2145row.classList.add('here');\
2146history.replaceState(null,'',row.getAttribute('href'));\
2147document.querySelector('header span').textContent=\
2148decodeURIComponent(new URL(row.href).pathname);\
2149}";
2150
2151fn render_level(out: &mut String, path: &str, rows: &[Row], open: &[(String, Vec<Row>)]) {
2157 out.push_str("<ul>");
2158 for row in rows {
2159 let here = format!("{path}/{}", row.name);
2160 let deeper = open.first().filter(|(next, _)| *next == here);
2161
2162 out.push_str(if deeper.is_some() {
2163 "<li class=\"open\">"
2164 } else {
2165 "<li>"
2166 });
2167 out.push_str("<a class=\"row ");
2168 out.push_str(match (row.dir, row.site) {
2169 (true, true) => "site",
2172 (true, false) => "dir",
2173 (false, _) => row.kind,
2174 });
2175 if deeper.is_some() && open.len() == 1 {
2178 out.push_str(" here");
2179 }
2180 out.push_str("\" href=\"");
2181 out.push_str(path);
2182 out.push('/');
2183 out.push_str(&url_escape(&row.name));
2184 if row.dir {
2185 out.push('/');
2186 }
2187 out.push_str(if row.dir {
2189 "\" data-dir=\"1\"><span class=\"tw\">\u{25b8}</span>"
2190 } else {
2191 "\"><span class=\"tw\"></span>"
2192 });
2193 out.push_str("<span class=\"ico\"></span><span class=\"name\">");
2194 out.push_str(&escape(&row.name));
2195 out.push_str("</span><span class=\"size\">");
2196 out.push_str(row.size.as_deref().unwrap_or(""));
2197 out.push_str("</span><span class=\"when\">");
2198 out.push_str(row.modified.as_deref().unwrap_or(""));
2199 out.push_str("</span></a>");
2200
2201 if let Some((next, rows)) = deeper {
2202 render_level(out, next, rows, &open[1..]);
2203 }
2204 out.push_str("</li>");
2205 }
2206 out.push_str("</ul>");
2207}
2208
2209fn autoindex(alias: &str, rel: &str, levels: &[(String, Vec<Row>)], theme: &str) -> String {
2215 let shown = if rel.is_empty() { "/" } else { rel };
2216 let mut s = String::from("<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">");
2217 s.push_str("<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>");
2218 s.push_str(&escape(&format!("{shown} \u{b7} {alias}")));
2219 s.push_str("</title><style>");
2220 s.push_str(&theme::css_for(theme));
2222 s.push_str(LISTING_CSS);
2223 s.push_str("</style></head><body><header><b>");
2224 s.push_str(&escape(alias));
2225 s.push_str("</b><span>");
2226 s.push_str(&escape(shown));
2227 s.push_str("</span></header><div id=\"tree\">");
2228
2229 match levels.split_first() {
2230 Some(((path, rows), rest)) if !rows.is_empty() => render_level(&mut s, path, rows, rest),
2231 _ => s.push_str("<p class=\"empty\">This directory is empty.</p>"),
2234 }
2235
2236 s.push_str("</div><script>");
2237 s.push_str(LISTING_JS);
2238 s.push_str("</script></body></html>");
2239 s
2240}
2241
2242fn escape(s: &str) -> String {
2245 s.replace('&', "&")
2246 .replace('<', "<")
2247 .replace('>', ">")
2248 .replace('"', """)
2249}
2250
2251fn url_escape(s: &str) -> String {
2254 let mut out = String::with_capacity(s.len());
2255 for b in s.bytes() {
2256 match b {
2257 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
2258 out.push(b as char);
2259 }
2260 _ => out.push_str(&format!("%{b:02X}")),
2261 }
2262 }
2263 out
2264}
2265
2266#[cfg(test)]
2267mod tests {
2268 use super::*;
2269 use crate::sftp::wire::Attrs;
2270 use crate::testing::{FakeRemote, dir_attrs, file_attrs, symlink_attrs};
2271 use http_body_util::{BodyExt, Empty};
2272
2273 const TEST_TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
2274
2275 async fn body_of(res: Response<Full<Bytes>>) -> Bytes {
2276 res.into_body()
2277 .collect()
2278 .await
2279 .expect("a Full body always collects")
2280 .to_bytes()
2281 }
2282
2283 fn loopback(path: &str, token: Option<&str>) -> Request<Empty<Bytes>> {
2285 let mut b = Request::builder().uri(path).header(HOST, "127.0.0.1:7391");
2286 if let Some(t) = token {
2287 b = b.header(control::TOKEN_HEADER, t);
2288 }
2289 b.body(Empty::<Bytes>::new()).expect("request builds")
2290 }
2291
2292 fn from_site(path: &str, site: Option<&str>) -> Request<Empty<Bytes>> {
2295 let mut b = Request::builder().uri(path).header(HOST, "127.0.0.1:7391");
2296 if let Some(site) = site {
2297 b = b.header(control::FETCH_SITE_HEADER, site);
2298 }
2299 b.body(Empty::<Bytes>::new()).expect("request builds")
2300 }
2301
2302 fn control_post(path: &str, token: Option<&str>, body: &str) -> Request<Full<Bytes>> {
2303 let mut b = Request::builder()
2304 .method(Method::POST)
2305 .uri(path)
2306 .header(HOST, "127.0.0.1:7391");
2307 if let Some(t) = token {
2308 b = b.header(control::TOKEN_HEADER, t);
2309 }
2310 b.body(Full::new(Bytes::from(body.to_string())))
2311 .expect("request builds")
2312 }
2313
2314 async fn json_of(res: Response<Full<Bytes>>) -> serde_json::Value {
2315 let bytes = body_of(res).await;
2316 serde_json::from_slice(&bytes).expect("a control response is json")
2317 }
2318
2319 fn ranged(path: &str, range: &str) -> Request<Empty<Bytes>> {
2320 Request::builder()
2321 .uri(format!("http://docs.ssh-browser{path}"))
2322 .header(HOST, "docs.ssh-browser")
2323 .header(RANGE, range)
2324 .body(Empty::new())
2325 .expect("request builds")
2326 }
2327
2328 async fn origin_with(remote: FakeRemote) -> Origin {
2331 origin_with_cache(remote, Cache::default()).await
2332 }
2333
2334 async fn origin_with_cache(remote: FakeRemote, cache: Cache) -> Origin {
2335 let fs = remote.spawn().await;
2336 let mut sessions = HashMap::new();
2337 sessions.insert(
2338 "docs".to_string(),
2339 Arc::new(Session {
2340 host: "nowhere".to_string(),
2341 base: "/srv".to_string(),
2342 fs,
2343 }),
2344 );
2345 Origin {
2346 suffix: "ssh-browser".to_string(),
2347 port: 7391,
2348 sessions: RwLock::new(sessions),
2349 cache,
2350 theme: RwLock::new(theme::DEFAULT.to_string()),
2351 token: Token::from_hex(TEST_TOKEN),
2352 author: "souta".to_string(),
2353 }
2354 }
2355
2356 fn get(path: &str, if_none_match: Option<&str>) -> Request<Empty<Bytes>> {
2357 let mut b = Request::builder()
2358 .uri(format!("http://docs.ssh-browser{path}"))
2359 .header(HOST, "docs.ssh-browser");
2360 if let Some(tag) = if_none_match {
2361 b = b.header(IF_NONE_MATCH, tag);
2362 }
2363 b.body(Empty::new()).expect("request builds")
2364 }
2365
2366 async fn trips(origin: &Origin) -> u64 {
2367 origin
2368 .sessions
2369 .read()
2370 .await
2371 .values()
2372 .map(|s| s.fs.round_trips())
2373 .sum()
2374 }
2375
2376 fn one_page() -> FakeRemote {
2377 FakeRemote::new()
2378 .dir("/srv", vec![("a.html", file_attrs(5, 100))])
2379 .file("/srv/a.html", b"hello")
2380 }
2381
2382 fn page_with_subresources(n: usize) -> FakeRemote {
2385 let mut html = String::from(
2386 "<!doctype html><html><head><link rel=\"stylesheet\" href=\"assets/style.css\"><script src=\"assets/app.js\"></script></head><body>",
2387 );
2388 for i in 0..n {
2389 html.push_str(&format!("<img src=\"assets/{i}.png\">"));
2390 }
2391 html.push_str("</body></html>");
2392
2393 let mut assets = vec!["style.css".to_string(), "app.js".to_string()];
2394 assets.extend((0..n).map(|i| format!("{i}.png")));
2395
2396 let mut remote = FakeRemote::new()
2397 .dir(
2398 "/srv",
2399 vec![
2400 ("index.html", file_attrs(html.len() as u64, 100)),
2401 ("assets", dir_attrs()),
2402 ],
2403 )
2404 .dir(
2405 "/srv/assets",
2406 assets
2407 .iter()
2408 .map(|name| (name.as_str(), file_attrs(3, 1)))
2409 .collect(),
2410 )
2411 .file("/srv/index.html", html.as_bytes());
2412 for name in &assets {
2413 remote = remote.file(&format!("/srv/assets/{name}"), b"xxx");
2414 }
2415 remote
2416 }
2417
2418 #[tokio::test]
2425 async fn a_pages_subresources_are_already_held_when_the_browser_asks_for_them() {
2426 const N: usize = 40;
2427 let origin = origin_with(page_with_subresources(N)).await;
2428
2429 let res = origin.handle(get("/index.html", None)).await;
2430 assert_eq!(res.status(), StatusCode::OK);
2431
2432 let before = trips(&origin).await;
2433 for i in 0..N {
2434 let path = format!("/assets/{i}.png");
2435 let res = origin.handle(get(&path, None)).await;
2436 assert_eq!(res.status(), StatusCode::OK, "{path}");
2437 assert_eq!(&body_of(res).await[..], b"xxx", "{path}");
2438 }
2439 for name in ["style.css", "app.js"] {
2440 let res = origin.handle(get(&format!("/assets/{name}"), None)).await;
2441 assert_eq!(res.status(), StatusCode::OK, "{name}");
2442 }
2443
2444 assert_eq!(
2445 trips(&origin).await - before,
2446 0,
2447 "reading the page's own references is what makes these free"
2448 );
2449 }
2450
2451 #[tokio::test]
2454 async fn serving_a_page_costs_the_same_however_many_subresources_it_has() {
2455 async fn cost(n: usize) -> u64 {
2456 let origin = origin_with(page_with_subresources(n)).await;
2457 let before = trips(&origin).await;
2458 let res = origin.handle(get("/index.html", None)).await;
2459 assert_eq!(res.status(), StatusCode::OK);
2460 trips(&origin).await - before
2461 }
2462 assert_eq!(cost(4).await, cost(40).await);
2463 }
2464
2465 fn page_referring_to(refs: &[&str], extra: Vec<(&'static str, Attrs)>) -> FakeRemote {
2469 let mut html = String::from("<!doctype html><html><body>");
2470 for r in refs {
2471 html.push_str(&format!("<img src=\"{r}\">"));
2472 }
2473 html.push_str("</body></html>");
2474
2475 let mut entries = vec![("index.html", file_attrs(html.len() as u64, 100))];
2476 entries.extend(extra);
2477 FakeRemote::new()
2478 .dir("/srv", entries)
2479 .file("/srv/index.html", html.as_bytes())
2480 }
2481
2482 async fn cost_of_serving(refs: &[&str], extra: Vec<(&'static str, Attrs)>) -> u64 {
2483 let origin = origin_with(page_referring_to(refs, extra)).await;
2484 let before = trips(&origin).await;
2485 let res = origin.handle(get("/index.html", None)).await;
2486 assert_eq!(res.status(), StatusCode::OK);
2487 trips(&origin).await - before
2488 }
2489
2490 #[tokio::test]
2493 async fn a_page_cannot_prefetch_its_way_out_of_the_alias_base() {
2494 let baseline = cost_of_serving(&[], vec![]).await;
2495 assert_eq!(
2496 cost_of_serving(&["../../../etc/passwd", "/../../etc/shadow"], vec![]).await,
2497 baseline,
2498 "an escaping reference is gone before anything is listed or read"
2499 );
2500 }
2501
2502 #[tokio::test]
2505 async fn a_page_cannot_prefetch_through_a_symlink() {
2506 let link = || vec![("link", symlink_attrs())];
2507 let baseline = cost_of_serving(&[], link()).await;
2508 assert_eq!(
2509 cost_of_serving(&["link/inside.png"], link()).await,
2510 baseline,
2511 "the symlink is known from the listing the page itself needed"
2512 );
2513
2514 let origin = origin_with(page_referring_to(&["link/inside.png"], link())).await;
2517 assert_eq!(
2518 origin.handle(get("/index.html", None)).await.status(),
2519 StatusCode::OK
2520 );
2521 assert_eq!(
2522 origin.handle(get("/link/inside.png", None)).await.status(),
2523 StatusCode::FORBIDDEN
2524 );
2525 }
2526
2527 #[tokio::test]
2539 async fn a_page_cannot_get_a_symlink_below_an_unlisted_directory_opened() {
2540 let html = "<!doctype html><html><body><img src=\"assets/link/secret.txt\"></body></html>";
2541 let origin = origin_with(
2542 FakeRemote::new()
2543 .dir(
2544 "/srv",
2545 vec![
2546 ("index.html", file_attrs(html.len() as u64, 100)),
2547 ("assets", dir_attrs()),
2548 ],
2549 )
2550 .dir("/srv/assets", vec![("link", symlink_attrs())])
2551 .dir("/srv/assets/link", vec![("secret.txt", file_attrs(9, 1))])
2553 .file("/srv/index.html", html.as_bytes())
2554 .file("/srv/assets/link/secret.txt", b"elsewhere"),
2555 )
2556 .await;
2557
2558 assert_eq!(
2559 origin.handle(get("/index.html", None)).await.status(),
2560 StatusCode::OK
2561 );
2562 assert!(
2563 !origin.cache.has_listing("/srv/assets/link"),
2564 "the daemon listed the directory a symlink points at"
2565 );
2566
2567 assert_eq!(
2570 origin
2571 .handle(get("/assets/link/secret.txt", None))
2572 .await
2573 .status(),
2574 StatusCode::FORBIDDEN
2575 );
2576 }
2577
2578 #[tokio::test]
2582 async fn a_reference_in_a_real_subdirectory_is_still_prefetched() {
2583 let html = "<!doctype html><html><body><img src=\"assets/x.png\"></body></html>";
2584 let origin = origin_with(
2585 FakeRemote::new()
2586 .dir(
2587 "/srv",
2588 vec![
2589 ("index.html", file_attrs(html.len() as u64, 100)),
2590 ("assets", dir_attrs()),
2591 ],
2592 )
2593 .dir("/srv/assets", vec![("x.png", file_attrs(3, 1))])
2594 .file("/srv/index.html", html.as_bytes())
2595 .file("/srv/assets/x.png", b"xxx"),
2596 )
2597 .await;
2598
2599 assert_eq!(
2600 origin.handle(get("/index.html", None)).await.status(),
2601 StatusCode::OK
2602 );
2603 let before = trips(&origin).await;
2604 let res = origin.handle(get("/assets/x.png", None)).await;
2605 assert_eq!(res.status(), StatusCode::OK);
2606 assert_eq!(&body_of(res).await[..], b"xxx");
2607 assert_eq!(
2608 trips(&origin).await - before,
2609 0,
2610 "a subdirectory one level down must still be warmed"
2611 );
2612 }
2613
2614 #[tokio::test]
2621 async fn a_large_subresource_costs_what_a_small_one_costs() {
2622 async fn cost(bytes: usize) -> u64 {
2623 let html = "<!doctype html><html><body><img src=\"assets/big.bin\"></body></html>";
2624 let origin = origin_with(
2625 FakeRemote::new()
2626 .dir(
2627 "/srv",
2628 vec![
2629 ("index.html", file_attrs(html.len() as u64, 100)),
2630 ("assets", dir_attrs()),
2631 ],
2632 )
2633 .dir(
2634 "/srv/assets",
2635 vec![("big.bin", file_attrs(bytes as u64, 1))],
2636 )
2637 .file("/srv/index.html", html.as_bytes())
2638 .file("/srv/assets/big.bin", &vec![b'x'; bytes]),
2639 )
2640 .await;
2641
2642 let before = trips(&origin).await;
2643 assert_eq!(
2644 origin.handle(get("/index.html", None)).await.status(),
2645 StatusCode::OK
2646 );
2647 let spent = trips(&origin).await - before;
2648
2649 let at = trips(&origin).await;
2652 let res = origin.handle(get("/assets/big.bin", None)).await;
2653 assert_eq!(res.status(), StatusCode::OK);
2654 assert_eq!(body_of(res).await.len(), bytes);
2655 assert_eq!(
2656 trips(&origin).await - at,
2657 0,
2658 "{bytes} bytes should have been held"
2659 );
2660
2661 spent
2662 }
2663
2664 assert_eq!(cost(1024).await, cost(200 * 1024).await);
2666 }
2667
2668 #[tokio::test]
2678 async fn a_subresource_the_listing_calls_empty_is_not_prefetched() {
2679 let html = "<!doctype html><html><body><img src=\"assets/x.png\"></body></html>";
2680 let sizeless = Attrs {
2681 permissions: Some(0o100644),
2682 mtime: Some(1),
2683 ..Attrs::default()
2684 };
2685 let origin = origin_with(
2686 FakeRemote::new()
2687 .dir(
2688 "/srv",
2689 vec![
2690 ("index.html", file_attrs(html.len() as u64, 100)),
2691 ("assets", dir_attrs()),
2692 ],
2693 )
2694 .dir("/srv/assets", vec![("x.png", sizeless)])
2695 .file("/srv/index.html", html.as_bytes())
2696 .file("/srv/assets/x.png", b"xxx"),
2697 )
2698 .await;
2699
2700 assert_eq!(
2701 origin.handle(get("/index.html", None)).await.status(),
2702 StatusCode::OK
2703 );
2704 let res = origin.handle(get("/assets/x.png", None)).await;
2705 assert_eq!(res.status(), StatusCode::OK);
2706 assert_eq!(
2707 &body_of(res).await[..],
2708 b"xxx",
2709 "the real request must still serve the whole file"
2710 );
2711 }
2712
2713 #[tokio::test]
2715 async fn an_oversized_subresource_is_not_prefetched() {
2716 async fn cost(size: u64) -> u64 {
2717 let html =
2718 "<!doctype html><html><body><video src=\"assets/film.mp4\"></video></body></html>";
2719 let origin = origin_with(
2720 FakeRemote::new()
2721 .dir(
2722 "/srv",
2723 vec![
2724 ("index.html", file_attrs(html.len() as u64, 100)),
2725 ("assets", dir_attrs()),
2726 ],
2727 )
2728 .dir("/srv/assets", vec![("film.mp4", file_attrs(size, 1))])
2729 .file("/srv/index.html", html.as_bytes())
2730 .file("/srv/assets/film.mp4", b"xxx"),
2731 )
2732 .await;
2733 let before = trips(&origin).await;
2734 assert_eq!(
2735 origin.handle(get("/index.html", None)).await.status(),
2736 StatusCode::OK
2737 );
2738 trips(&origin).await - before
2739 }
2740
2741 let read_it = cost(3).await;
2744 let skipped = cost(CACHE_WHOLE_MAX + 1).await;
2745 assert!(
2746 skipped < read_it,
2747 "an oversized subresource cost {skipped} against {read_it} for a small one"
2748 );
2749 }
2750
2751 #[tokio::test]
2763 async fn the_port_is_taken_before_any_host_is_connected() {
2764 let held = TcpListener::bind(("127.0.0.1", 0))
2765 .await
2766 .expect("a free port");
2767 let port = held.local_addr().expect("its address").port();
2768
2769 const NOWHERE: &str = "a-host-that-cannot-resolve.invalid";
2770 let result = Origin::bind(
2771 vec![Alias::new("docs", NOWHERE, Some("/srv")).expect("a valid alias")],
2772 "ssh-browser".to_string(),
2773 port,
2774 Token::from_hex(TEST_TOKEN),
2775 "souta".to_string(),
2776 theme::DEFAULT.to_string(),
2777 )
2778 .await;
2779
2780 let Err(e) = result else {
2781 panic!("binding a port that is already held must fail");
2782 };
2783 let text = format!("{e:#}");
2784 assert!(
2785 text.contains(&format!("bind 127.0.0.1:{port}")),
2786 "the error should name the port, got: {text}"
2787 );
2788 assert!(
2789 !text.contains(NOWHERE),
2790 "the ssh host was reached before the port was taken: {text}"
2791 );
2792 }
2793
2794 #[tokio::test]
2797 async fn a_dot_name_is_never_served() {
2798 let origin = origin_with(
2799 FakeRemote::new()
2800 .dir(
2801 "/srv",
2802 vec![
2803 ("Vault", dir_attrs()),
2804 (".ssh", dir_attrs()),
2805 (".netrc", file_attrs(9, 1)),
2806 ],
2807 )
2808 .dir("/srv/.ssh", vec![("id_ed25519", file_attrs(9, 1))])
2809 .dir("/srv/Vault", vec![(".git", dir_attrs())])
2810 .dir("/srv/Vault/.git", vec![("config", file_attrs(9, 1))])
2811 .file("/srv/.ssh/id_ed25519", b"a-secret-")
2812 .file("/srv/.netrc", b"a-secret-")
2813 .file("/srv/Vault/.git/config", b"a-secret-"),
2814 )
2815 .await;
2816
2817 for path in [
2818 "/.ssh/id_ed25519",
2819 "/.netrc",
2820 "/Vault/.git/config",
2822 "/.ssh/",
2824 ] {
2825 assert_eq!(
2826 origin.handle(get(path, None)).await.status(),
2827 StatusCode::FORBIDDEN,
2828 "{path}"
2829 );
2830 }
2831 }
2832
2833 #[tokio::test]
2836 async fn a_listing_does_not_mention_dot_names() {
2837 let origin = origin_with(FakeRemote::new().dir(
2838 "/srv",
2839 vec![
2840 ("Vault", dir_attrs()),
2841 (".ssh", dir_attrs()),
2842 (".obsidian", dir_attrs()),
2843 ],
2844 ))
2845 .await;
2846
2847 let body = body_of(origin.handle(get("/", None)).await).await;
2848 let listing = String::from_utf8_lossy(&body);
2849 assert!(listing.contains("Vault"), "the ordinary entry is listed");
2850 assert!(!listing.contains(".ssh"), "got: {listing}");
2851 assert!(!listing.contains(".obsidian"), "got: {listing}");
2852 }
2853
2854 #[tokio::test]
2857 async fn a_page_cannot_prefetch_a_dot_name() {
2858 let html = "<!doctype html><html><body><img src=\".ssh/id_ed25519\"></body></html>";
2859 let origin = origin_with(
2860 FakeRemote::new()
2861 .dir(
2862 "/srv",
2863 vec![
2864 ("index.html", file_attrs(html.len() as u64, 100)),
2865 (".ssh", dir_attrs()),
2866 ],
2867 )
2868 .dir("/srv/.ssh", vec![("id_ed25519", file_attrs(9, 1))])
2869 .file("/srv/index.html", html.as_bytes())
2870 .file("/srv/.ssh/id_ed25519", b"a-secret-"),
2871 )
2872 .await;
2873
2874 assert_eq!(
2875 origin.handle(get("/index.html", None)).await.status(),
2876 StatusCode::OK
2877 );
2878 assert!(
2879 !origin.cache.has_listing("/srv/.ssh"),
2880 "the page got the daemon to list a directory it will not serve"
2881 );
2882 assert_eq!(
2883 origin.handle(get("/.ssh/id_ed25519", None)).await.status(),
2884 StatusCode::FORBIDDEN
2885 );
2886 }
2887
2888 fn deep_tree() -> FakeRemote {
2890 FakeRemote::new()
2891 .dir("/srv", vec![("a", dir_attrs())])
2892 .dir("/srv/a", vec![("b", dir_attrs())])
2893 .dir("/srv/a/b", vec![("c", dir_attrs())])
2894 .dir("/srv/a/b/c", vec![("d.html", file_attrs(5, 100))])
2895 .file("/srv/a/b/c/d.html", b"deep!")
2896 }
2897
2898 fn entry(name: &str, dir: bool) -> Entry {
2899 Entry {
2900 name: name.to_string(),
2901 attrs: Attrs {
2902 permissions: Some(if dir { 0o040755 } else { 0o100644 }),
2903 ..Attrs::default()
2904 },
2905 owner: None,
2906 }
2907 }
2908
2909 fn listing(alias: &str, rel: &str, entries: &[Entry]) -> String {
2912 let levels = vec![(rel.to_string(), rows_of(entries, &HashSet::new()))];
2913 autoindex(alias, rel, &levels, theme::DEFAULT)
2914 }
2915
2916 #[test]
2917 fn a_hostile_filename_cannot_inject_script_into_our_origin() {
2918 let page = listing("docs", "", &[entry("<script>alert(1)</script>", false)]);
2919 assert!(!page.contains("<script>alert"));
2920 assert!(page.contains("<script>"));
2921 }
2922
2923 #[test]
2926 fn directories_come_first_and_pages_lead_the_files() {
2927 let page = listing(
2928 "docs",
2929 "",
2930 &[
2931 entry("b.txt", false),
2932 entry("z-dir", true),
2933 entry("a.txt", false),
2934 entry("report.html", false),
2935 ],
2936 );
2937 let dir = page.find("z-dir").expect("dir listed");
2938 let html = page.find("report.html").expect("page listed");
2939 let a = page.find("a.txt").expect("a listed");
2940 let b = page.find("b.txt").expect("b listed");
2941 assert!(
2942 dir < html,
2943 "directories come first, whatever they are called"
2944 );
2945 assert!(html < a, "then the pages, ahead of the other files");
2946 assert!(a < b, "and the rest by name");
2947 assert!(!page.contains("<h2"), "{page}");
2949 }
2950
2951 #[test]
2954 fn only_html_counts_as_a_page() {
2955 let page = listing(
2956 "docs",
2957 "",
2958 &[
2959 entry("a.htm", false),
2960 entry("b.html.bak", false),
2961 entry("c.xhtml", false),
2962 ],
2963 );
2964 let htm = page.find("a.htm").expect("htm listed");
2965 let bak = page.find("b.html.bak").expect("bak listed");
2966 let xhtml = page.find("c.xhtml").expect("xhtml listed");
2967 assert!(htm < bak && htm < xhtml, "only the .htm leads: {page}");
2968 assert!(
2971 page.contains("class=\"row k-page\" href=\"/a.htm\""),
2972 "{page}"
2973 );
2974 }
2975
2976 #[test]
2977 fn hrefs_are_url_escaped() {
2978 let page = listing("docs", "", &[entry("a b#c.html", false)]);
2979 assert!(page.contains("href=\"/a%20b%23c.html\""));
2980 }
2981
2982 #[test]
2985 fn the_header_names_the_alias_and_where_you_are() {
2986 let page = listing("panza", "/Vault/infra", &[]);
2987 assert!(page.contains("<b>panza</b>"), "{page}");
2988 assert!(page.contains("<span>/Vault/infra</span>"), "{page}");
2989 }
2990
2991 #[tokio::test]
2998 async fn the_whole_path_is_expanded_and_the_deepest_is_selected() {
2999 let origin = origin_with(
3000 FakeRemote::new()
3001 .dir("/srv", vec![("a", dir_attrs()), ("elsewhere", dir_attrs())])
3002 .dir("/srv/a", vec![("b", dir_attrs()), ("sibling", dir_attrs())])
3003 .dir("/srv/a/b", vec![("leaf.txt", file_attrs(3, 1))])
3004 .dir("/srv/elsewhere", vec![])
3005 .dir("/srv/a/sibling", vec![]),
3006 )
3007 .await;
3008
3009 let body = String::from_utf8(
3010 body_of(origin.handle(get("/a/b/", None)).await)
3011 .await
3012 .to_vec(),
3013 )
3014 .expect("utf-8");
3015
3016 assert!(body.contains("<li class=\"open\">"), "{body}");
3018 assert!(body.contains("href=\"/a/\""), "{body}");
3019 assert!(body.contains("row dir here\" href=\"/a/b/\""), "{body}");
3021 assert!(body.contains("leaf.txt"), "{body}");
3023 assert!(body.contains("elsewhere"), "{body}");
3026 assert!(body.contains("sibling"), "{body}");
3027 }
3028
3029 #[tokio::test]
3033 async fn the_tree_costs_what_one_directory_cost() {
3034 let deep = origin_with(deep_tree()).await;
3035 let before = trips(&deep).await;
3036 assert_eq!(
3037 deep.handle(get("/a/b/c/", None)).await.status(),
3038 StatusCode::OK
3039 );
3040 let four = trips(&deep).await - before;
3041
3042 let shallow = origin_with(one_page()).await;
3043 let before = trips(&shallow).await;
3044 assert_eq!(
3045 shallow.handle(get("/", None)).await.status(),
3046 StatusCode::OK
3047 );
3048 let one = trips(&shallow).await - before;
3049
3050 assert!(
3053 four <= one + 2,
3054 "a tree four deep cost {four} round trips against {one} for one directory"
3055 );
3056 }
3057
3058 #[tokio::test]
3061 async fn asking_for_one_level_answers_with_its_rows() {
3062 let origin = origin_with(
3063 FakeRemote::new()
3064 .dir("/srv", vec![("sub", dir_attrs())])
3065 .dir("/srv/sub", vec![("inner.md", file_attrs(4, 1))]),
3066 )
3067 .await;
3068
3069 let req = Request::builder()
3070 .uri("http://docs.ssh-browser/sub/?ls")
3071 .header(HOST, "docs.ssh-browser")
3072 .body(Empty::<Bytes>::new())
3073 .expect("request builds");
3074 let res = origin.handle(req).await;
3075 assert_eq!(res.status(), StatusCode::OK);
3076
3077 let body = String::from_utf8(body_of(res).await.to_vec()).expect("utf-8");
3078 assert!(body.starts_with("<ul>"), "{body}");
3080 assert!(!body.contains("<html"), "{body}");
3081 assert!(body.contains("href=\"/sub/inner.md\""), "{body}");
3083 }
3084
3085 #[tokio::test]
3088 async fn asking_for_one_level_does_not_mention_dot_names() {
3089 let origin = origin_with(
3090 FakeRemote::new()
3091 .dir("/srv", vec![("sub", dir_attrs())])
3092 .dir(
3093 "/srv/sub",
3094 vec![("shown.md", file_attrs(4, 1)), (".hidden", dir_attrs())],
3095 ),
3096 )
3097 .await;
3098
3099 let req = Request::builder()
3100 .uri("http://docs.ssh-browser/sub/?ls")
3101 .header(HOST, "docs.ssh-browser")
3102 .body(Empty::<Bytes>::new())
3103 .expect("request builds");
3104 let body =
3105 String::from_utf8(body_of(origin.handle(req).await).await.to_vec()).expect("utf-8");
3106 assert!(body.contains("shown.md"), "{body}");
3107 assert!(!body.contains(".hidden"), "{body}");
3108 }
3109
3110 #[test]
3111 fn sizes_read_the_way_a_file_manager_shows_them() {
3112 assert_eq!(human_size(0), "0 B");
3113 assert_eq!(human_size(999), "999 B");
3114 assert_eq!(human_size(1024), "1.0 KiB");
3115 assert_eq!(human_size(1536), "1.5 KiB");
3116 assert_eq!(human_size(10 * 1024 * 1024), "10 MiB");
3118 assert_eq!(human_size(9_961_472), "9.5 MiB");
3119 assert_eq!(human_size(3 * 1024 * 1024 * 1024), "3.0 GiB");
3120 }
3121
3122 #[test]
3125 fn timestamps_are_the_utc_civil_date() {
3126 assert_eq!(utc_stamp(0), "1970-01-01 00:00");
3127 assert_eq!(utc_stamp(86_399), "1970-01-01 23:59");
3128 assert_eq!(utc_stamp(86_400), "1970-01-02 00:00");
3129 assert_eq!(utc_stamp(951_782_400), "2000-02-29 00:00");
3131 assert_eq!(utc_stamp(4_107_456_000), "2100-02-28 00:00");
3135 assert_eq!(utc_stamp(4_107_542_400), "2100-03-01 00:00");
3136 assert_eq!(utc_stamp(1_757_745_840), "2025-09-13 06:44");
3137 }
3138
3139 #[test]
3142 fn an_empty_directory_says_it_is_empty() {
3143 let page = listing("docs", "/nothing", &[]);
3144 assert!(page.contains("This directory is empty"), "{page}");
3145 }
3146
3147 #[test]
3148 fn the_component_chain_walks_from_the_base_down() {
3149 assert_eq!(
3150 components("/srv", "/srv/a/b/c.html"),
3151 vec![
3152 ("/srv".to_string(), "a".to_string()),
3153 ("/srv/a".to_string(), "b".to_string()),
3154 ("/srv/a/b".to_string(), "c.html".to_string()),
3155 ]
3156 );
3157 assert_eq!(
3158 components("/srv", "/srv/index.html"),
3159 vec![("/srv".to_string(), "index.html".to_string())]
3160 );
3161 assert_eq!(
3163 components("/srv/", "/srv/a.html"),
3164 vec![("/srv".to_string(), "a.html".to_string())]
3165 );
3166 assert!(components("/srv", "/srv").is_empty());
3168 }
3169
3170 #[tokio::test]
3173 async fn a_revisit_costs_no_remote_round_trips() {
3174 let origin = origin_with(one_page()).await;
3175
3176 let first = origin.handle(get("/a.html", None)).await;
3177 assert_eq!(first.status(), StatusCode::OK);
3178 let after_first = trips(&origin).await;
3179 assert!(after_first > 0, "the first request has to fetch something");
3180
3181 let second = origin.handle(get("/a.html", None)).await;
3182 assert_eq!(second.status(), StatusCode::OK);
3183 assert_eq!(
3184 trips(&origin).await,
3185 after_first,
3186 "a revisit must be answered entirely from cache"
3187 );
3188 }
3189
3190 #[tokio::test]
3193 async fn a_conditional_get_is_answered_without_the_remote() {
3194 let origin = origin_with(one_page()).await;
3195
3196 let first = origin.handle(get("/a.html", None)).await;
3197 let tag = first
3198 .headers()
3199 .get(ETAG)
3200 .expect("a validator is offered")
3201 .to_str()
3202 .expect("ascii")
3203 .to_string();
3204 let after_first = trips(&origin).await;
3205
3206 let second = origin.handle(get("/a.html", Some(&tag))).await;
3207 assert_eq!(second.status(), StatusCode::NOT_MODIFIED);
3208 assert_eq!(
3209 trips(&origin).await,
3210 after_first,
3211 "a 304 must not touch the remote"
3212 );
3213 }
3214
3215 #[tokio::test]
3217 async fn a_missing_file_is_a_404_from_the_cached_listing() {
3218 let origin = origin_with(one_page()).await;
3219
3220 origin.handle(get("/a.html", None)).await;
3222 let warm = trips(&origin).await;
3223
3224 let missing = origin.handle(get("/nope.html", None)).await;
3225 assert_eq!(missing.status(), StatusCode::NOT_FOUND);
3226 assert_eq!(
3227 trips(&origin).await,
3228 warm,
3229 "a 404 for a listed-but-absent name must cost nothing"
3230 );
3231 }
3232
3233 #[tokio::test]
3236 async fn a_symlink_is_refused() {
3237 let origin = origin_with(
3238 FakeRemote::new()
3239 .dir("/srv", vec![("link.html", symlink_attrs())])
3240 .file("/srv/link.html", b"whatever the target is"),
3241 )
3242 .await;
3243
3244 let res = origin.handle(get("/link.html", None)).await;
3245 assert_eq!(res.status(), StatusCode::FORBIDDEN);
3246 }
3247
3248 #[tokio::test]
3250 async fn a_directory_without_a_trailing_slash_redirects() {
3251 let origin = origin_with(
3252 FakeRemote::new()
3253 .dir("/srv", vec![("sub", dir_attrs())])
3254 .dir("/srv/sub", vec![("b.html", file_attrs(1, 1))]),
3255 )
3256 .await;
3257
3258 let res = origin.handle(get("/sub", None)).await;
3259 assert_eq!(res.status(), StatusCode::MOVED_PERMANENTLY);
3260 assert_eq!(
3261 res.headers().get(LOCATION).and_then(|v| v.to_str().ok()),
3262 Some("/sub/")
3263 );
3264 }
3265
3266 #[tokio::test]
3269 async fn a_listing_proven_wrong_is_forgotten() {
3270 let origin =
3272 origin_with(FakeRemote::new().dir("/srv", vec![("ghost.html", file_attrs(5, 100))]))
3273 .await;
3274
3275 let res = origin.handle(get("/ghost.html", None)).await;
3276 assert_eq!(res.status(), StatusCode::NOT_FOUND);
3277 assert!(
3278 !origin.cache.has_listing("/srv"),
3279 "a listing contradicted by the remote must be dropped"
3280 );
3281 }
3282
3283 #[tokio::test]
3285 async fn a_directory_without_an_index_is_listed() {
3286 let origin =
3287 origin_with(FakeRemote::new().dir("/srv", vec![("only.txt", file_attrs(2, 1))])).await;
3288
3289 let res = origin.handle(get("/", None)).await;
3290 assert_eq!(res.status(), StatusCode::OK);
3291 assert_eq!(
3292 res.headers()
3293 .get(CONTENT_TYPE)
3294 .and_then(|v| v.to_str().ok()),
3295 Some("text/html; charset=utf-8")
3296 );
3297 }
3298
3299 #[tokio::test]
3302 async fn a_symlinked_directory_higher_up_the_path_is_refused() {
3303 let origin = origin_with(
3304 FakeRemote::new()
3305 .dir("/srv", vec![("link", symlink_attrs())])
3306 .dir("/srv/link", vec![("inside.html", file_attrs(2, 1))])
3307 .file("/srv/link/inside.html", b"hi"),
3308 )
3309 .await;
3310
3311 let res = origin.handle(get("/link/inside.html", None)).await;
3312 assert_eq!(res.status(), StatusCode::FORBIDDEN);
3313 }
3314
3315 #[tokio::test]
3318 async fn a_deep_path_costs_what_a_shallow_one_costs() {
3319 let deep = origin_with(deep_tree()).await;
3320 assert_eq!(
3321 deep.handle(get("/a/b/c/d.html", None)).await.status(),
3322 StatusCode::OK
3323 );
3324
3325 let shallow = origin_with(one_page()).await;
3326 assert_eq!(
3327 shallow.handle(get("/a.html", None)).await.status(),
3328 StatusCode::OK
3329 );
3330
3331 let (d, sh) = (trips(&deep).await, trips(&shallow).await);
3332 assert!(
3336 d <= sh + 2,
3337 "depth 4 cost {d} round trips against depth 1's {sh}"
3338 );
3339 }
3340
3341 #[tokio::test]
3343 async fn a_file_used_as_a_directory_is_a_404() {
3344 let origin = origin_with(one_page()).await;
3345 let res = origin.handle(get("/a.html/b.html", None)).await;
3346 assert_eq!(res.status(), StatusCode::NOT_FOUND);
3347 }
3348
3349 #[tokio::test]
3352 async fn a_deep_path_serves_its_body() {
3353 let origin = origin_with(deep_tree()).await;
3354 let res = origin.handle(get("/a/b/c/d.html", None)).await;
3355 assert_eq!(res.status(), StatusCode::OK);
3356 assert_eq!(
3357 res.headers()
3358 .get(CONTENT_TYPE)
3359 .and_then(|v| v.to_str().ok()),
3360 Some("text/html; charset=utf-8")
3361 );
3362 }
3363
3364 #[tokio::test]
3366 async fn a_range_is_sliced_out_of_the_cached_body() {
3367 let origin = origin_with(one_page()).await;
3368 assert_eq!(
3369 origin.handle(get("/a.html", None)).await.status(),
3370 StatusCode::OK
3371 );
3372 let warm = trips(&origin).await;
3373
3374 let res = origin.handle(ranged("/a.html", "bytes=1-3")).await;
3375 assert_eq!(res.status(), StatusCode::PARTIAL_CONTENT);
3376 assert_eq!(
3377 res.headers()
3378 .get(CONTENT_RANGE)
3379 .and_then(|v| v.to_str().ok()),
3380 Some("bytes 1-3/5")
3381 );
3382 assert_eq!(&body_of(res).await[..], b"ell");
3383 assert_eq!(
3384 trips(&origin).await,
3385 warm,
3386 "slicing a held body must cost no round trip"
3387 );
3388 }
3389
3390 #[tokio::test]
3392 async fn a_range_on_a_cold_small_file_works_and_warms_the_cache() {
3393 let origin = origin_with(one_page()).await;
3394
3395 let res = origin.handle(ranged("/a.html", "bytes=0-1")).await;
3396 assert_eq!(res.status(), StatusCode::PARTIAL_CONTENT);
3397 assert_eq!(&body_of(res).await[..], b"he");
3398
3399 let warm = trips(&origin).await;
3400 let again = origin.handle(ranged("/a.html", "bytes=2-4")).await;
3401 assert_eq!(&body_of(again).await[..], b"llo");
3402 assert_eq!(
3403 trips(&origin).await,
3404 warm,
3405 "a small file fetched for a range should be held whole"
3406 );
3407 }
3408
3409 #[tokio::test]
3411 async fn a_range_past_the_end_is_a_416_carrying_the_real_size() {
3412 let origin = origin_with(one_page()).await;
3413 let res = origin.handle(ranged("/a.html", "bytes=99-")).await;
3414 assert_eq!(res.status(), StatusCode::RANGE_NOT_SATISFIABLE);
3415 assert_eq!(
3416 res.headers()
3417 .get(CONTENT_RANGE)
3418 .and_then(|v| v.to_str().ok()),
3419 Some("bytes */5")
3420 );
3421 }
3422
3423 #[tokio::test]
3425 async fn a_full_response_advertises_ranges() {
3426 let origin = origin_with(one_page()).await;
3427 let res = origin.handle(get("/a.html", None)).await;
3428 assert_eq!(
3429 res.headers()
3430 .get(ACCEPT_RANGES)
3431 .and_then(|v| v.to_str().ok()),
3432 Some("bytes")
3433 );
3434 }
3435
3436 #[tokio::test]
3439 async fn if_range_yields_the_whole_file() {
3440 let origin = origin_with(one_page()).await;
3441 let req = Request::builder()
3442 .uri("http://docs.ssh-browser/a.html")
3443 .header(HOST, "docs.ssh-browser")
3444 .header(RANGE, "bytes=1-3")
3445 .header(IF_RANGE, "W/\"64-5\"")
3446 .body(Empty::<Bytes>::new())
3447 .expect("request builds");
3448
3449 let res = origin.handle(req).await;
3450 assert_eq!(res.status(), StatusCode::OK);
3451 assert_eq!(&body_of(res).await[..], b"hello");
3452 }
3453
3454 #[tokio::test]
3457 async fn a_large_file_is_served_by_range_and_not_held() {
3458 let body: Vec<u8> = (0..64u8).collect();
3459 let origin = origin_with(
3460 FakeRemote::new()
3461 .dir("/srv", vec![("big.bin", file_attrs(9 * 1024 * 1024, 7))])
3464 .file("/srv/big.bin", &body),
3465 )
3466 .await;
3467
3468 let res = origin.handle(ranged("/big.bin", "bytes=0-9")).await;
3469 assert_eq!(res.status(), StatusCode::PARTIAL_CONTENT);
3470 assert_eq!(&body_of(res).await[..], &body[0..10]);
3471
3472 let after = trips(&origin).await;
3473 let second = origin.handle(ranged("/big.bin", "bytes=10-19")).await;
3474 assert_eq!(&body_of(second).await[..], &body[10..20]);
3475 assert!(
3476 trips(&origin).await > after,
3477 "a file over the threshold must not be held"
3478 );
3479 }
3480
3481 #[tokio::test]
3485 async fn an_alias_origin_has_no_control_api_on_it() {
3486 let origin = origin_with(one_page()).await;
3487 let res = origin.handle(get("/_control/hello", None)).await;
3488 assert_eq!(res.status(), StatusCode::NOT_FOUND);
3489 assert_ne!(
3490 res.status(),
3491 StatusCode::UNAUTHORIZED,
3492 "a 401 would mean the control router was reached from an alias origin"
3493 );
3494 }
3495
3496 #[tokio::test]
3499 async fn an_alias_origin_with_a_valid_token_still_has_no_control_api() {
3500 let origin = origin_with(one_page()).await;
3501 let req = Request::builder()
3502 .uri("http://docs.ssh-browser/_control/hello")
3503 .header(HOST, "docs.ssh-browser")
3504 .header(control::TOKEN_HEADER, TEST_TOKEN)
3505 .body(Empty::<Bytes>::new())
3506 .expect("request builds");
3507 assert_eq!(origin.handle(req).await.status(), StatusCode::NOT_FOUND);
3508 }
3509
3510 #[tokio::test]
3513 async fn the_alias_origin_refuses_writes() {
3514 let origin = origin_with(one_page()).await;
3515 for method in [Method::POST, Method::PUT, Method::DELETE, Method::PATCH] {
3516 let req = Request::builder()
3517 .method(method.clone())
3518 .uri("http://docs.ssh-browser/a.html")
3519 .header(HOST, "docs.ssh-browser")
3520 .body(Empty::<Bytes>::new())
3521 .expect("request builds");
3522 assert_eq!(
3523 origin.handle(req).await.status(),
3524 StatusCode::METHOD_NOT_ALLOWED,
3525 "{method} should be refused on the read-only origin"
3526 );
3527 }
3528 }
3529
3530 #[tokio::test]
3531 async fn the_control_api_answers_on_loopback_with_the_token() {
3532 let origin = origin_with(one_page()).await;
3533 let res = origin
3534 .handle(loopback("/_control/hello", Some(TEST_TOKEN)))
3535 .await;
3536 assert_eq!(res.status(), StatusCode::OK);
3537 let body = body_of(res).await;
3538 let text = String::from_utf8_lossy(&body);
3539 assert!(
3540 text.contains("\"protocol\""),
3541 "hello must negotiate: {text}"
3542 );
3543 assert!(text.contains("\"docs\""), "hello must list aliases: {text}");
3544 }
3545
3546 #[tokio::test]
3547 async fn the_control_api_refuses_loopback_without_the_token() {
3548 let origin = origin_with(one_page()).await;
3549 assert_eq!(
3550 origin
3551 .handle(loopback("/_control/hello", None))
3552 .await
3553 .status(),
3554 StatusCode::UNAUTHORIZED
3555 );
3556 assert_eq!(
3557 origin
3558 .handle(loopback("/_control/hello", Some("wrong")))
3559 .await
3560 .status(),
3561 StatusCode::UNAUTHORIZED
3562 );
3563 }
3564
3565 #[tokio::test]
3567 async fn the_loopback_path_still_serves_files() {
3568 let origin = origin_with(one_page()).await;
3569 let res = origin.handle(loopback("/docs/a.html", None)).await;
3570 assert_eq!(res.status(), StatusCode::OK);
3571 assert_eq!(&body_of(res).await[..], b"hello");
3572 }
3573
3574 #[tokio::test]
3576 async fn an_annotation_written_through_control_comes_back_out() {
3577 let origin = origin_with(one_page()).await;
3578
3579 let added = origin
3580 .handle(control_post(
3581 "/_control/annotations",
3582 Some(TEST_TOKEN),
3583 r#"{"doc":"docs/a.html","op":"add","body":"a note"}"#,
3584 ))
3585 .await;
3586 assert_eq!(added.status(), StatusCode::OK);
3587 let added = json_of(added).await;
3588 let id = added["id"].as_str().expect("an id was minted").to_string();
3589 assert!(
3590 id.starts_with("souta:"),
3591 "the id must name the daemon's author, got {id}"
3592 );
3593 assert_eq!(added["author"], "souta");
3594
3595 let listed = origin
3596 .handle(loopback(
3597 "/_control/annotations?doc=docs/a.html",
3598 Some(TEST_TOKEN),
3599 ))
3600 .await;
3601 assert_eq!(listed.status(), StatusCode::OK);
3602 let listed = json_of(listed).await;
3603 assert_eq!(listed["skipped"], 0);
3604 let annotations = listed["annotations"].as_array().expect("an array");
3605 assert_eq!(annotations.len(), 1);
3606 assert_eq!(annotations[0]["body"], "a note");
3607 assert_eq!(annotations[0]["id"], id.as_str());
3608 assert_eq!(annotations[0]["author"], "souta");
3609 assert_eq!(annotations[0]["attribution"]["state"], "unchecked");
3613 }
3614
3615 #[tokio::test]
3618 async fn a_mismatched_author_reaches_the_extension_as_json() {
3619 let dir = "/srv/.ssh-browser/a.html/ann";
3620 let log = b"{\"op\":\"add\",\"id\":\"alice:1\",\"at\":10,\"body\":\"is this alice?\"}\n";
3621 let origin = origin_with(
3622 one_page()
3623 .dir(dir, vec![("alice.jsonl", file_attrs(log.len() as u64, 1))])
3624 .owner(&format!("{dir}/alice.jsonl"), "bob")
3625 .file(&format!("{dir}/alice.jsonl"), log),
3626 )
3627 .await;
3628
3629 let listed = origin
3630 .handle(loopback(
3631 "/_control/annotations?doc=docs/a.html",
3632 Some(TEST_TOKEN),
3633 ))
3634 .await;
3635 assert_eq!(listed.status(), StatusCode::OK);
3636 let listed = json_of(listed).await;
3637 let annotations = listed["annotations"].as_array().expect("an array");
3638 assert_eq!(annotations.len(), 1, "the note is served, not censored");
3639 assert_eq!(annotations[0]["author"], "alice");
3640 assert_eq!(annotations[0]["attribution"]["state"], "mismatched");
3641 assert_eq!(annotations[0]["attribution"]["owner"], "bob");
3642 }
3643
3644 #[tokio::test]
3645 async fn writing_an_annotation_without_the_token_is_refused() {
3646 let origin = origin_with(one_page()).await;
3647 let res = origin
3648 .handle(control_post(
3649 "/_control/annotations",
3650 None,
3651 r#"{"doc":"docs/a.html","op":"add","body":"a note"}"#,
3652 ))
3653 .await;
3654 assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
3655 }
3656
3657 #[tokio::test]
3661 async fn an_add_may_not_carry_an_id() {
3662 let origin = origin_with(one_page()).await;
3663 let res = origin
3664 .handle(control_post(
3665 "/_control/annotations",
3666 Some(TEST_TOKEN),
3667 r#"{"doc":"docs/a.html","op":"add","id":"alice:1","body":"x"}"#,
3668 ))
3669 .await;
3670 assert_eq!(res.status(), StatusCode::BAD_REQUEST);
3671 }
3672
3673 #[tokio::test]
3674 async fn an_update_without_an_id_is_refused() {
3675 let origin = origin_with(one_page()).await;
3676 let res = origin
3677 .handle(control_post(
3678 "/_control/annotations",
3679 Some(TEST_TOKEN),
3680 r#"{"doc":"docs/a.html","op":"update","body":"x"}"#,
3681 ))
3682 .await;
3683 assert_eq!(res.status(), StatusCode::BAD_REQUEST);
3684 }
3685
3686 #[tokio::test]
3687 async fn listing_annotations_needs_a_doc() {
3688 let origin = origin_with(one_page()).await;
3689 let res = origin
3690 .handle(loopback("/_control/annotations", Some(TEST_TOKEN)))
3691 .await;
3692 assert_eq!(res.status(), StatusCode::BAD_REQUEST);
3693 }
3694
3695 #[tokio::test]
3696 async fn an_unknown_alias_is_a_404() {
3697 let origin = origin_with(one_page()).await;
3698 let res = origin
3699 .handle(loopback(
3700 "/_control/annotations?doc=nope/a.html",
3701 Some(TEST_TOKEN),
3702 ))
3703 .await;
3704 assert_eq!(res.status(), StatusCode::NOT_FOUND);
3705 }
3706
3707 #[tokio::test]
3709 async fn a_traversal_in_the_doc_parameter_is_refused() {
3710 let origin = origin_with(one_page()).await;
3711 let res = origin
3712 .handle(control_post(
3713 "/_control/annotations",
3714 Some(TEST_TOKEN),
3715 r#"{"doc":"docs/../../etc/passwd","op":"add","body":"x"}"#,
3716 ))
3717 .await;
3718 assert_eq!(res.status(), StatusCode::FORBIDDEN);
3719 }
3720
3721 #[tokio::test]
3724 async fn writing_through_a_symlinked_directory_is_refused() {
3725 let origin = origin_with(
3726 FakeRemote::new()
3727 .dir("/srv", vec![("link", symlink_attrs())])
3728 .dir("/srv/link", vec![("inside.html", file_attrs(2, 1))])
3729 .file("/srv/link/inside.html", b"hi"),
3730 )
3731 .await;
3732
3733 let res = origin
3734 .handle(control_post(
3735 "/_control/annotations",
3736 Some(TEST_TOKEN),
3737 r#"{"doc":"docs/link/inside.html","op":"add","body":"x"}"#,
3738 ))
3739 .await;
3740 assert_eq!(res.status(), StatusCode::FORBIDDEN);
3741 }
3742
3743 #[tokio::test]
3745 async fn an_unannotated_document_lists_empty() {
3746 let origin = origin_with(one_page()).await;
3747 let res = origin
3748 .handle(loopback(
3749 "/_control/annotations?doc=docs/a.html",
3750 Some(TEST_TOKEN),
3751 ))
3752 .await;
3753 assert_eq!(res.status(), StatusCode::OK);
3754 let body = json_of(res).await;
3755 assert_eq!(body["annotations"].as_array().expect("array").len(), 0);
3756 }
3757
3758 #[tokio::test]
3761 async fn a_base_may_be_written_relative_to_the_home_directory() {
3762 let fs = FakeRemote::new().home("/home/souta").spawn().await;
3763 assert_eq!(
3764 resolve_base(Some("~/work"), &fs).await.expect("resolves"),
3765 "/home/souta/work"
3766 );
3767 }
3768
3769 #[tokio::test]
3771 async fn a_bare_tilde_and_no_base_are_both_the_home_directory() {
3772 let fs = FakeRemote::new().home("/home/souta").spawn().await;
3773 assert_eq!(
3774 resolve_base(None, &fs).await.expect("resolves"),
3775 "/home/souta"
3776 );
3777 assert_eq!(
3778 resolve_base(Some("~"), &fs).await.expect("resolves"),
3779 "/home/souta"
3780 );
3781 }
3782
3783 #[tokio::test]
3786 async fn an_absolute_base_costs_no_round_trip() {
3787 let fs = FakeRemote::new().home("/home/souta").spawn().await;
3788 let before = fs.round_trips();
3789 assert_eq!(
3790 resolve_base(Some("/srv/docs"), &fs)
3791 .await
3792 .expect("resolves"),
3793 "/srv/docs"
3794 );
3795 assert_eq!(fs.round_trips(), before, "an absolute base must not ask");
3796 }
3797
3798 #[test]
3801 fn a_base_that_could_climb_out_of_the_home_directory_is_refused() {
3802 for bad in [
3803 "~/..",
3804 "~/../.ssh",
3805 "~/work/../..",
3806 "~/./x",
3807 "~work",
3808 "work",
3809 "",
3810 ] {
3811 assert!(!is_base(bad), "should have been refused: {bad:?}");
3812 assert!(
3813 Alias::new("docs", "h", Some(bad)).is_err(),
3814 "should have been refused: {bad:?}"
3815 );
3816 }
3817 for good in ["/", "/srv", "~", "~/work", "~/a/b/c"] {
3818 assert!(is_base(good), "should have been accepted: {good:?}");
3819 }
3820 }
3821
3822 #[tokio::test]
3825 async fn a_root_home_does_not_produce_a_doubled_slash() {
3826 let fs = FakeRemote::new().home("/").spawn().await;
3827 assert_eq!(resolve_base(None, &fs).await.expect("resolves"), "/");
3828 assert_eq!(
3829 resolve_base(Some("~/work"), &fs).await.expect("resolves"),
3830 "/work"
3831 );
3832 }
3833
3834 #[tokio::test]
3843 async fn the_host_list_is_a_control_route() {
3844 let origin = origin_with(one_page()).await;
3845 let res = origin
3846 .handle(loopback("/_control/hosts", Some(TEST_TOKEN)))
3847 .await;
3848 assert_eq!(res.status(), StatusCode::OK);
3849 let text = String::from_utf8(body_of(res).await.to_vec()).expect("utf-8");
3850 let parsed: serde_json::Value = serde_json::from_str(&text).expect("json");
3851 assert!(parsed.get("hosts").is_some_and(|h| h.is_array()), "{text}");
3852 assert!(
3853 parsed.get("unusable").is_some_and(|u| u.is_array()),
3854 "{text}"
3855 );
3856 }
3857
3858 #[tokio::test]
3865 async fn opening_a_host_ssh_does_not_know_is_refused() {
3866 let origin = origin_with(one_page()).await;
3867 let res = origin
3868 .handle(control_post(
3869 "/_control/open",
3870 Some(TEST_TOKEN),
3871 r#"{"host":"not-a-host-in-anyones-ssh-config.invalid"}"#,
3872 ))
3873 .await;
3874 assert_eq!(res.status(), StatusCode::NOT_FOUND);
3875 }
3876
3877 #[tokio::test]
3881 async fn an_open_request_that_is_not_one_is_refused() {
3882 let origin = origin_with(one_page()).await;
3883 for body in [
3884 "",
3885 "{}",
3886 r#"{"base":"/srv"}"#,
3887 r#"{"host":"docs","base_path":"/srv"}"#,
3888 ] {
3889 let res = origin
3890 .handle(control_post("/_control/open", Some(TEST_TOKEN), body))
3891 .await;
3892 assert_eq!(
3893 res.status(),
3894 StatusCode::BAD_REQUEST,
3895 "should have been refused: {body}"
3896 );
3897 }
3898 }
3899
3900 #[tokio::test]
3904 async fn opening_a_host_needs_the_token() {
3905 let origin = origin_with(one_page()).await;
3906 for token in [None, Some("wrong")] {
3907 let res = origin
3908 .handle(control_post("/_control/open", token, r#"{"host":"docs"}"#))
3909 .await;
3910 assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "token {token:?}");
3911 }
3912 }
3913
3914 #[tokio::test]
3921 async fn the_token_is_handed_over_to_something_that_is_not_a_page() {
3922 let origin = origin_with(one_page()).await;
3923 for site in [None, Some("none")] {
3924 let res = origin.handle(from_site("/_control/token", site)).await;
3925 assert_eq!(res.status(), StatusCode::OK, "site {site:?}");
3926 let body = String::from_utf8(body_of(res).await.to_vec()).expect("utf-8");
3927 assert_eq!(body.trim(), TEST_TOKEN, "site {site:?}");
3928 }
3929 }
3930
3931 #[tokio::test]
3932 async fn a_page_is_not_handed_the_token() {
3933 let origin = origin_with(one_page()).await;
3934 for site in ["same-origin", "same-site", "cross-site"] {
3935 let res = origin
3936 .handle(from_site("/_control/token", Some(site)))
3937 .await;
3938 assert_eq!(res.status(), StatusCode::FORBIDDEN, "site {site}");
3939 let body = String::from_utf8(body_of(res).await.to_vec()).expect("utf-8");
3940 assert!(!body.contains(TEST_TOKEN), "the refusal leaked it: {body}");
3941 }
3942 }
3943
3944 #[tokio::test]
3947 async fn a_page_with_the_token_still_cannot_use_the_control_api() {
3948 let origin = origin_with(one_page()).await;
3949 let req = Request::builder()
3950 .uri("http://127.0.0.1:7391/_control/hello")
3951 .header(HOST, "127.0.0.1:7391")
3952 .header(control::TOKEN_HEADER, TEST_TOKEN)
3953 .header(control::FETCH_SITE_HEADER, "same-origin")
3954 .body(Full::new(Bytes::new()))
3955 .expect("request builds");
3956 assert_eq!(origin.handle(req).await.status(), StatusCode::FORBIDDEN);
3957 }
3958
3959 #[tokio::test]
3961 async fn an_alias_can_be_closed_and_is_then_gone() {
3962 let origin = origin_with(one_page()).await;
3963 assert_eq!(
3964 origin.handle(get("/a.html", None)).await.status(),
3965 StatusCode::OK
3966 );
3967
3968 let res = origin
3969 .handle(control_post(
3970 "/_control/close",
3971 Some(TEST_TOKEN),
3972 r#"{"alias":"docs"}"#,
3973 ))
3974 .await;
3975 assert_eq!(res.status(), StatusCode::OK);
3976
3977 assert_eq!(
3980 origin.handle(get("/a.html", None)).await.status(),
3981 StatusCode::NOT_FOUND
3982 );
3983 }
3984
3985 #[tokio::test]
3988 async fn closing_an_alias_that_is_not_open_says_so() {
3989 let origin = origin_with(one_page()).await;
3990 let res = origin
3991 .handle(control_post(
3992 "/_control/close",
3993 Some(TEST_TOKEN),
3994 r#"{"alias":"nope"}"#,
3995 ))
3996 .await;
3997 assert_eq!(res.status(), StatusCode::NOT_FOUND);
3998 }
3999
4000 #[tokio::test]
4001 async fn closing_an_alias_needs_the_token() {
4002 let origin = origin_with(one_page()).await;
4003 let res = origin
4004 .handle(control_post("/_control/close", None, r#"{"alias":"docs"}"#))
4005 .await;
4006 assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
4007 assert_eq!(
4009 origin.handle(get("/a.html", None)).await.status(),
4010 StatusCode::OK
4011 );
4012 }
4013
4014 #[tokio::test]
4018 async fn a_directory_holding_an_index_is_listed_as_a_site() {
4019 let origin = origin_with(
4020 FakeRemote::new()
4021 .dir("/srv", vec![("ft-demo", dir_attrs()), ("src", dir_attrs())])
4022 .dir("/srv/ft-demo", vec![("index.html", file_attrs(5, 1))])
4023 .dir("/srv/src", vec![("main.jl", file_attrs(5, 1))])
4024 .file("/srv/ft-demo/index.html", b"board"),
4025 )
4026 .await;
4027
4028 let body = String::from_utf8(body_of(origin.handle(get("/", None)).await).await.to_vec())
4029 .expect("utf-8");
4030 assert!(
4033 body.contains("class=\"row site\" href=\"/ft-demo/\""),
4034 "{body}"
4035 );
4036 let demo = body.find("ft-demo/").expect("the site listed");
4037 let src = body.find("src/").expect("the folder listed");
4038 assert!(demo < src, "a site leads the other directories: {body}");
4039 }
4040
4041 #[tokio::test]
4046 async fn the_site_scan_costs_the_same_however_many_subdirectories() {
4047 async fn trips_for(n: usize) -> u64 {
4048 let names: Vec<String> = (0..n).map(|i| format!("d{i:02}")).collect();
4049 let mut remote = FakeRemote::new().dir(
4050 "/srv",
4051 names.iter().map(|s| (s.as_str(), dir_attrs())).collect(),
4052 );
4053 for name in &names {
4054 remote = remote.dir(&format!("/srv/{name}"), vec![("a.txt", file_attrs(1, 1))]);
4055 }
4056 let origin = origin_with(remote).await;
4057 let before = trips(&origin).await;
4058 assert_eq!(origin.handle(get("/", None)).await.status(), StatusCode::OK);
4059 trips(&origin).await - before
4060 }
4061
4062 let few = trips_for(2).await;
4063 let many = trips_for(20).await;
4064 assert_eq!(
4065 few, many,
4066 "{many} round trips for twenty subdirectories against {few} for two"
4067 );
4068 }
4069
4070 #[tokio::test]
4073 async fn the_scan_leaves_the_next_click_paid_for() {
4074 let origin = origin_with(
4075 FakeRemote::new()
4076 .dir("/srv", vec![("sub", dir_attrs())])
4077 .dir("/srv/sub", vec![("a.txt", file_attrs(1, 1))]),
4078 )
4079 .await;
4080 assert_eq!(origin.handle(get("/", None)).await.status(), StatusCode::OK);
4081
4082 let before = trips(&origin).await;
4083 assert_eq!(
4084 origin.handle(get("/sub/", None)).await.status(),
4085 StatusCode::OK
4086 );
4087 assert_eq!(
4088 trips(&origin).await,
4089 before,
4090 "the listing the scan fetched should still be the one that answers"
4091 );
4092 }
4093
4094 #[tokio::test]
4105 async fn a_listing_that_expires_mid_request_does_not_lose_the_path() {
4106 let origin =
4107 origin_with_cache(deep_tree(), Cache::new(std::time::Duration::ZERO, 1 << 20)).await;
4108 assert_eq!(
4109 origin.handle(get("/a/b/c/d.html", None)).await.status(),
4110 StatusCode::OK,
4111 "a path four deep must survive its own listings expiring"
4112 );
4113 assert_eq!(
4115 origin.handle(get("/a/b/c/", None)).await.status(),
4116 StatusCode::OK
4117 );
4118 }
4119
4120 #[tokio::test]
4123 async fn a_directory_the_remote_refuses_says_why() {
4124 let origin = origin_with(
4125 FakeRemote::new()
4126 .dir("/srv", vec![("locked", dir_attrs())])
4127 .refuses_listing("/srv/locked", 3),
4129 )
4130 .await;
4131
4132 let res = origin.handle(get("/locked/x.html", None)).await;
4133 assert_eq!(res.status(), StatusCode::BAD_GATEWAY);
4134 let said = String::from_utf8(body_of(res).await.to_vec()).expect("utf-8");
4135 assert!(said.contains("/srv/locked"), "{said}");
4136 assert!(
4137 !said.contains("cannot list"),
4138 "the old wording said nothing the reader could act on: {said}"
4139 );
4140 }
4141}