1pub mod guard;
15pub mod mime;
16pub mod pac;
17pub mod range;
18
19use std::collections::HashMap;
20use std::net::SocketAddr;
21use std::sync::Arc;
22
23use anyhow::{Context, Result, ensure};
24use bytes::Bytes;
25use http_body_util::Full;
26use hyper::header::{
27 ACCEPT_RANGES, CACHE_CONTROL, CONTENT_RANGE, CONTENT_TYPE, ETAG, HOST, HeaderName,
28 IF_NONE_MATCH, IF_RANGE, LOCATION, RANGE,
29};
30use hyper::server::conn::http1;
31use hyper::service::service_fn;
32use hyper::{Method, Request, Response, StatusCode};
33use hyper_util::rt::TokioIo;
34use serde::{Deserialize, Serialize};
35use tokio::net::TcpListener;
36
37use crate::annot;
38use crate::cache::{self, Cache};
39use crate::control::{self, Token};
40use crate::fs::sftp::SftpFs;
41use crate::fs::{Entry, RangeReq, RemoteFs};
42use crate::prefetch;
43use crate::sftp::wire::Attrs;
44
45const CACHE_WHOLE_MAX: u64 = 8 * 1024 * 1024;
49
50struct Conditions {
52 if_none_match: Option<String>,
53 range: Option<String>,
54 if_range: Option<String>,
55 control_token: Option<String>,
57}
58
59#[derive(Debug)]
67pub struct Alias {
68 name: String,
69 host: String,
70 base: String,
71}
72
73impl Alias {
74 pub fn new(name: &str, host: &str, base: &str) -> Result<Self> {
75 ensure!(!host.is_empty(), "alias {name:?} has no ssh host");
76 ensure!(
82 guard::is_label(name),
83 "alias {name:?} must be lowercase letters, digits and hyphens, and may not start or end with a hyphen: it becomes a hostname label"
84 );
85 ensure!(
86 base.starts_with('/'),
87 "alias {name:?} needs an absolute base path, got {base:?}"
88 );
89 Ok(Self {
90 name: name.to_string(),
91 host: host.to_string(),
92 base: base.to_string(),
93 })
94 }
95
96 pub fn name(&self) -> &str {
97 &self.name
98 }
99
100 pub fn host(&self) -> &str {
101 &self.host
102 }
103
104 pub fn base(&self) -> &str {
105 &self.base
106 }
107}
108
109struct Session {
110 base: String,
111 fs: SftpFs,
112}
113
114pub struct Origin {
115 suffix: String,
116 port: u16,
117 sessions: HashMap<String, Session>,
118 cache: Cache,
119 token: Token,
120 author: String,
128}
129
130pub struct Bound {
136 origin: Arc<Origin>,
137 listener: TcpListener,
138}
139
140impl Origin {
141 pub async fn bind(
151 aliases: Vec<Alias>,
152 suffix: String,
153 port: u16,
154 token: Token,
155 author: String,
156 ) -> Result<Bound> {
157 let addr = SocketAddr::from(([127, 0, 0, 1], port));
158 let listener = TcpListener::bind(addr)
159 .await
160 .with_context(|| format!("bind {addr}"))?;
161
162 ensure!(
166 pac::is_suffix(&suffix),
167 "suffix {suffix:?} must be lowercase letters, digits, hyphens and dots"
168 );
169 ensure!(
174 annot::is_safe_name(&author),
175 "author {author:?} must be letters, digits, dots, dashes or underscores: it becomes a filename"
176 );
177
178 let mut sessions = HashMap::new();
179 for a in aliases {
180 let fs = SftpFs::connect(&a.host)
181 .await
182 .with_context(|| format!("alias {} -> ssh host {}", a.name, a.host))?;
183 ensure!(
187 sessions
188 .insert(a.name.clone(), Session { base: a.base, fs })
189 .is_none(),
190 "alias {:?} is defined twice",
191 a.name
192 );
193 }
194 Ok(Bound {
195 origin: Arc::new(Self {
196 suffix,
197 port,
198 sessions,
199 cache: Cache::default(),
200 token,
201 author,
202 }),
203 listener,
204 })
205 }
206}
207
208impl Bound {
209 pub async fn serve(self) -> Result<()> {
210 let Bound { origin, listener } = self;
211 let self_ = origin;
212
213 loop {
214 let (stream, _) = listener.accept().await?;
215 let me = Arc::clone(&self_);
216 tokio::spawn(async move {
217 let service = service_fn(move |req| {
218 let me = Arc::clone(&me);
219 async move { Ok::<_, std::convert::Infallible>(me.handle(req).await) }
220 });
221 let _ = http1::Builder::new()
225 .serve_connection(TokioIo::new(stream), service)
226 .await;
227 });
228 }
229 }
230}
231
232impl Origin {
233 pub async fn handle<B>(&self, req: Request<B>) -> Response<Full<Bytes>>
236 where
237 B: hyper::body::Body,
238 B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
239 {
240 let Some(host) = host_of(&req) else {
241 return fail(StatusCode::BAD_REQUEST, "request carries no Host");
242 };
243 let path = req.uri().path().to_string();
244 let cond = Conditions {
245 if_none_match: header(&req, IF_NONE_MATCH),
246 range: header(&req, RANGE),
247 if_range: header(&req, IF_RANGE),
248 control_token: req
249 .headers()
250 .get(control::TOKEN_HEADER)
251 .and_then(|v| v.to_str().ok())
252 .map(str::to_string),
253 };
254 let method = req.method().clone();
255 let query = req.uri().query().map(str::to_string);
256
257 let control_body = if path.starts_with(control::PATH_PREFIX) {
260 match read_body(req.into_body()).await {
261 Ok(b) => b,
262 Err(e) => return fail(StatusCode::BAD_REQUEST, e),
263 }
264 } else {
265 Bytes::new()
266 };
267
268 match guard::classify(&host, &path, &self.suffix, self.port) {
269 Err(e) => fail(StatusCode::FORBIDDEN, format!("{e:#}")),
272 Ok(guard::Target::Direct { path }) => {
273 self.direct(&method, path, &cond, query.as_deref(), &control_body)
274 .await
275 }
276 Ok(guard::Target::Alias { alias, path }) => {
277 self.alias(&method, alias, path, &cond).await
278 }
279 }
280 }
281
282 async fn direct(
283 &self,
284 method: &Method,
285 path: &str,
286 cond: &Conditions,
287 query: Option<&str>,
288 body: &[u8],
289 ) -> Response<Full<Bytes>> {
290 if path.starts_with(control::PATH_PREFIX) {
293 if let Some(refusal) = control::gate(method, cond.control_token.as_deref(), &self.token)
295 {
296 return refusal;
297 }
298 return self.control(method, path, query, body).await;
299 }
300
301 if path == "/proxy.pac" {
302 return match pac::script(&self.suffix, self.port) {
303 Ok(body) => plain_ok("application/x-ns-proxy-autoconfig", Bytes::from(body)),
304 Err(e) => fail(StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")),
305 };
306 }
307
308 let rest = path.trim_start_matches('/');
309 if rest.is_empty() {
310 return plain_ok("text/html; charset=utf-8", Bytes::from(self.alias_index()));
311 }
312
313 let (alias, sub) = rest.split_once('/').unwrap_or((rest, ""));
314 self.alias(method, alias, &format!("/{sub}"), cond).await
315 }
316
317 async fn alias(
318 &self,
319 method: &Method,
320 alias: &str,
321 path: &str,
322 cond: &Conditions,
323 ) -> Response<Full<Bytes>> {
324 if !matches!(*method, Method::GET | Method::HEAD) {
329 return fail(
330 StatusCode::METHOD_NOT_ALLOWED,
331 format!("{method} is not allowed: this origin is read-only"),
332 );
333 }
334
335 let Some(session) = self.sessions.get(alias) else {
336 return fail(StatusCode::NOT_FOUND, format!("no alias named {alias:?}"));
337 };
338 let resolved = match guard::resolve(&session.base, path) {
339 Ok(p) => p,
340 Err(e) => return fail(StatusCode::FORBIDDEN, format!("{e:#}")),
341 };
342
343 let wants_dir = path.ends_with('/');
344 let file = if wants_dir {
345 format!("{resolved}/index.html")
346 } else {
347 resolved.clone()
348 };
349
350 let chain = components(&session.base, &file);
354 if chain.is_empty() {
355 return self.autoindex_of(session, path, &resolved).await;
356 }
357 let last = chain.len() - 1;
358
359 self.warm_ancestor_listings(session, &chain).await;
360
361 if let Some(at) = self.first_symlink(&chain) {
365 return fail(
366 StatusCode::FORBIDDEN,
367 format!("refusing symlink at {at} (its target is not checked)"),
368 );
369 }
370
371 let mut found_last = None;
372 for (i, (dir, name)) in chain.iter().enumerate() {
373 if !self.cache.has_listing(dir) {
374 return fail(StatusCode::NOT_FOUND, format!("{path}: cannot list {dir}"));
375 }
376 let Some(attrs) = self.cache.attrs_of(dir, name) else {
377 if i == last && wants_dir {
380 return self.autoindex_of(session, path, &resolved).await;
381 }
382 return fail(StatusCode::NOT_FOUND, format!("not found: {path}"));
383 };
384
385 if i < last && !attrs.is_dir() {
386 return fail(
387 StatusCode::NOT_FOUND,
388 format!("{path}: {dir}/{name} is not a directory"),
389 );
390 }
391 if i == last {
392 found_last = Some(attrs);
393 }
394 }
395 let attrs = found_last.expect("the walk assigns on its final iteration");
396
397 if attrs.is_dir() {
398 if wants_dir {
399 return self.autoindex_of(session, path, &resolved).await;
401 }
402 return redirect(&format!("{path}/"));
405 }
406
407 let tag = cache::etag(&attrs);
408
409 if let (Some(tag), Some(header)) = (tag.as_deref(), cond.if_none_match.as_deref()) {
416 if cache::etag_matches(header, tag) {
417 return not_modified(tag);
418 }
419 }
420
421 let size = attrs.size.unwrap_or(0);
424 let wanted = match cond.range.as_deref() {
425 Some(header) => range::resolve(header, cond.if_range.as_deref(), size),
426 None => range::Resolved::Whole,
427 };
428 if wanted == range::Resolved::Unsatisfiable {
429 return unsatisfiable(size);
430 }
431
432 if let Some(body) = self.cache.body(&file, &attrs) {
434 return respond(&file, body, tag.as_deref(), &wanted, size);
435 }
436
437 if let range::Resolved::Part { start, end } = wanted {
441 if size > CACHE_WHOLE_MAX {
442 let req = RangeReq {
443 path: file.clone(),
444 offset: start,
445 len: end - start + 1,
446 };
447 let mut got = session.fs.read_ranges(std::slice::from_ref(&req)).await;
448 return match got.pop() {
449 Some(Ok(body)) => partial(
450 mime::guess(&file),
451 Bytes::from(body),
452 tag.as_deref(),
453 start,
454 end,
455 size,
456 ),
457 Some(Err(e)) => {
458 self.cache.forget_listing(&chain[last].0);
459 fail(StatusCode::NOT_FOUND, format!("{path}: {e:#}"))
460 }
461 None => fail(
462 StatusCode::INTERNAL_SERVER_ERROR,
463 "read_ranges returned no result",
464 ),
465 };
466 }
467 }
468
469 let mut got = session.fs.read_batch(std::slice::from_ref(&file)).await;
470 match got.pop() {
471 Some(Ok(body)) => {
472 let body = Bytes::from(body);
473 self.cache.put_body(&file, &attrs, body.clone());
474 if mime::guess(&file).starts_with("text/html") {
479 self.warm_subresources(session, path, &body).await;
480 }
481 respond(&file, body, tag.as_deref(), &wanted, size)
482 }
483 Some(Err(e)) => {
487 self.cache.forget_listing(&chain[last].0);
488 fail(StatusCode::NOT_FOUND, format!("{path}: {e:#}"))
489 }
490 None => fail(
491 StatusCode::INTERNAL_SERVER_ERROR,
492 "read_batch returned no result",
493 ),
494 }
495 }
496
497 async fn control(
498 &self,
499 method: &Method,
500 path: &str,
501 query: Option<&str>,
502 body: &[u8],
503 ) -> Response<Full<Bytes>> {
504 match (method, control::route_of(path)) {
505 (&Method::GET, "hello") => {
506 let mut aliases: Vec<String> = self.sessions.keys().cloned().collect();
507 aliases.sort();
508 control::hello(&aliases, &self.suffix)
509 }
510 (&Method::GET, "annotations") => self.list_annotations(query).await,
511 (&Method::POST, "annotations") => self.add_annotation(body).await,
512 (&Method::GET, route) => {
513 control::text(StatusCode::NOT_FOUND, format!("no control route {route:?}"))
514 }
515 (_, route) => control::text(
516 StatusCode::METHOD_NOT_ALLOWED,
517 format!("{method} is not allowed on {route:?}"),
518 ),
519 }
520 }
521
522 async fn resolve_doc(&self, doc: &str) -> Result<(&Session, String), (StatusCode, String)> {
528 let (alias, rest) = doc.split_once('/').unwrap_or((doc, ""));
529 let Some(session) = self.sessions.get(alias) else {
530 return Err((StatusCode::NOT_FOUND, format!("no alias named {alias:?}")));
531 };
532 let resolved = match guard::resolve(&session.base, &format!("/{rest}")) {
533 Ok(p) => p,
534 Err(e) => return Err((StatusCode::FORBIDDEN, format!("{e:#}"))),
535 };
536
537 let chain = components(&session.base, &resolved);
538 self.warm_ancestor_listings(session, &chain).await;
539 if let Some(at) = self.first_symlink(&chain) {
540 return Err((StatusCode::FORBIDDEN, format!("refusing symlink at {at}")));
541 }
542 Ok((session, resolved))
543 }
544
545 async fn list_annotations(&self, query: Option<&str>) -> Response<Full<Bytes>> {
547 let Some(doc) = param(query, "doc") else {
548 return control::text(
549 StatusCode::BAD_REQUEST,
550 "annotations needs a doc parameter, e.g. ?doc=docs/index.html",
551 );
552 };
553 let (session, resolved) = match self.resolve_doc(doc).await {
554 Ok(v) => v,
555 Err((status, detail)) => return control::text(status, detail),
556 };
557
558 match annot::Store::new(&session.fs).load(&resolved).await {
559 Ok(loaded) => control::json(&AnnotationsBody {
560 doc: resolved,
561 annotations: loaded.annotations,
562 skipped: loaded.skipped,
563 }),
564 Err(e) => control::text(StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")),
565 }
566 }
567
568 async fn add_annotation(&self, body: &[u8]) -> Response<Full<Bytes>> {
573 let request: AddBody = match serde_json::from_slice(body) {
574 Ok(r) => r,
575 Err(e) => {
576 return control::text(StatusCode::BAD_REQUEST, format!("malformed request: {e}"));
577 }
578 };
579
580 let (session, resolved) = match self.resolve_doc(&request.doc).await {
581 Ok(v) => v,
582 Err((status, detail)) => return control::text(status, detail),
583 };
584
585 let id = match (request.op, request.id) {
590 (annot::Op::Add, None) => match annot::new_id(&self.author) {
591 Ok(id) => id,
592 Err(e) => {
593 return control::text(StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}"));
594 }
595 },
596 (annot::Op::Add, Some(_)) => {
597 return control::text(
598 StatusCode::BAD_REQUEST,
599 "an id is minted by the daemon; do not send one when adding",
600 );
601 }
602 (_, Some(id)) => id,
603 (_, None) => {
604 return control::text(StatusCode::BAD_REQUEST, "an update or a delete needs an id");
605 }
606 };
607
608 let at = std::time::SystemTime::now()
611 .duration_since(std::time::UNIX_EPOCH)
612 .map_or(0, |d| d.as_secs());
613
614 let record = annot::Record {
615 op: request.op,
616 id: id.clone(),
617 at,
618 body: request.body,
619 selectors: request.selectors,
620 reply_to: request.reply_to,
621 };
622
623 match annot::Store::new(&session.fs)
624 .append(&resolved, &self.author, &record)
625 .await
626 {
627 Ok(()) => control::json(&AddedBody {
628 id,
629 at,
630 author: &self.author,
631 }),
632 Err(e) => control::text(StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")),
633 }
634 }
635
636 async fn autoindex_of(
637 &self,
638 session: &Session,
639 path: &str,
640 resolved: &str,
641 ) -> Response<Full<Bytes>> {
642 if let Some(entries) = self.cache.listing_entries(resolved) {
643 return plain_ok(
644 "text/html; charset=utf-8",
645 Bytes::from(autoindex(path, &entries)),
646 );
647 }
648 match session.fs.list_dir(resolved).await {
649 Ok(entries) => {
650 self.cache.put_listing(resolved, &entries);
651 plain_ok(
652 "text/html; charset=utf-8",
653 Bytes::from(autoindex(path, &entries)),
654 )
655 }
656 Err(e) => fail(StatusCode::NOT_FOUND, format!("{path}: {e:#}")),
657 }
658 }
659
660 async fn warm_subresources(&self, session: &Session, doc_path: &str, html: &[u8]) {
682 let refs = prefetch::scan(html, prefetch::MAX_SUBRESOURCES);
683 if refs.is_empty() {
684 return;
685 }
686 let dir_of_doc = match doc_path.rsplit_once('/') {
689 Some((head, _)) => head,
690 None => "",
691 };
692
693 let mut wanted: Vec<(String, Vec<(String, String)>)> = Vec::new();
696 for r in &refs {
697 let url = if r.starts_with('/') {
698 r.clone()
699 } else {
700 format!("{dir_of_doc}/{r}")
701 };
702 let Ok(resolved) = guard::resolve(&session.base, &url) else {
703 continue;
704 };
705 let chain = components(&session.base, &resolved);
706 if chain.is_empty() {
707 continue;
708 }
709 if self.first_symlink(&chain).is_some() {
714 continue;
715 }
716 if !chain
722 .iter()
723 .all(|(dir, _)| self.listable(&session.base, dir))
724 {
725 continue;
726 }
727 wanted.push((resolved, chain));
728 }
729
730 let all: Vec<(String, String)> = wanted.iter().flat_map(|(_, c)| c.clone()).collect();
731 self.warm_ancestor_listings(session, &all).await;
732
733 let mut to_read = Vec::new();
734 for (resolved, chain) in &wanted {
735 if self.first_symlink(chain).is_some() {
736 continue;
737 }
738 let (dir, name) = &chain[chain.len() - 1];
739 let Some(attrs) = self.cache.attrs_of(dir, name) else {
740 continue;
741 };
742 if attrs.is_dir() {
743 continue;
744 }
745 let Some(size) = attrs.size else {
750 continue;
751 };
752 if size == 0 || size > CACHE_WHOLE_MAX {
760 continue;
761 }
762 if self.cache.body(resolved, &attrs).is_some() {
763 continue;
764 }
765 to_read.push((resolved.clone(), attrs, size));
766 }
767 if to_read.is_empty() {
768 return;
769 }
770
771 let reqs: Vec<RangeReq> = to_read
779 .iter()
780 .map(|(path, _, size)| RangeReq {
781 path: path.clone(),
782 offset: 0,
783 len: *size,
784 })
785 .collect();
786
787 for ((path, attrs, size), got) in to_read.iter().zip(session.fs.read_ranges(&reqs).await) {
788 let Ok(body) = got else {
789 continue;
790 };
791 if body.len() as u64 != *size {
796 continue;
797 }
798 self.cache.put_body(path, attrs, Bytes::from(body));
799 }
800 }
801
802 async fn warm_ancestor_listings(&self, session: &Session, chain: &[(String, String)]) {
808 let mut missing: Vec<String> = chain
809 .iter()
810 .map(|(dir, _)| dir.clone())
811 .filter(|dir| !self.cache.has_listing(dir))
812 .collect();
813 missing.sort();
817 missing.dedup();
818 if missing.is_empty() {
819 return;
820 }
821 for (dir, result) in missing.iter().zip(session.fs.list_dirs(&missing).await) {
822 if let Ok(entries) = result {
823 self.cache.put_listing(dir, &entries);
824 }
825 }
826 }
827
828 fn listable(&self, base: &str, dir: &str) -> bool {
837 if dir.trim_end_matches('/') == base.trim_end_matches('/') {
838 return true;
839 }
840 components(base, dir).iter().all(|(parent, name)| {
841 self.cache
842 .attrs_of(parent, name)
843 .is_some_and(|a| a.is_dir() && !a.is_symlink())
844 })
845 }
846
847 fn first_symlink(&self, chain: &[(String, String)]) -> Option<String> {
853 chain.iter().find_map(|(dir, name)| {
854 self.cache
855 .attrs_of(dir, name)
856 .filter(Attrs::is_symlink)
857 .map(|_| format!("{dir}/{name}"))
858 })
859 }
860
861 fn alias_index(&self) -> String {
862 let mut names: Vec<&String> = self.sessions.keys().collect();
863 names.sort();
864 let mut s = String::from(
865 "<!doctype html><html><head><meta charset=\"utf-8\"><title>ssh-browser</title></head><body><h1>ssh-browser</h1><ul>",
866 );
867 for name in names {
868 let href = format!("http://{name}.{}/", self.suffix);
869 s.push_str("<li><a href=\"");
870 s.push_str(&escape(&href));
871 s.push_str("\">");
872 s.push_str(&escape(&href));
873 s.push_str("</a></li>");
874 }
875 s.push_str("</ul></body></html>");
876 s
877 }
878}
879
880#[derive(Serialize)]
881struct AnnotationsBody {
882 doc: String,
883 annotations: Vec<annot::Annotation>,
884 skipped: usize,
886}
887
888#[derive(Serialize)]
889struct AddedBody<'a> {
890 id: String,
891 at: u64,
892 author: &'a str,
893}
894
895#[derive(Deserialize)]
897struct AddBody {
898 doc: String,
899 op: annot::Op,
900 #[serde(default)]
902 id: Option<String>,
903 #[serde(default)]
904 body: Option<String>,
905 #[serde(default)]
906 selectors: Option<serde_json::Value>,
907 #[serde(default)]
908 reply_to: Option<String>,
909}
910
911fn param<'q>(query: Option<&'q str>, want: &str) -> Option<&'q str> {
916 query?.split('&').find_map(|pair| {
917 let (key, value) = pair.split_once('=')?;
918 (key == want).then_some(value)
919 })
920}
921
922fn components(base: &str, file: &str) -> Vec<(String, String)> {
928 let base = base.trim_end_matches('/');
929 let relative = file
930 .strip_prefix(base)
931 .unwrap_or("")
932 .trim_start_matches('/');
933
934 let mut out = Vec::new();
935 let mut dir = base.to_string();
936 for name in relative.split('/').filter(|s| !s.is_empty()) {
937 out.push((dir.clone(), name.to_string()));
938 dir = format!("{dir}/{name}");
939 }
940 out
941}
942
943const MAX_CONTROL_BODY: usize = 256 * 1024;
948
949async fn read_body<B>(body: B) -> Result<Bytes, String>
950where
951 B: hyper::body::Body,
952 B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
953{
954 use http_body_util::{BodyExt, Limited};
955 Limited::new(body, MAX_CONTROL_BODY)
956 .collect()
957 .await
958 .map(|collected| collected.to_bytes())
959 .map_err(|e| format!("reading the request body: {e}"))
960}
961
962fn header<B>(req: &Request<B>, name: HeaderName) -> Option<String> {
963 req.headers()
964 .get(name)
965 .and_then(|v| v.to_str().ok())
966 .map(str::to_string)
967}
968
969fn respond(
971 file: &str,
972 body: Bytes,
973 tag: Option<&str>,
974 wanted: &range::Resolved,
975 size: u64,
976) -> Response<Full<Bytes>> {
977 match wanted {
978 range::Resolved::Part { start, end } => {
979 let lo = usize::try_from(*start)
982 .unwrap_or(usize::MAX)
983 .min(body.len());
984 let hi = usize::try_from(end.saturating_add(1))
985 .unwrap_or(usize::MAX)
986 .min(body.len())
987 .max(lo);
988 partial(
989 mime::guess(file),
990 body.slice(lo..hi),
991 tag,
992 *start,
993 *end,
994 size,
995 )
996 }
997 _ => served(mime::guess(file), body, tag),
998 }
999}
1000
1001fn partial(
1002 content_type: &str,
1003 body: Bytes,
1004 tag: Option<&str>,
1005 start: u64,
1006 end: u64,
1007 size: u64,
1008) -> Response<Full<Bytes>> {
1009 let mut b = Response::builder()
1010 .status(StatusCode::PARTIAL_CONTENT)
1011 .header(CONTENT_TYPE, content_type)
1012 .header(CACHE_CONTROL, "no-cache")
1013 .header(ACCEPT_RANGES, "bytes")
1014 .header(CONTENT_RANGE, format!("bytes {start}-{end}/{size}"));
1015 if let Some(tag) = tag {
1016 b = b.header(ETAG, tag);
1017 }
1018 b.body(Full::new(body))
1019 .unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR, "malformed 206"))
1020}
1021
1022fn unsatisfiable(size: u64) -> Response<Full<Bytes>> {
1025 Response::builder()
1026 .status(StatusCode::RANGE_NOT_SATISFIABLE)
1027 .header(CONTENT_TYPE, "text/plain; charset=utf-8")
1028 .header(CONTENT_RANGE, format!("bytes */{size}"))
1029 .body(Full::new(Bytes::from_static(b"range not satisfiable")))
1030 .unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR, "malformed 416"))
1031}
1032
1033fn host_of<B>(req: &Request<B>) -> Option<String> {
1034 req.headers()
1037 .get(HOST)
1038 .and_then(|v| v.to_str().ok())
1039 .map(str::to_string)
1040 .or_else(|| req.uri().host().map(str::to_string))
1041}
1042
1043fn served(content_type: &str, body: Bytes, tag: Option<&str>) -> Response<Full<Bytes>> {
1044 let mut b = Response::builder()
1045 .status(StatusCode::OK)
1046 .header(CONTENT_TYPE, content_type)
1047 .header(CACHE_CONTROL, "no-cache")
1051 .header(ACCEPT_RANGES, "bytes");
1054 if let Some(tag) = tag {
1055 b = b.header(ETAG, tag);
1056 }
1057 b.body(Full::new(body))
1058 .unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR, "malformed response"))
1059}
1060
1061fn plain_ok(content_type: &str, body: Bytes) -> Response<Full<Bytes>> {
1063 served(content_type, body, None)
1064}
1065
1066fn not_modified(tag: &str) -> Response<Full<Bytes>> {
1073 Response::builder()
1074 .status(StatusCode::NOT_MODIFIED)
1075 .header(ETAG, tag)
1076 .header(CACHE_CONTROL, "no-cache")
1077 .body(Full::new(Bytes::new()))
1078 .unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR, "malformed 304"))
1079}
1080
1081fn fail(status: StatusCode, detail: impl Into<String>) -> Response<Full<Bytes>> {
1082 Response::builder()
1083 .status(status)
1084 .header(CONTENT_TYPE, "text/plain; charset=utf-8")
1085 .body(Full::new(Bytes::from(detail.into())))
1086 .expect("a plain-text body with static headers always builds")
1087}
1088
1089fn redirect(to: &str) -> Response<Full<Bytes>> {
1090 Response::builder()
1091 .status(StatusCode::MOVED_PERMANENTLY)
1092 .header(LOCATION, to)
1093 .body(Full::new(Bytes::new()))
1094 .unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR, "bad redirect target"))
1095}
1096
1097fn autoindex(path: &str, entries: &[Entry]) -> String {
1099 let mut visible: Vec<&Entry> = entries
1100 .iter()
1101 .filter(|e| e.name != "." && e.name != "..")
1102 .collect();
1103 visible.sort_by(|a, b| (!a.attrs.is_dir(), &a.name).cmp(&(!b.attrs.is_dir(), &b.name)));
1104
1105 let mut s = String::from("<!doctype html><html><head><meta charset=\"utf-8\"><title>");
1106 s.push_str(&escape(path));
1107 s.push_str("</title></head><body><h1>");
1108 s.push_str(&escape(path));
1109 s.push_str("</h1><ul><li><a href=\"../\">../</a></li>");
1110 for e in visible {
1111 let slash = if e.attrs.is_dir() { "/" } else { "" };
1112 s.push_str("<li><a href=\"");
1113 s.push_str(&url_escape(&e.name));
1114 s.push_str(slash);
1115 s.push_str("\">");
1116 s.push_str(&escape(&e.name));
1117 s.push_str(slash);
1118 s.push_str("</a></li>");
1119 }
1120 s.push_str("</ul></body></html>");
1121 s
1122}
1123
1124fn escape(s: &str) -> String {
1127 s.replace('&', "&")
1128 .replace('<', "<")
1129 .replace('>', ">")
1130 .replace('"', """)
1131}
1132
1133fn url_escape(s: &str) -> String {
1136 let mut out = String::with_capacity(s.len());
1137 for b in s.bytes() {
1138 match b {
1139 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1140 out.push(b as char);
1141 }
1142 _ => out.push_str(&format!("%{b:02X}")),
1143 }
1144 }
1145 out
1146}
1147
1148#[cfg(test)]
1149mod tests {
1150 use super::*;
1151 use crate::sftp::wire::Attrs;
1152 use crate::testing::{FakeRemote, dir_attrs, file_attrs, symlink_attrs};
1153 use http_body_util::{BodyExt, Empty};
1154
1155 const TEST_TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
1156
1157 async fn body_of(res: Response<Full<Bytes>>) -> Bytes {
1158 res.into_body()
1159 .collect()
1160 .await
1161 .expect("a Full body always collects")
1162 .to_bytes()
1163 }
1164
1165 fn loopback(path: &str, token: Option<&str>) -> Request<Empty<Bytes>> {
1167 let mut b = Request::builder().uri(path).header(HOST, "127.0.0.1:7391");
1168 if let Some(t) = token {
1169 b = b.header(control::TOKEN_HEADER, t);
1170 }
1171 b.body(Empty::<Bytes>::new()).expect("request builds")
1172 }
1173
1174 fn control_post(path: &str, token: Option<&str>, body: &str) -> Request<Full<Bytes>> {
1175 let mut b = Request::builder()
1176 .method(Method::POST)
1177 .uri(path)
1178 .header(HOST, "127.0.0.1:7391");
1179 if let Some(t) = token {
1180 b = b.header(control::TOKEN_HEADER, t);
1181 }
1182 b.body(Full::new(Bytes::from(body.to_string())))
1183 .expect("request builds")
1184 }
1185
1186 async fn json_of(res: Response<Full<Bytes>>) -> serde_json::Value {
1187 let bytes = body_of(res).await;
1188 serde_json::from_slice(&bytes).expect("a control response is json")
1189 }
1190
1191 fn ranged(path: &str, range: &str) -> Request<Empty<Bytes>> {
1192 Request::builder()
1193 .uri(format!("http://docs.ssh-browser{path}"))
1194 .header(HOST, "docs.ssh-browser")
1195 .header(RANGE, range)
1196 .body(Empty::new())
1197 .expect("request builds")
1198 }
1199
1200 async fn origin_with(remote: FakeRemote) -> Origin {
1203 let fs = remote.spawn().await;
1204 let mut sessions = HashMap::new();
1205 sessions.insert(
1206 "docs".to_string(),
1207 Session {
1208 base: "/srv".to_string(),
1209 fs,
1210 },
1211 );
1212 Origin {
1213 suffix: "ssh-browser".to_string(),
1214 port: 7391,
1215 sessions,
1216 cache: Cache::default(),
1217 token: Token::from_hex(TEST_TOKEN),
1218 author: "souta".to_string(),
1219 }
1220 }
1221
1222 fn get(path: &str, if_none_match: Option<&str>) -> Request<Empty<Bytes>> {
1223 let mut b = Request::builder()
1224 .uri(format!("http://docs.ssh-browser{path}"))
1225 .header(HOST, "docs.ssh-browser");
1226 if let Some(tag) = if_none_match {
1227 b = b.header(IF_NONE_MATCH, tag);
1228 }
1229 b.body(Empty::new()).expect("request builds")
1230 }
1231
1232 fn trips(origin: &Origin) -> u64 {
1233 origin.sessions.values().map(|s| s.fs.round_trips()).sum()
1234 }
1235
1236 fn one_page() -> FakeRemote {
1237 FakeRemote::new()
1238 .dir("/srv", vec![("a.html", file_attrs(5, 100))])
1239 .file("/srv/a.html", b"hello")
1240 }
1241
1242 fn page_with_subresources(n: usize) -> FakeRemote {
1245 let mut html = String::from(
1246 "<!doctype html><html><head><link rel=\"stylesheet\" href=\"assets/style.css\"><script src=\"assets/app.js\"></script></head><body>",
1247 );
1248 for i in 0..n {
1249 html.push_str(&format!("<img src=\"assets/{i}.png\">"));
1250 }
1251 html.push_str("</body></html>");
1252
1253 let mut assets = vec!["style.css".to_string(), "app.js".to_string()];
1254 assets.extend((0..n).map(|i| format!("{i}.png")));
1255
1256 let mut remote = FakeRemote::new()
1257 .dir(
1258 "/srv",
1259 vec![
1260 ("index.html", file_attrs(html.len() as u64, 100)),
1261 ("assets", dir_attrs()),
1262 ],
1263 )
1264 .dir(
1265 "/srv/assets",
1266 assets
1267 .iter()
1268 .map(|name| (name.as_str(), file_attrs(3, 1)))
1269 .collect(),
1270 )
1271 .file("/srv/index.html", html.as_bytes());
1272 for name in &assets {
1273 remote = remote.file(&format!("/srv/assets/{name}"), b"xxx");
1274 }
1275 remote
1276 }
1277
1278 #[tokio::test]
1285 async fn a_pages_subresources_are_already_held_when_the_browser_asks_for_them() {
1286 const N: usize = 40;
1287 let origin = origin_with(page_with_subresources(N)).await;
1288
1289 let res = origin.handle(get("/index.html", None)).await;
1290 assert_eq!(res.status(), StatusCode::OK);
1291
1292 let before = trips(&origin);
1293 for i in 0..N {
1294 let path = format!("/assets/{i}.png");
1295 let res = origin.handle(get(&path, None)).await;
1296 assert_eq!(res.status(), StatusCode::OK, "{path}");
1297 assert_eq!(&body_of(res).await[..], b"xxx", "{path}");
1298 }
1299 for name in ["style.css", "app.js"] {
1300 let res = origin.handle(get(&format!("/assets/{name}"), None)).await;
1301 assert_eq!(res.status(), StatusCode::OK, "{name}");
1302 }
1303
1304 assert_eq!(
1305 trips(&origin) - before,
1306 0,
1307 "reading the page's own references is what makes these free"
1308 );
1309 }
1310
1311 #[tokio::test]
1314 async fn serving_a_page_costs_the_same_however_many_subresources_it_has() {
1315 async fn cost(n: usize) -> u64 {
1316 let origin = origin_with(page_with_subresources(n)).await;
1317 let before = trips(&origin);
1318 let res = origin.handle(get("/index.html", None)).await;
1319 assert_eq!(res.status(), StatusCode::OK);
1320 trips(&origin) - before
1321 }
1322 assert_eq!(cost(4).await, cost(40).await);
1323 }
1324
1325 fn page_referring_to(refs: &[&str], extra: Vec<(&'static str, Attrs)>) -> FakeRemote {
1329 let mut html = String::from("<!doctype html><html><body>");
1330 for r in refs {
1331 html.push_str(&format!("<img src=\"{r}\">"));
1332 }
1333 html.push_str("</body></html>");
1334
1335 let mut entries = vec![("index.html", file_attrs(html.len() as u64, 100))];
1336 entries.extend(extra);
1337 FakeRemote::new()
1338 .dir("/srv", entries)
1339 .file("/srv/index.html", html.as_bytes())
1340 }
1341
1342 async fn cost_of_serving(refs: &[&str], extra: Vec<(&'static str, Attrs)>) -> u64 {
1343 let origin = origin_with(page_referring_to(refs, extra)).await;
1344 let before = trips(&origin);
1345 let res = origin.handle(get("/index.html", None)).await;
1346 assert_eq!(res.status(), StatusCode::OK);
1347 trips(&origin) - before
1348 }
1349
1350 #[tokio::test]
1353 async fn a_page_cannot_prefetch_its_way_out_of_the_alias_base() {
1354 let baseline = cost_of_serving(&[], vec![]).await;
1355 assert_eq!(
1356 cost_of_serving(&["../../../etc/passwd", "/../../etc/shadow"], vec![]).await,
1357 baseline,
1358 "an escaping reference is gone before anything is listed or read"
1359 );
1360 }
1361
1362 #[tokio::test]
1365 async fn a_page_cannot_prefetch_through_a_symlink() {
1366 let link = || vec![("link", symlink_attrs())];
1367 let baseline = cost_of_serving(&[], link()).await;
1368 assert_eq!(
1369 cost_of_serving(&["link/inside.png"], link()).await,
1370 baseline,
1371 "the symlink is known from the listing the page itself needed"
1372 );
1373
1374 let origin = origin_with(page_referring_to(&["link/inside.png"], link())).await;
1377 assert_eq!(
1378 origin.handle(get("/index.html", None)).await.status(),
1379 StatusCode::OK
1380 );
1381 assert_eq!(
1382 origin.handle(get("/link/inside.png", None)).await.status(),
1383 StatusCode::FORBIDDEN
1384 );
1385 }
1386
1387 #[tokio::test]
1399 async fn a_page_cannot_get_a_symlink_below_an_unlisted_directory_opened() {
1400 let html = "<!doctype html><html><body><img src=\"assets/link/secret.txt\"></body></html>";
1401 let origin = origin_with(
1402 FakeRemote::new()
1403 .dir(
1404 "/srv",
1405 vec![
1406 ("index.html", file_attrs(html.len() as u64, 100)),
1407 ("assets", dir_attrs()),
1408 ],
1409 )
1410 .dir("/srv/assets", vec![("link", symlink_attrs())])
1411 .dir("/srv/assets/link", vec![("secret.txt", file_attrs(9, 1))])
1413 .file("/srv/index.html", html.as_bytes())
1414 .file("/srv/assets/link/secret.txt", b"elsewhere"),
1415 )
1416 .await;
1417
1418 assert_eq!(
1419 origin.handle(get("/index.html", None)).await.status(),
1420 StatusCode::OK
1421 );
1422 assert!(
1423 !origin.cache.has_listing("/srv/assets/link"),
1424 "the daemon listed the directory a symlink points at"
1425 );
1426
1427 assert_eq!(
1430 origin
1431 .handle(get("/assets/link/secret.txt", None))
1432 .await
1433 .status(),
1434 StatusCode::FORBIDDEN
1435 );
1436 }
1437
1438 #[tokio::test]
1442 async fn a_reference_in_a_real_subdirectory_is_still_prefetched() {
1443 let html = "<!doctype html><html><body><img src=\"assets/x.png\"></body></html>";
1444 let origin = origin_with(
1445 FakeRemote::new()
1446 .dir(
1447 "/srv",
1448 vec![
1449 ("index.html", file_attrs(html.len() as u64, 100)),
1450 ("assets", dir_attrs()),
1451 ],
1452 )
1453 .dir("/srv/assets", vec![("x.png", file_attrs(3, 1))])
1454 .file("/srv/index.html", html.as_bytes())
1455 .file("/srv/assets/x.png", b"xxx"),
1456 )
1457 .await;
1458
1459 assert_eq!(
1460 origin.handle(get("/index.html", None)).await.status(),
1461 StatusCode::OK
1462 );
1463 let before = trips(&origin);
1464 let res = origin.handle(get("/assets/x.png", None)).await;
1465 assert_eq!(res.status(), StatusCode::OK);
1466 assert_eq!(&body_of(res).await[..], b"xxx");
1467 assert_eq!(
1468 trips(&origin) - before,
1469 0,
1470 "a subdirectory one level down must still be warmed"
1471 );
1472 }
1473
1474 #[tokio::test]
1481 async fn a_large_subresource_costs_what_a_small_one_costs() {
1482 async fn cost(bytes: usize) -> u64 {
1483 let html = "<!doctype html><html><body><img src=\"assets/big.bin\"></body></html>";
1484 let origin = origin_with(
1485 FakeRemote::new()
1486 .dir(
1487 "/srv",
1488 vec![
1489 ("index.html", file_attrs(html.len() as u64, 100)),
1490 ("assets", dir_attrs()),
1491 ],
1492 )
1493 .dir(
1494 "/srv/assets",
1495 vec![("big.bin", file_attrs(bytes as u64, 1))],
1496 )
1497 .file("/srv/index.html", html.as_bytes())
1498 .file("/srv/assets/big.bin", &vec![b'x'; bytes]),
1499 )
1500 .await;
1501
1502 let before = trips(&origin);
1503 assert_eq!(
1504 origin.handle(get("/index.html", None)).await.status(),
1505 StatusCode::OK
1506 );
1507 let spent = trips(&origin) - before;
1508
1509 let at = trips(&origin);
1512 let res = origin.handle(get("/assets/big.bin", None)).await;
1513 assert_eq!(res.status(), StatusCode::OK);
1514 assert_eq!(body_of(res).await.len(), bytes);
1515 assert_eq!(
1516 trips(&origin) - at,
1517 0,
1518 "{bytes} bytes should have been held"
1519 );
1520
1521 spent
1522 }
1523
1524 assert_eq!(cost(1024).await, cost(200 * 1024).await);
1526 }
1527
1528 #[tokio::test]
1538 async fn a_subresource_the_listing_calls_empty_is_not_prefetched() {
1539 let html = "<!doctype html><html><body><img src=\"assets/x.png\"></body></html>";
1540 let sizeless = Attrs {
1541 permissions: Some(0o100644),
1542 mtime: Some(1),
1543 ..Attrs::default()
1544 };
1545 let origin = origin_with(
1546 FakeRemote::new()
1547 .dir(
1548 "/srv",
1549 vec![
1550 ("index.html", file_attrs(html.len() as u64, 100)),
1551 ("assets", dir_attrs()),
1552 ],
1553 )
1554 .dir("/srv/assets", vec![("x.png", sizeless)])
1555 .file("/srv/index.html", html.as_bytes())
1556 .file("/srv/assets/x.png", b"xxx"),
1557 )
1558 .await;
1559
1560 assert_eq!(
1561 origin.handle(get("/index.html", None)).await.status(),
1562 StatusCode::OK
1563 );
1564 let res = origin.handle(get("/assets/x.png", None)).await;
1565 assert_eq!(res.status(), StatusCode::OK);
1566 assert_eq!(
1567 &body_of(res).await[..],
1568 b"xxx",
1569 "the real request must still serve the whole file"
1570 );
1571 }
1572
1573 #[tokio::test]
1575 async fn an_oversized_subresource_is_not_prefetched() {
1576 async fn cost(size: u64) -> u64 {
1577 let html =
1578 "<!doctype html><html><body><video src=\"assets/film.mp4\"></video></body></html>";
1579 let origin = origin_with(
1580 FakeRemote::new()
1581 .dir(
1582 "/srv",
1583 vec![
1584 ("index.html", file_attrs(html.len() as u64, 100)),
1585 ("assets", dir_attrs()),
1586 ],
1587 )
1588 .dir("/srv/assets", vec![("film.mp4", file_attrs(size, 1))])
1589 .file("/srv/index.html", html.as_bytes())
1590 .file("/srv/assets/film.mp4", b"xxx"),
1591 )
1592 .await;
1593 let before = trips(&origin);
1594 assert_eq!(
1595 origin.handle(get("/index.html", None)).await.status(),
1596 StatusCode::OK
1597 );
1598 trips(&origin) - before
1599 }
1600
1601 let read_it = cost(3).await;
1604 let skipped = cost(CACHE_WHOLE_MAX + 1).await;
1605 assert!(
1606 skipped < read_it,
1607 "an oversized subresource cost {skipped} against {read_it} for a small one"
1608 );
1609 }
1610
1611 #[tokio::test]
1623 async fn the_port_is_taken_before_any_host_is_connected() {
1624 let held = TcpListener::bind(("127.0.0.1", 0))
1625 .await
1626 .expect("a free port");
1627 let port = held.local_addr().expect("its address").port();
1628
1629 const NOWHERE: &str = "a-host-that-cannot-resolve.invalid";
1630 let result = Origin::bind(
1631 vec![Alias::new("docs", NOWHERE, "/srv").expect("a valid alias")],
1632 "ssh-browser".to_string(),
1633 port,
1634 Token::from_hex(TEST_TOKEN),
1635 "souta".to_string(),
1636 )
1637 .await;
1638
1639 let Err(e) = result else {
1640 panic!("binding a port that is already held must fail");
1641 };
1642 let text = format!("{e:#}");
1643 assert!(
1644 text.contains(&format!("bind 127.0.0.1:{port}")),
1645 "the error should name the port, got: {text}"
1646 );
1647 assert!(
1648 !text.contains(NOWHERE),
1649 "the ssh host was reached before the port was taken: {text}"
1650 );
1651 }
1652
1653 fn deep_tree() -> FakeRemote {
1655 FakeRemote::new()
1656 .dir("/srv", vec![("a", dir_attrs())])
1657 .dir("/srv/a", vec![("b", dir_attrs())])
1658 .dir("/srv/a/b", vec![("c", dir_attrs())])
1659 .dir("/srv/a/b/c", vec![("d.html", file_attrs(5, 100))])
1660 .file("/srv/a/b/c/d.html", b"deep!")
1661 }
1662
1663 fn entry(name: &str, dir: bool) -> Entry {
1664 Entry {
1665 name: name.to_string(),
1666 attrs: Attrs {
1667 permissions: Some(if dir { 0o040755 } else { 0o100644 }),
1668 ..Attrs::default()
1669 },
1670 owner: None,
1671 }
1672 }
1673
1674 #[test]
1675 fn a_hostile_filename_cannot_inject_script_into_our_origin() {
1676 let page = autoindex("/", &[entry("<script>alert(1)</script>", false)]);
1677 assert!(!page.contains("<script>alert"));
1678 assert!(page.contains("<script>"));
1679 }
1680
1681 #[test]
1682 fn listings_put_directories_first_then_sort_by_name() {
1683 let page = autoindex(
1684 "/",
1685 &[
1686 entry("b.txt", false),
1687 entry("z-dir", true),
1688 entry("a.txt", false),
1689 ],
1690 );
1691 let dir = page.find("z-dir").expect("dir listed");
1692 let a = page.find("a.txt").expect("a listed");
1693 let b = page.find("b.txt").expect("b listed");
1694 assert!(dir < a, "directories come first");
1695 assert!(a < b, "files sort by name");
1696 }
1697
1698 #[test]
1699 fn hrefs_are_url_escaped() {
1700 let page = autoindex("/", &[entry("a b#c.html", false)]);
1701 assert!(page.contains("href=\"a%20b%23c.html\""));
1702 }
1703
1704 #[test]
1705 fn the_component_chain_walks_from_the_base_down() {
1706 assert_eq!(
1707 components("/srv", "/srv/a/b/c.html"),
1708 vec![
1709 ("/srv".to_string(), "a".to_string()),
1710 ("/srv/a".to_string(), "b".to_string()),
1711 ("/srv/a/b".to_string(), "c.html".to_string()),
1712 ]
1713 );
1714 assert_eq!(
1715 components("/srv", "/srv/index.html"),
1716 vec![("/srv".to_string(), "index.html".to_string())]
1717 );
1718 assert_eq!(
1720 components("/srv/", "/srv/a.html"),
1721 vec![("/srv".to_string(), "a.html".to_string())]
1722 );
1723 assert!(components("/srv", "/srv").is_empty());
1725 }
1726
1727 #[tokio::test]
1730 async fn a_revisit_costs_no_remote_round_trips() {
1731 let origin = origin_with(one_page()).await;
1732
1733 let first = origin.handle(get("/a.html", None)).await;
1734 assert_eq!(first.status(), StatusCode::OK);
1735 let after_first = trips(&origin);
1736 assert!(after_first > 0, "the first request has to fetch something");
1737
1738 let second = origin.handle(get("/a.html", None)).await;
1739 assert_eq!(second.status(), StatusCode::OK);
1740 assert_eq!(
1741 trips(&origin),
1742 after_first,
1743 "a revisit must be answered entirely from cache"
1744 );
1745 }
1746
1747 #[tokio::test]
1750 async fn a_conditional_get_is_answered_without_the_remote() {
1751 let origin = origin_with(one_page()).await;
1752
1753 let first = origin.handle(get("/a.html", None)).await;
1754 let tag = first
1755 .headers()
1756 .get(ETAG)
1757 .expect("a validator is offered")
1758 .to_str()
1759 .expect("ascii")
1760 .to_string();
1761 let after_first = trips(&origin);
1762
1763 let second = origin.handle(get("/a.html", Some(&tag))).await;
1764 assert_eq!(second.status(), StatusCode::NOT_MODIFIED);
1765 assert_eq!(
1766 trips(&origin),
1767 after_first,
1768 "a 304 must not touch the remote"
1769 );
1770 }
1771
1772 #[tokio::test]
1774 async fn a_missing_file_is_a_404_from_the_cached_listing() {
1775 let origin = origin_with(one_page()).await;
1776
1777 origin.handle(get("/a.html", None)).await;
1779 let warm = trips(&origin);
1780
1781 let missing = origin.handle(get("/nope.html", None)).await;
1782 assert_eq!(missing.status(), StatusCode::NOT_FOUND);
1783 assert_eq!(
1784 trips(&origin),
1785 warm,
1786 "a 404 for a listed-but-absent name must cost nothing"
1787 );
1788 }
1789
1790 #[tokio::test]
1793 async fn a_symlink_is_refused() {
1794 let origin = origin_with(
1795 FakeRemote::new()
1796 .dir("/srv", vec![("link.html", symlink_attrs())])
1797 .file("/srv/link.html", b"whatever the target is"),
1798 )
1799 .await;
1800
1801 let res = origin.handle(get("/link.html", None)).await;
1802 assert_eq!(res.status(), StatusCode::FORBIDDEN);
1803 }
1804
1805 #[tokio::test]
1807 async fn a_directory_without_a_trailing_slash_redirects() {
1808 let origin = origin_with(
1809 FakeRemote::new()
1810 .dir("/srv", vec![("sub", dir_attrs())])
1811 .dir("/srv/sub", vec![("b.html", file_attrs(1, 1))]),
1812 )
1813 .await;
1814
1815 let res = origin.handle(get("/sub", None)).await;
1816 assert_eq!(res.status(), StatusCode::MOVED_PERMANENTLY);
1817 assert_eq!(
1818 res.headers().get(LOCATION).and_then(|v| v.to_str().ok()),
1819 Some("/sub/")
1820 );
1821 }
1822
1823 #[tokio::test]
1826 async fn a_listing_proven_wrong_is_forgotten() {
1827 let origin =
1829 origin_with(FakeRemote::new().dir("/srv", vec![("ghost.html", file_attrs(5, 100))]))
1830 .await;
1831
1832 let res = origin.handle(get("/ghost.html", None)).await;
1833 assert_eq!(res.status(), StatusCode::NOT_FOUND);
1834 assert!(
1835 !origin.cache.has_listing("/srv"),
1836 "a listing contradicted by the remote must be dropped"
1837 );
1838 }
1839
1840 #[tokio::test]
1842 async fn a_directory_without_an_index_is_listed() {
1843 let origin =
1844 origin_with(FakeRemote::new().dir("/srv", vec![("only.txt", file_attrs(2, 1))])).await;
1845
1846 let res = origin.handle(get("/", None)).await;
1847 assert_eq!(res.status(), StatusCode::OK);
1848 assert_eq!(
1849 res.headers()
1850 .get(CONTENT_TYPE)
1851 .and_then(|v| v.to_str().ok()),
1852 Some("text/html; charset=utf-8")
1853 );
1854 }
1855
1856 #[tokio::test]
1859 async fn a_symlinked_directory_higher_up_the_path_is_refused() {
1860 let origin = origin_with(
1861 FakeRemote::new()
1862 .dir("/srv", vec![("link", symlink_attrs())])
1863 .dir("/srv/link", vec![("inside.html", file_attrs(2, 1))])
1864 .file("/srv/link/inside.html", b"hi"),
1865 )
1866 .await;
1867
1868 let res = origin.handle(get("/link/inside.html", None)).await;
1869 assert_eq!(res.status(), StatusCode::FORBIDDEN);
1870 }
1871
1872 #[tokio::test]
1875 async fn a_deep_path_costs_what_a_shallow_one_costs() {
1876 let deep = origin_with(deep_tree()).await;
1877 assert_eq!(
1878 deep.handle(get("/a/b/c/d.html", None)).await.status(),
1879 StatusCode::OK
1880 );
1881
1882 let shallow = origin_with(one_page()).await;
1883 assert_eq!(
1884 shallow.handle(get("/a.html", None)).await.status(),
1885 StatusCode::OK
1886 );
1887
1888 let (d, sh) = (trips(&deep), trips(&shallow));
1889 assert!(
1893 d <= sh + 2,
1894 "depth 4 cost {d} round trips against depth 1's {sh}"
1895 );
1896 }
1897
1898 #[tokio::test]
1900 async fn a_file_used_as_a_directory_is_a_404() {
1901 let origin = origin_with(one_page()).await;
1902 let res = origin.handle(get("/a.html/b.html", None)).await;
1903 assert_eq!(res.status(), StatusCode::NOT_FOUND);
1904 }
1905
1906 #[tokio::test]
1909 async fn a_deep_path_serves_its_body() {
1910 let origin = origin_with(deep_tree()).await;
1911 let res = origin.handle(get("/a/b/c/d.html", None)).await;
1912 assert_eq!(res.status(), StatusCode::OK);
1913 assert_eq!(
1914 res.headers()
1915 .get(CONTENT_TYPE)
1916 .and_then(|v| v.to_str().ok()),
1917 Some("text/html; charset=utf-8")
1918 );
1919 }
1920
1921 #[tokio::test]
1923 async fn a_range_is_sliced_out_of_the_cached_body() {
1924 let origin = origin_with(one_page()).await;
1925 assert_eq!(
1926 origin.handle(get("/a.html", None)).await.status(),
1927 StatusCode::OK
1928 );
1929 let warm = trips(&origin);
1930
1931 let res = origin.handle(ranged("/a.html", "bytes=1-3")).await;
1932 assert_eq!(res.status(), StatusCode::PARTIAL_CONTENT);
1933 assert_eq!(
1934 res.headers()
1935 .get(CONTENT_RANGE)
1936 .and_then(|v| v.to_str().ok()),
1937 Some("bytes 1-3/5")
1938 );
1939 assert_eq!(&body_of(res).await[..], b"ell");
1940 assert_eq!(
1941 trips(&origin),
1942 warm,
1943 "slicing a held body must cost no round trip"
1944 );
1945 }
1946
1947 #[tokio::test]
1949 async fn a_range_on_a_cold_small_file_works_and_warms_the_cache() {
1950 let origin = origin_with(one_page()).await;
1951
1952 let res = origin.handle(ranged("/a.html", "bytes=0-1")).await;
1953 assert_eq!(res.status(), StatusCode::PARTIAL_CONTENT);
1954 assert_eq!(&body_of(res).await[..], b"he");
1955
1956 let warm = trips(&origin);
1957 let again = origin.handle(ranged("/a.html", "bytes=2-4")).await;
1958 assert_eq!(&body_of(again).await[..], b"llo");
1959 assert_eq!(
1960 trips(&origin),
1961 warm,
1962 "a small file fetched for a range should be held whole"
1963 );
1964 }
1965
1966 #[tokio::test]
1968 async fn a_range_past_the_end_is_a_416_carrying_the_real_size() {
1969 let origin = origin_with(one_page()).await;
1970 let res = origin.handle(ranged("/a.html", "bytes=99-")).await;
1971 assert_eq!(res.status(), StatusCode::RANGE_NOT_SATISFIABLE);
1972 assert_eq!(
1973 res.headers()
1974 .get(CONTENT_RANGE)
1975 .and_then(|v| v.to_str().ok()),
1976 Some("bytes */5")
1977 );
1978 }
1979
1980 #[tokio::test]
1982 async fn a_full_response_advertises_ranges() {
1983 let origin = origin_with(one_page()).await;
1984 let res = origin.handle(get("/a.html", None)).await;
1985 assert_eq!(
1986 res.headers()
1987 .get(ACCEPT_RANGES)
1988 .and_then(|v| v.to_str().ok()),
1989 Some("bytes")
1990 );
1991 }
1992
1993 #[tokio::test]
1996 async fn if_range_yields_the_whole_file() {
1997 let origin = origin_with(one_page()).await;
1998 let req = Request::builder()
1999 .uri("http://docs.ssh-browser/a.html")
2000 .header(HOST, "docs.ssh-browser")
2001 .header(RANGE, "bytes=1-3")
2002 .header(IF_RANGE, "W/\"64-5\"")
2003 .body(Empty::<Bytes>::new())
2004 .expect("request builds");
2005
2006 let res = origin.handle(req).await;
2007 assert_eq!(res.status(), StatusCode::OK);
2008 assert_eq!(&body_of(res).await[..], b"hello");
2009 }
2010
2011 #[tokio::test]
2014 async fn a_large_file_is_served_by_range_and_not_held() {
2015 let body: Vec<u8> = (0..64u8).collect();
2016 let origin = origin_with(
2017 FakeRemote::new()
2018 .dir("/srv", vec![("big.bin", file_attrs(9 * 1024 * 1024, 7))])
2021 .file("/srv/big.bin", &body),
2022 )
2023 .await;
2024
2025 let res = origin.handle(ranged("/big.bin", "bytes=0-9")).await;
2026 assert_eq!(res.status(), StatusCode::PARTIAL_CONTENT);
2027 assert_eq!(&body_of(res).await[..], &body[0..10]);
2028
2029 let after = trips(&origin);
2030 let second = origin.handle(ranged("/big.bin", "bytes=10-19")).await;
2031 assert_eq!(&body_of(second).await[..], &body[10..20]);
2032 assert!(
2033 trips(&origin) > after,
2034 "a file over the threshold must not be held"
2035 );
2036 }
2037
2038 #[tokio::test]
2042 async fn an_alias_origin_has_no_control_api_on_it() {
2043 let origin = origin_with(one_page()).await;
2044 let res = origin.handle(get("/_control/hello", None)).await;
2045 assert_eq!(res.status(), StatusCode::NOT_FOUND);
2046 assert_ne!(
2047 res.status(),
2048 StatusCode::UNAUTHORIZED,
2049 "a 401 would mean the control router was reached from an alias origin"
2050 );
2051 }
2052
2053 #[tokio::test]
2056 async fn an_alias_origin_with_a_valid_token_still_has_no_control_api() {
2057 let origin = origin_with(one_page()).await;
2058 let req = Request::builder()
2059 .uri("http://docs.ssh-browser/_control/hello")
2060 .header(HOST, "docs.ssh-browser")
2061 .header(control::TOKEN_HEADER, TEST_TOKEN)
2062 .body(Empty::<Bytes>::new())
2063 .expect("request builds");
2064 assert_eq!(origin.handle(req).await.status(), StatusCode::NOT_FOUND);
2065 }
2066
2067 #[tokio::test]
2070 async fn the_alias_origin_refuses_writes() {
2071 let origin = origin_with(one_page()).await;
2072 for method in [Method::POST, Method::PUT, Method::DELETE, Method::PATCH] {
2073 let req = Request::builder()
2074 .method(method.clone())
2075 .uri("http://docs.ssh-browser/a.html")
2076 .header(HOST, "docs.ssh-browser")
2077 .body(Empty::<Bytes>::new())
2078 .expect("request builds");
2079 assert_eq!(
2080 origin.handle(req).await.status(),
2081 StatusCode::METHOD_NOT_ALLOWED,
2082 "{method} should be refused on the read-only origin"
2083 );
2084 }
2085 }
2086
2087 #[tokio::test]
2088 async fn the_control_api_answers_on_loopback_with_the_token() {
2089 let origin = origin_with(one_page()).await;
2090 let res = origin
2091 .handle(loopback("/_control/hello", Some(TEST_TOKEN)))
2092 .await;
2093 assert_eq!(res.status(), StatusCode::OK);
2094 let body = body_of(res).await;
2095 let text = String::from_utf8_lossy(&body);
2096 assert!(
2097 text.contains("\"protocol\""),
2098 "hello must negotiate: {text}"
2099 );
2100 assert!(text.contains("\"docs\""), "hello must list aliases: {text}");
2101 }
2102
2103 #[tokio::test]
2104 async fn the_control_api_refuses_loopback_without_the_token() {
2105 let origin = origin_with(one_page()).await;
2106 assert_eq!(
2107 origin
2108 .handle(loopback("/_control/hello", None))
2109 .await
2110 .status(),
2111 StatusCode::UNAUTHORIZED
2112 );
2113 assert_eq!(
2114 origin
2115 .handle(loopback("/_control/hello", Some("wrong")))
2116 .await
2117 .status(),
2118 StatusCode::UNAUTHORIZED
2119 );
2120 }
2121
2122 #[tokio::test]
2124 async fn the_loopback_path_still_serves_files() {
2125 let origin = origin_with(one_page()).await;
2126 let res = origin.handle(loopback("/docs/a.html", None)).await;
2127 assert_eq!(res.status(), StatusCode::OK);
2128 assert_eq!(&body_of(res).await[..], b"hello");
2129 }
2130
2131 #[tokio::test]
2133 async fn an_annotation_written_through_control_comes_back_out() {
2134 let origin = origin_with(one_page()).await;
2135
2136 let added = origin
2137 .handle(control_post(
2138 "/_control/annotations",
2139 Some(TEST_TOKEN),
2140 r#"{"doc":"docs/a.html","op":"add","body":"a note"}"#,
2141 ))
2142 .await;
2143 assert_eq!(added.status(), StatusCode::OK);
2144 let added = json_of(added).await;
2145 let id = added["id"].as_str().expect("an id was minted").to_string();
2146 assert!(
2147 id.starts_with("souta:"),
2148 "the id must name the daemon's author, got {id}"
2149 );
2150 assert_eq!(added["author"], "souta");
2151
2152 let listed = origin
2153 .handle(loopback(
2154 "/_control/annotations?doc=docs/a.html",
2155 Some(TEST_TOKEN),
2156 ))
2157 .await;
2158 assert_eq!(listed.status(), StatusCode::OK);
2159 let listed = json_of(listed).await;
2160 assert_eq!(listed["skipped"], 0);
2161 let annotations = listed["annotations"].as_array().expect("an array");
2162 assert_eq!(annotations.len(), 1);
2163 assert_eq!(annotations[0]["body"], "a note");
2164 assert_eq!(annotations[0]["id"], id.as_str());
2165 assert_eq!(annotations[0]["author"], "souta");
2166 assert_eq!(annotations[0]["attribution"]["state"], "unchecked");
2170 }
2171
2172 #[tokio::test]
2175 async fn a_mismatched_author_reaches_the_extension_as_json() {
2176 let dir = "/srv/.ssh-browser/a.html/ann";
2177 let log = b"{\"op\":\"add\",\"id\":\"alice:1\",\"at\":10,\"body\":\"is this alice?\"}\n";
2178 let origin = origin_with(
2179 one_page()
2180 .dir(dir, vec![("alice.jsonl", file_attrs(log.len() as u64, 1))])
2181 .owner(&format!("{dir}/alice.jsonl"), "bob")
2182 .file(&format!("{dir}/alice.jsonl"), log),
2183 )
2184 .await;
2185
2186 let listed = origin
2187 .handle(loopback(
2188 "/_control/annotations?doc=docs/a.html",
2189 Some(TEST_TOKEN),
2190 ))
2191 .await;
2192 assert_eq!(listed.status(), StatusCode::OK);
2193 let listed = json_of(listed).await;
2194 let annotations = listed["annotations"].as_array().expect("an array");
2195 assert_eq!(annotations.len(), 1, "the note is served, not censored");
2196 assert_eq!(annotations[0]["author"], "alice");
2197 assert_eq!(annotations[0]["attribution"]["state"], "mismatched");
2198 assert_eq!(annotations[0]["attribution"]["owner"], "bob");
2199 }
2200
2201 #[tokio::test]
2202 async fn writing_an_annotation_without_the_token_is_refused() {
2203 let origin = origin_with(one_page()).await;
2204 let res = origin
2205 .handle(control_post(
2206 "/_control/annotations",
2207 None,
2208 r#"{"doc":"docs/a.html","op":"add","body":"a note"}"#,
2209 ))
2210 .await;
2211 assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
2212 }
2213
2214 #[tokio::test]
2218 async fn an_add_may_not_carry_an_id() {
2219 let origin = origin_with(one_page()).await;
2220 let res = origin
2221 .handle(control_post(
2222 "/_control/annotations",
2223 Some(TEST_TOKEN),
2224 r#"{"doc":"docs/a.html","op":"add","id":"alice:1","body":"x"}"#,
2225 ))
2226 .await;
2227 assert_eq!(res.status(), StatusCode::BAD_REQUEST);
2228 }
2229
2230 #[tokio::test]
2231 async fn an_update_without_an_id_is_refused() {
2232 let origin = origin_with(one_page()).await;
2233 let res = origin
2234 .handle(control_post(
2235 "/_control/annotations",
2236 Some(TEST_TOKEN),
2237 r#"{"doc":"docs/a.html","op":"update","body":"x"}"#,
2238 ))
2239 .await;
2240 assert_eq!(res.status(), StatusCode::BAD_REQUEST);
2241 }
2242
2243 #[tokio::test]
2244 async fn listing_annotations_needs_a_doc() {
2245 let origin = origin_with(one_page()).await;
2246 let res = origin
2247 .handle(loopback("/_control/annotations", Some(TEST_TOKEN)))
2248 .await;
2249 assert_eq!(res.status(), StatusCode::BAD_REQUEST);
2250 }
2251
2252 #[tokio::test]
2253 async fn an_unknown_alias_is_a_404() {
2254 let origin = origin_with(one_page()).await;
2255 let res = origin
2256 .handle(loopback(
2257 "/_control/annotations?doc=nope/a.html",
2258 Some(TEST_TOKEN),
2259 ))
2260 .await;
2261 assert_eq!(res.status(), StatusCode::NOT_FOUND);
2262 }
2263
2264 #[tokio::test]
2266 async fn a_traversal_in_the_doc_parameter_is_refused() {
2267 let origin = origin_with(one_page()).await;
2268 let res = origin
2269 .handle(control_post(
2270 "/_control/annotations",
2271 Some(TEST_TOKEN),
2272 r#"{"doc":"docs/../../etc/passwd","op":"add","body":"x"}"#,
2273 ))
2274 .await;
2275 assert_eq!(res.status(), StatusCode::FORBIDDEN);
2276 }
2277
2278 #[tokio::test]
2281 async fn writing_through_a_symlinked_directory_is_refused() {
2282 let origin = origin_with(
2283 FakeRemote::new()
2284 .dir("/srv", vec![("link", symlink_attrs())])
2285 .dir("/srv/link", vec![("inside.html", file_attrs(2, 1))])
2286 .file("/srv/link/inside.html", b"hi"),
2287 )
2288 .await;
2289
2290 let res = origin
2291 .handle(control_post(
2292 "/_control/annotations",
2293 Some(TEST_TOKEN),
2294 r#"{"doc":"docs/link/inside.html","op":"add","body":"x"}"#,
2295 ))
2296 .await;
2297 assert_eq!(res.status(), StatusCode::FORBIDDEN);
2298 }
2299
2300 #[tokio::test]
2302 async fn an_unannotated_document_lists_empty() {
2303 let origin = origin_with(one_page()).await;
2304 let res = origin
2305 .handle(loopback(
2306 "/_control/annotations?doc=docs/a.html",
2307 Some(TEST_TOKEN),
2308 ))
2309 .await;
2310 assert_eq!(res.status(), StatusCode::OK);
2311 let body = json_of(res).await;
2312 assert_eq!(body["annotations"].as_array().expect("array").len(), 0);
2313 }
2314}