pub mod guard;
pub mod mime;
pub mod pac;
pub mod range;
use std::collections::{HashMap, HashSet};
use std::net::SocketAddr;
use std::sync::atomic::Ordering;
use std::sync::{Arc, Mutex};
use tokio::sync::RwLock;
use anyhow::{Context, Result, bail, ensure};
use bytes::Bytes;
use http_body_util::Full;
use hyper::header::{
ACCEPT_RANGES, CACHE_CONTROL, CONTENT_RANGE, CONTENT_TYPE, ETAG, HOST, HeaderName,
IF_NONE_MATCH, IF_RANGE, LOCATION, RANGE,
};
use hyper::server::conn::http1;
use hyper::service::service_fn;
use hyper::{Method, Request, Response, StatusCode};
use hyper_util::rt::TokioIo;
use tokio::net::TcpListener;
use crate::cache::{self, Cache};
use crate::control::{self, Token};
use crate::fs::sftp::SftpFs;
use crate::fs::{Entry, RangeReq, RemoteFs};
use crate::prefetch;
use crate::reachable;
use crate::sftp::wire::Attrs;
use crate::ssh_config;
use crate::theme;
use crate::tls;
use rustls_pki_types::pem::PemObject;
const CACHE_WHOLE_MAX: u64 = 8 * 1024 * 1024;
struct Conditions {
if_none_match: Option<String>,
range: Option<String>,
if_range: Option<String>,
control_token: Option<String>,
fetch_site: Option<String>,
}
#[derive(Debug)]
pub struct Alias {
name: String,
host: String,
base: Option<String>,
}
impl Alias {
pub fn new(name: &str, host: &str, base: Option<&str>) -> Result<Self> {
ensure!(!host.is_empty(), "alias {name:?} has no ssh host");
ensure!(
guard::is_label(name),
"alias {name:?} must be lowercase letters, digits and hyphens, and may not start or end with a hyphen: it becomes a hostname label"
);
if let Some(base) = base {
ensure!(
is_base(base),
"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:?}"
);
}
Ok(Self {
name: name.to_string(),
host: host.to_string(),
base: base.map(str::to_string),
})
}
pub fn name(&self) -> &str {
&self.name
}
pub fn host(&self) -> &str {
&self.host
}
pub fn base(&self) -> Option<&str> {
self.base.as_deref()
}
}
#[derive(serde::Serialize)]
struct KnownHost {
alias: String,
host: String,
#[serde(flatten)]
settings: ssh_config::Settings,
served: bool,
enabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
unresolved: Option<String>,
}
#[derive(serde::Serialize)]
struct OpenAlias {
alias: String,
host: String,
base: String,
url: String,
trips: u64,
}
#[derive(serde::Serialize)]
struct KnownHosts {
open: Vec<OpenAlias>,
hosts: Vec<KnownHost>,
unusable: Vec<ssh_config::Unusable>,
#[serde(skip_serializing_if = "Option::is_none")]
tls: Option<control::Handshakes>,
}
fn is_base(base: &str) -> bool {
if base.starts_with('/') {
return true;
}
let Some(rest) = base.strip_prefix('~') else {
return false;
};
match rest {
"" => true,
rest => match rest.strip_prefix('/') {
Some(under) => {
!under.is_empty()
&& under
.split('/')
.all(|c| !c.is_empty() && c != "." && c != "..")
}
None => false,
},
}
}
async fn resolve_base(base: Option<&str>, fs: &SftpFs) -> Result<String> {
let under = match base {
None | Some("~") => "",
Some(b) => match b.strip_prefix("~/") {
Some(under) => under,
None => return Ok(b.to_string()),
},
};
let home = fs.home().await?;
let home = home.trim_end_matches('/');
let home = if home.is_empty() { "" } else { home };
Ok(match under {
"" if home.is_empty() => "/".to_string(),
"" => home.to_string(),
under => format!("{home}/{under}"),
})
}
impl Origin {
async fn session(&self, alias: &str) -> Option<Arc<Session>> {
self.sessions.read().await.get(alias).cloned()
}
fn site_url(&self, alias: &str) -> String {
format!("{}://{alias}.{}/", self.scheme, self.suffix)
}
async fn alias_names(&self) -> Vec<String> {
let mut names: Vec<String> = self.sessions.read().await.keys().cloned().collect();
names.sort();
names
}
async fn round_trips(&self) -> u64 {
self.sessions
.read()
.await
.values()
.map(|s| s.fs.round_trips())
.sum()
}
}
struct Session {
host: String,
base: String,
fs: SftpFs,
}
pub struct Origin {
suffix: String,
port: u16,
sessions: RwLock<HashMap<String, Arc<Session>>>,
cache: Cache,
token: Token,
theme: RwLock<String>,
scheme: String,
handshakes: Handshakes,
tls: Option<Arc<rustls::ServerConfig>>,
reachable: RwLock<reachable::Set>,
}
pub struct Bound {
origin: Arc<Origin>,
listener: TcpListener,
}
pub struct Startup {
routes: Vec<String>,
refused: Vec<String>,
trust: Option<String>,
}
impl Startup {
pub fn routes(&self) -> &[String] {
&self.routes
}
pub fn refused(&self) -> &[String] {
&self.refused
}
pub fn trust(&self) -> Option<&str> {
self.trust.as_deref()
}
}
impl Origin {
pub async fn bind(
aliases: Vec<Alias>,
hosts: reachable::Set,
suffix: String,
scheme: String,
port: u16,
token: Token,
theme: String,
) -> Result<(Bound, Startup)> {
let addr = SocketAddr::from(([127, 0, 0, 1], port));
let listener = TcpListener::bind(addr)
.await
.with_context(|| format!("bind {addr}"))?;
ensure!(
pac::is_suffix(&suffix),
"suffix {suffix:?} must be lowercase letters, digits, hyphens and dots"
);
theme::check(&theme)?;
let (tls, trust) = match scheme.as_str() {
"http" => (None, None),
"https" => {
let (config, advice) = serving_config(&suffix)?;
(Some(Arc::new(config)), Some(advice))
}
other => bail!("scheme {other:?} is not one this daemon serves; use http or https"),
};
let mut sessions = HashMap::new();
let mut routes = Vec::new();
for a in aliases {
let fs = SftpFs::connect(&a.host)
.await
.with_context(|| format!("alias {} -> ssh host {}", a.name, a.host))?;
let base = resolve_base(a.base.as_deref(), &fs)
.await
.with_context(|| {
format!(
"alias {} -> ssh host {}: working out where {} is",
a.name,
a.host,
a.base.as_deref().unwrap_or("the home directory")
)
})?;
routes.push(format!(
" {scheme}://{}.{suffix}/ -> {}:{base}",
a.name, a.host
));
ensure!(
sessions
.insert(
a.name.clone(),
Arc::new(Session {
host: a.host.clone(),
base,
fs,
}),
)
.is_none(),
"alias {:?} is defined twice",
a.name
);
}
let origin = Arc::new(Self {
suffix,
scheme,
tls,
port,
sessions: RwLock::new(sessions),
cache: Cache::default(),
token,
theme: RwLock::new(theme),
reachable: RwLock::new(hosts),
handshakes: Handshakes::default(),
});
let (opened, refused) = origin.open_enabled().await;
routes.extend(opened);
Ok((
Bound { origin, listener },
Startup {
routes,
refused,
trust,
},
))
}
}
impl Bound {
pub async fn serve(self) -> Result<()> {
let Bound { origin, listener } = self;
let self_ = origin;
loop {
let (stream, _) = listener.accept().await?;
let me = Arc::clone(&self_);
tokio::spawn(async move {
let outer = Arc::clone(&me);
let service = service_fn(move |req: Request<hyper::body::Incoming>| {
let me = Arc::clone(&outer);
async move {
if req.method() == Method::CONNECT {
return Ok::<_, std::convert::Infallible>(me.tunnel(req));
}
Ok(me.handle(req).await)
}
});
let _ = http1::Builder::new()
.serve_connection(TokioIo::new(stream), service)
.with_upgrades()
.await;
});
}
}
}
impl Origin {
fn tunnel(self: &Arc<Self>, mut req: Request<hyper::body::Incoming>) -> Response<Full<Bytes>> {
let Some(authority) = req.uri().authority().map(ToString::to_string) else {
return fail(StatusCode::BAD_REQUEST, "CONNECT carries no authority");
};
let (name, port) = match authority.rsplit_once(':') {
Some((name, port)) => (name, port),
None => (authority.as_str(), "443"),
};
if port != "443" {
return fail(
StatusCode::FORBIDDEN,
format!("CONNECT to port {port} is refused; only 443 is tunnelled"),
);
}
if guard::classify(name, "/", &self.suffix, self.port).is_err() {
return fail(
StatusCode::FORBIDDEN,
format!("{name:?} is not a name this daemon serves"),
);
}
let Some(config) = self.tls.clone() else {
return fail(
StatusCode::NOT_IMPLEMENTED,
concat!(
"this daemon serves http; CONNECT needs the https mode and a certificate. ",
"Set scheme = \"https\" and see `ssh-browser trust`.",
),
);
};
let me = Arc::clone(self);
let upgrade = hyper::upgrade::on(&mut req);
tokio::spawn(async move {
let Ok(upgraded) = upgrade.await else {
return;
};
let acceptor = tokio_rustls::TlsAcceptor::from(config);
let tls = match acceptor.accept(TokioIo::new(upgraded)).await {
Ok(tls) => {
me.handshakes.completed.fetch_add(1, Ordering::Relaxed);
tls
}
Err(e) => {
if me.handshakes.failed.fetch_add(1, Ordering::Relaxed) == 0 {
eprintln!();
eprintln!("a browser refused the certificate: {e}");
eprintln!(" almost always the local authority is not trusted yet.");
eprintln!(" `ssh-browser trust` prints how to trust it.");
}
return;
}
};
let service = service_fn(move |req: Request<hyper::body::Incoming>| {
let me = Arc::clone(&me);
async move { Ok::<_, std::convert::Infallible>(me.handle(req).await) }
});
let _ = http1::Builder::new()
.serve_connection(TokioIo::new(tls), service)
.await;
});
Response::builder()
.status(StatusCode::OK)
.body(Full::new(Bytes::new()))
.unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR, "building the tunnel"))
}
}
impl Origin {
pub async fn handle<B>(&self, req: Request<B>) -> Response<Full<Bytes>>
where
B: hyper::body::Body,
B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
{
let Some(host) = host_of(&req) else {
return fail(StatusCode::BAD_REQUEST, "request carries no Host");
};
let path = req.uri().path().to_string();
let cond = Conditions {
if_none_match: header(&req, IF_NONE_MATCH),
range: header(&req, RANGE),
if_range: header(&req, IF_RANGE),
control_token: req
.headers()
.get(control::TOKEN_HEADER)
.and_then(|v| v.to_str().ok())
.map(str::to_string),
fetch_site: req
.headers()
.get(control::FETCH_SITE_HEADER)
.and_then(|v| v.to_str().ok())
.map(str::to_string),
};
let method = req.method().clone();
let query = req.uri().query().map(str::to_string);
let control_body = if path.starts_with(control::PATH_PREFIX) {
match read_body(req.into_body()).await {
Ok(b) => b,
Err(e) => return fail(StatusCode::BAD_REQUEST, e),
}
} else {
Bytes::new()
};
match guard::classify(&host, &path, &self.suffix, self.port) {
Err(e) => fail(StatusCode::FORBIDDEN, format!("{e:#}")),
Ok(guard::Target::Direct { path }) => {
self.direct(&method, path, &cond, query.as_deref(), &control_body)
.await
}
Ok(guard::Target::Alias { alias, path }) => {
self.alias(&method, alias, path, &cond, query.as_deref())
.await
}
}
}
async fn direct(
&self,
method: &Method,
path: &str,
cond: &Conditions,
query: Option<&str>,
body: &[u8],
) -> Response<Full<Bytes>> {
if path.starts_with(control::PATH_PREFIX) {
if control::from_a_page(cond.fetch_site.as_deref()) {
return control::text(
StatusCode::FORBIDDEN,
"the control API is not reachable from a page",
);
}
if method == Method::GET && control::route_of(path) == "token" {
return control::text(StatusCode::OK, self.token.as_str());
}
if let Some(refusal) = control::gate(
method,
cond.fetch_site.as_deref(),
cond.control_token.as_deref(),
&self.token,
) {
return refusal;
}
return self.control(method, path, body).await;
}
if path == "/proxy.pac" {
return match pac::script(&self.suffix, self.port) {
Ok(body) => plain_ok("application/x-ns-proxy-autoconfig", Bytes::from(body)),
Err(e) => fail(StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")),
};
}
let rest = path.trim_start_matches('/');
if rest.is_empty() {
return plain_ok(
"text/html; charset=utf-8",
Bytes::from(self.alias_index().await),
);
}
let (alias, sub) = rest.split_once('/').unwrap_or((rest, ""));
self.alias(method, alias, &format!("/{sub}"), cond, query)
.await
}
async fn alias(
&self,
method: &Method,
alias: &str,
path: &str,
cond: &Conditions,
query: Option<&str>,
) -> Response<Full<Bytes>> {
if !matches!(*method, Method::GET | Method::HEAD) {
return fail(
StatusCode::METHOD_NOT_ALLOWED,
format!("{method} is not allowed: this origin is read-only"),
);
}
let Some(session) = self.session(alias).await else {
return fail(StatusCode::NOT_FOUND, format!("no alias named {alias:?}"));
};
let session = session.as_ref();
let resolved = match guard::resolve(&session.base, path) {
Ok(p) => p,
Err(e) => return fail(StatusCode::FORBIDDEN, format!("{e:#}")),
};
let wants_dir = path.ends_with('/');
let file = if wants_dir {
format!("{resolved}/index.html")
} else {
resolved.clone()
};
let chain = components(&session.base, &file);
if chain.is_empty() {
return self
.autoindex_of(session, alias, path, &resolved, query)
.await;
}
let last = chain.len() - 1;
if let Some((_, name)) = chain.iter().find(|(_, n)| hidden(n)) {
return fail(
StatusCode::FORBIDDEN,
format!("refusing {name}: names beginning with a dot are not served"),
);
}
let held = match self.listings_along(session, &chain).await {
Ok(held) => held,
Err((at, why)) => {
return fail(
StatusCode::BAD_GATEWAY,
format!("{path}: listing {at} failed: {why}"),
);
}
};
if let Some(at) = first_symlink(&held, &chain) {
return fail(
StatusCode::FORBIDDEN,
format!("refusing symlink at {at} (its target is not checked)"),
);
}
let mut found_last = None;
for (i, (dir, name)) in chain.iter().enumerate() {
let Some(attrs) = attrs_in(&held, dir, name) else {
if i == last && wants_dir {
return self
.autoindex_of(session, alias, path, &resolved, query)
.await;
}
return fail(StatusCode::NOT_FOUND, format!("not found: {path}"));
};
if i < last && !attrs.is_dir() {
return fail(
StatusCode::NOT_FOUND,
format!("{path}: {dir}/{name} is not a directory"),
);
}
if i == last {
found_last = Some(attrs);
}
}
let attrs = found_last.expect("the walk assigns on its final iteration");
if attrs.is_dir() {
if wants_dir {
return self
.autoindex_of(session, alias, path, &resolved, query)
.await;
}
return redirect(&format!("{path}/"));
}
let tag = cache::etag(&attrs);
if let (Some(tag), Some(header)) = (tag.as_deref(), cond.if_none_match.as_deref())
&& cache::etag_matches(header, tag)
{
return not_modified(tag);
}
let size = attrs.size.unwrap_or(0);
let wanted = match cond.range.as_deref() {
Some(header) => range::resolve(header, cond.if_range.as_deref(), size),
None => range::Resolved::Whole,
};
if wanted == range::Resolved::Unsatisfiable {
return unsatisfiable(size);
}
if let Some(body) = self.cache.body(&file, &attrs) {
return respond(&file, body, tag.as_deref(), &wanted, size);
}
if let range::Resolved::Part { start, end } = wanted
&& size > CACHE_WHOLE_MAX
{
let req = RangeReq {
path: file.clone(),
offset: start,
len: end - start + 1,
};
let mut got = session.fs.read_ranges(std::slice::from_ref(&req)).await;
return match got.pop() {
Some(Ok(body)) => partial(
mime::guess(&file),
Bytes::from(body),
tag.as_deref(),
start,
end,
size,
),
Some(Err(e)) => {
self.cache.forget_listing(&chain[last].0);
fail(StatusCode::NOT_FOUND, format!("{path}: {e:#}"))
}
None => fail(
StatusCode::INTERNAL_SERVER_ERROR,
"read_ranges returned no result",
),
};
}
let mut got = match size {
0 => session.fs.read_batch(std::slice::from_ref(&file)).await,
size => {
let req = RangeReq {
path: file.clone(),
offset: 0,
len: size,
};
let mut ranged = session.fs.read_ranges(std::slice::from_ref(&req)).await;
match ranged.pop() {
Some(Ok(body)) if body.len() as u64 == size => vec![Ok(body)],
_ => session.fs.read_batch(std::slice::from_ref(&file)).await,
}
}
};
match got.pop() {
Some(Ok(body)) => {
let body = Bytes::from(body);
self.cache.put_body(&file, &attrs, body.clone());
if mime::guess(&file).starts_with("text/html") {
self.warm_subresources(session, path, &body).await;
}
respond(&file, body, tag.as_deref(), &wanted, size)
}
Some(Err(e)) => {
self.cache.forget_listing(&chain[last].0);
fail(StatusCode::NOT_FOUND, format!("{path}: {e:#}"))
}
None => fail(
StatusCode::INTERNAL_SERVER_ERROR,
"read_batch returned no result",
),
}
}
async fn control(&self, method: &Method, path: &str, body: &[u8]) -> Response<Full<Bytes>> {
match (method, control::route_of(path)) {
(&Method::GET, "hello") => {
let aliases = self.alias_names().await;
control::hello(
&aliases,
&self.suffix,
&self.scheme,
self.round_trips().await,
self.tls.is_some().then(|| control::Handshakes {
completed: self.handshakes.completed.load(Ordering::Relaxed),
failed: self.handshakes.failed.load(Ordering::Relaxed),
}),
)
}
(&Method::GET, "hosts") => self.list_hosts().await,
(&Method::POST, "open") => self.open_host(body).await,
(&Method::POST, "close") => self.close_alias(body).await,
(&Method::POST, "enabled") => self.set_enabled(body).await,
(&Method::GET, "theme") => self.show_theme().await,
(&Method::POST, "theme") => self.set_theme(body).await,
(&Method::GET, route) => {
control::text(StatusCode::NOT_FOUND, format!("no control route {route:?}"))
}
(_, route) => control::text(
StatusCode::METHOD_NOT_ALLOWED,
format!("{method} is not allowed on {route:?}"),
),
}
}
async fn list_hosts(&self) -> Response<Full<Bytes>> {
let found = match ssh_config::read() {
Ok(found) => found,
Err(e) => {
return control::text(
StatusCode::INTERNAL_SERVER_ERROR,
format!("reading ssh_config: {e:#}"),
);
}
};
let described: Vec<_> = found
.hosts
.iter()
.map(|h| {
let host = h.host.clone();
tokio::spawn(async move { ssh_config::describe(&host).await })
})
.collect();
let open = {
let sessions = self.sessions.read().await;
let mut open: Vec<OpenAlias> = sessions
.iter()
.map(|(alias, s)| OpenAlias {
alias: alias.clone(),
host: s.host.clone(),
base: s.base.clone(),
url: self.site_url(alias),
trips: s.fs.round_trips(),
})
.collect();
open.sort_by(|a, b| a.alias.cmp(&b.alias));
open
};
let enabled: Vec<String> = self
.reachable
.read()
.await
.enabled()
.map(|h| h.name.clone())
.collect();
let mut hosts = Vec::with_capacity(found.hosts.len());
for (h, task) in found.hosts.iter().zip(described) {
let (settings, unresolved) = match task.await {
Ok(Ok(settings)) => (settings, None),
Ok(Err(e)) => (ssh_config::Settings::default(), Some(format!("{e:#}"))),
Err(e) => (ssh_config::Settings::default(), Some(e.to_string())),
};
hosts.push(KnownHost {
alias: h.alias.clone(),
host: h.host.clone(),
settings,
served: open.iter().any(|o| o.alias == h.alias),
enabled: enabled.iter().any(|name| name == &h.alias),
unresolved,
});
}
control::json(&KnownHosts {
open,
hosts,
unusable: found.unusable,
tls: self.tls.is_some().then(|| control::Handshakes {
completed: self.handshakes.completed.load(Ordering::Relaxed),
failed: self.handshakes.failed.load(Ordering::Relaxed),
}),
})
}
async fn open_host(&self, body: &[u8]) -> Response<Full<Bytes>> {
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Ask {
host: String,
#[serde(default)]
base: Option<String>,
}
let ask: Ask = match serde_json::from_slice(body) {
Ok(ask) => ask,
Err(e) => {
return control::text(
StatusCode::BAD_REQUEST,
format!("open needs a JSON body naming a host: {e}"),
);
}
};
let found = match ssh_config::read() {
Ok(found) => found,
Err(e) => {
return control::text(
StatusCode::INTERNAL_SERVER_ERROR,
format!("reading ssh_config: {e:#}"),
);
}
};
let Some(known) = found
.hosts
.iter()
.find(|h| h.host.eq_ignore_ascii_case(&ask.host) || h.alias == ask.host)
else {
return control::text(
StatusCode::NOT_FOUND,
format!("{:?} is not a host in your ssh_config", ask.host),
);
};
let alias = match Alias::new(&known.alias, &known.host, ask.base.as_deref()) {
Ok(alias) => alias,
Err(e) => return control::text(StatusCode::BAD_REQUEST, format!("{e:#}")),
};
if let Some(open) = self.session(&known.alias).await {
let Some(asked) = alias.base() else {
return self.opened(&known.alias, &known.host, &open.base);
};
let wanted = match resolve_base(Some(asked), &open.fs).await {
Ok(base) => base,
Err(e) => {
return control::text(
StatusCode::BAD_GATEWAY,
format!("working out where to root {}: {e:#}", known.alias),
);
}
};
if wanted != open.base {
return control::text(
StatusCode::CONFLICT,
format!(
"{} 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",
known.alias, open.base, wanted
),
);
}
return self.opened(&known.alias, &known.host, &open.base);
}
let fs = match SftpFs::connect(&known.host).await {
Ok(fs) => fs,
Err(e) => {
return control::text(
StatusCode::BAD_GATEWAY,
format!("ssh to {}: {e:#}", known.host),
);
}
};
let base = match resolve_base(alias.base(), &fs).await {
Ok(base) => base,
Err(e) => {
return control::text(
StatusCode::BAD_GATEWAY,
format!("working out where to root {}: {e:#}", known.alias),
);
}
};
let session = {
let mut sessions = self.sessions.write().await;
Arc::clone(sessions.entry(known.alias.clone()).or_insert_with(|| {
Arc::new(Session {
host: known.host.clone(),
base,
fs,
})
}))
};
self.opened(&known.alias, &known.host, &session.base)
}
async fn close_alias(&self, body: &[u8]) -> Response<Full<Bytes>> {
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Ask {
alias: String,
}
let ask: Ask = match serde_json::from_slice(body) {
Ok(ask) => ask,
Err(e) => {
return control::text(
StatusCode::BAD_REQUEST,
format!("close needs a JSON body naming an alias: {e}"),
);
}
};
let gone = self.sessions.write().await.remove(&ask.alias);
match gone {
Some(session) => {
#[derive(serde::Serialize)]
struct Closed<'a> {
alias: &'a str,
host: &'a str,
base: &'a str,
}
control::json(&Closed {
alias: &ask.alias,
host: &session.host,
base: &session.base,
})
}
None => control::text(
StatusCode::NOT_FOUND,
format!("no alias named {:?} is open", ask.alias),
),
}
}
async fn dial(alias: String, host: String, base: Option<String>) -> Result<Session> {
let fs = SftpFs::connect(&host)
.await
.with_context(|| format!("ssh to {host}"))?;
let resolved = resolve_base(base.as_deref(), &fs)
.await
.with_context(|| format!("working out where to root {alias}"))?;
Ok(Session {
host,
base: resolved,
fs,
})
}
async fn adopt(&self, alias: &str, session: Session) -> Arc<Session> {
let mut sessions = self.sessions.write().await;
Arc::clone(
sessions
.entry(alias.to_string())
.or_insert_with(|| Arc::new(session)),
)
}
async fn connect(&self, alias: &str, host: &str, base: Option<&str>) -> Result<Arc<Session>> {
let session = Self::dial(
alias.to_string(),
host.to_string(),
base.map(str::to_string),
)
.await?;
Ok(self.adopt(alias, session).await)
}
async fn open_enabled(&self) -> (Vec<String>, Vec<String>) {
let wanted: Vec<reachable::Host> = self.reachable.read().await.enabled().cloned().collect();
if wanted.is_empty() {
return (Vec::new(), Vec::new());
}
let known = match ssh_config::read() {
Ok(found) => found.hosts,
Err(e) => {
let mut refused: Vec<String> = wanted
.iter()
.map(|h| {
format!(
" {} is enabled but ssh_config could not be read: {e:#}",
h.name
)
})
.collect();
refused.sort();
return (Vec::new(), refused);
}
};
let mut dialling = tokio::task::JoinSet::new();
let mut refused = Vec::new();
for host in wanted {
let Some(entry) = entry_for(&known, &host.name) else {
refused.push(format!(
" {} is enabled but is no longer a host in your ssh_config",
host.name
));
continue;
};
let (label, target) = (entry.alias.clone(), entry.host.clone());
dialling.spawn(async move {
let got = Self::dial(label.clone(), target, host.base.clone()).await;
(label, got)
});
}
let mut opened = Vec::new();
while let Some(finished) = dialling.join_next().await {
let (name, got) = match finished {
Ok(pair) => pair,
Err(e) => {
refused.push(format!(" an enabled host could not be opened: {e}"));
continue;
}
};
match got {
Ok(session) => {
let base = session.base.clone();
self.adopt(&name, session).await;
opened.push(format!(
" {}://{name}.{}/ -> {name}:{base}",
self.scheme, self.suffix
));
}
Err(e) => refused.push(format!(" {name} is enabled but did not answer: {e:#}")),
}
}
opened.sort();
refused.sort();
(opened, refused)
}
fn opened(&self, alias: &str, host: &str, base: &str) -> Response<Full<Bytes>> {
#[derive(serde::Serialize)]
struct Opened<'a> {
alias: &'a str,
host: &'a str,
base: &'a str,
url: String,
}
control::json(&Opened {
alias,
host,
base,
url: self.site_url(alias),
})
}
async fn set_enabled(&self, body: &[u8]) -> Response<Full<Bytes>> {
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Ask {
host: String,
enabled: bool,
#[serde(default)]
base: Option<String>,
}
let ask: Ask = match serde_json::from_slice(body) {
Ok(ask) => ask,
Err(e) => {
return control::text(
StatusCode::BAD_REQUEST,
format!("enabled needs a JSON body naming a host and whether it is on: {e}"),
);
}
};
let found = match ssh_config::read() {
Ok(found) => found,
Err(e) => {
return control::text(
StatusCode::INTERNAL_SERVER_ERROR,
format!("reading ssh_config: {e:#}"),
);
}
};
let Some(known) = found
.hosts
.iter()
.find(|h| h.host.eq_ignore_ascii_case(&ask.host) || h.alias == ask.host)
else {
return control::text(
StatusCode::NOT_FOUND,
format!("{:?} is not a host in your ssh_config", ask.host),
);
};
if let Err(e) = Alias::new(&known.alias, &known.host, ask.base.as_deref()) {
return control::text(StatusCode::BAD_REQUEST, format!("{e:#}"));
}
if ask.enabled {
if self.session(&known.alias).await.is_none()
&& let Err(e) = self
.connect(&known.alias, &known.host, ask.base.as_deref())
.await
{
return control::text(StatusCode::BAD_GATEWAY, format!("{e:#}"));
}
} else {
self.sessions.write().await.remove(&known.alias);
}
let remembered = {
let mut set = self.reachable.write().await;
set.set(&known.alias, ask.enabled, ask.base.clone());
reachable::remember(&set).is_ok()
};
#[derive(serde::Serialize)]
struct Switched<'a> {
host: &'a str,
enabled: bool,
remembered: bool,
url: Option<String>,
}
control::json(&Switched {
host: &known.alias,
enabled: ask.enabled,
remembered,
url: ask.enabled.then(|| self.site_url(&known.alias)),
})
}
async fn show_theme(&self) -> Response<Full<Bytes>> {
#[derive(serde::Serialize)]
struct Choice<'a> {
name: &'a str,
label: &'a str,
variant: &'a str,
}
#[derive(serde::Serialize)]
struct Themes<'a> {
current: &'a str,
themes: Vec<Choice<'a>>,
}
control::json(&Themes {
current: &self.theme.read().await,
themes: theme::all()
.iter()
.map(|t| Choice {
name: &t.name,
label: &t.label,
variant: t.variant,
})
.collect(),
})
}
async fn set_theme(&self, body: &[u8]) -> Response<Full<Bytes>> {
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Ask {
name: String,
}
let ask: Ask = match serde_json::from_slice(body) {
Ok(ask) => ask,
Err(e) => {
return control::text(
StatusCode::BAD_REQUEST,
format!("theme needs a JSON body naming one: {e}"),
);
}
};
if let Err(e) = theme::check(&ask.name) {
return control::text(StatusCode::BAD_REQUEST, format!("{e:#}"));
}
*self.theme.write().await = ask.name.clone();
let remembered = theme::remember(&ask.name).is_ok();
#[derive(serde::Serialize)]
struct Chose<'a> {
current: &'a str,
remembered: bool,
}
control::json(&Chose {
current: &ask.name,
remembered,
})
}
async fn autoindex_of(
&self,
session: &Session,
alias: &str,
path: &str,
resolved: &str,
query: Option<&str>,
) -> Response<Full<Bytes>> {
let rel = resolved
.strip_prefix(&session.base)
.unwrap_or("")
.to_string();
let entries = match self.listing_of(session, resolved).await {
Ok(entries) => entries,
Err(e) => return fail(StatusCode::NOT_FOUND, format!("{path}: {e:#}")),
};
let sites = self.sites_among(session, resolved, &entries).await;
if query == Some("ls") {
let mut out = String::new();
render_level(&mut out, &rel, &rows_of(&entries, &sites), &[]);
return plain_ok("text/html; charset=utf-8", Bytes::from(out));
}
let mut levels = Vec::new();
let mut at = session.base.clone();
for part in rel.split('/').filter(|p| !p.is_empty()) {
if let Some(entries) = self.cache.listing_entries(&at) {
let here = at.strip_prefix(&session.base).unwrap_or("").to_string();
levels.push((here, rows_of(&entries, &HashSet::new())));
}
at.push('/');
at.push_str(part);
}
levels.push((rel.clone(), rows_of(&entries, &sites)));
plain_ok(
"text/html; charset=utf-8",
Bytes::from(autoindex(alias, &rel, &levels, &self.theme.read().await)),
)
}
async fn listing_of(&self, session: &Session, dir: &str) -> Result<Vec<Entry>> {
if let Some(entries) = self.cache.listing_entries(dir) {
return Ok(entries);
}
let entries = session.fs.list_dir(dir).await?;
self.cache.put_listing(dir, &entries);
Ok(entries)
}
async fn sites_among(
&self,
session: &Session,
dir: &str,
entries: &[Entry],
) -> HashSet<String> {
const MAX_SCAN: usize = 64;
let names: Vec<&str> = entries
.iter()
.filter(|e| e.attrs.is_dir() && e.name != "." && e.name != ".." && !hidden(&e.name))
.map(|e| e.name.as_str())
.take(MAX_SCAN)
.collect();
if names.is_empty() {
return HashSet::new();
}
let paths: Vec<String> = names.iter().map(|n| format!("{dir}/{n}")).collect();
let missing: Vec<String> = paths
.iter()
.filter(|p| self.cache.listing_entries(p).is_none())
.cloned()
.collect();
if !missing.is_empty() {
for (path, got) in missing.iter().zip(session.fs.list_dirs(&missing).await) {
if let Ok(entries) = got {
self.cache.put_listing(path, &entries);
}
}
}
names
.iter()
.zip(paths.iter())
.filter(|(_, path)| {
self.cache.listing_entries(path).is_some_and(|listing| {
listing
.iter()
.any(|e| e.name == "index.html" && !e.attrs.is_dir())
})
})
.map(|(name, _)| (*name).to_string())
.collect()
}
async fn warm_subresources(&self, session: &Session, doc_path: &str, html: &[u8]) {
let refs = prefetch::scan(html, prefetch::MAX_SUBRESOURCES);
if refs.is_empty() {
return;
}
let dir_of_doc = match doc_path.rsplit_once('/') {
Some((head, _)) => head,
None => "",
};
let mut wanted: Vec<(String, Vec<(String, String)>)> = Vec::new();
for r in &refs {
let url = if r.starts_with('/') {
r.clone()
} else {
format!("{dir_of_doc}/{r}")
};
let Ok(resolved) = guard::resolve(&session.base, &url) else {
continue;
};
let chain = components(&session.base, &resolved);
if chain.is_empty() {
continue;
}
if chain.iter().any(|(_, n)| hidden(n)) {
continue;
}
if self.first_symlink_cached(&chain).is_some() {
continue;
}
if !chain
.iter()
.all(|(dir, _)| self.listable(&session.base, dir))
{
continue;
}
wanted.push((resolved, chain));
}
let all: Vec<(String, String)> = wanted.iter().flat_map(|(_, c)| c.clone()).collect();
let held = self.held_listings(session, &all).await;
let mut to_read = Vec::new();
for (resolved, chain) in &wanted {
if first_symlink(&held, chain).is_some() {
continue;
}
let (dir, name) = &chain[chain.len() - 1];
let Some(attrs) = attrs_in(&held, dir, name) else {
continue;
};
if attrs.is_dir() {
continue;
}
let Some(size) = attrs.size else {
continue;
};
if size == 0 || size > CACHE_WHOLE_MAX {
continue;
}
if self.cache.body(resolved, &attrs).is_some() {
continue;
}
to_read.push((resolved.clone(), attrs, size));
}
if to_read.is_empty() {
return;
}
let reqs: Vec<RangeReq> = to_read
.iter()
.map(|(path, _, size)| RangeReq {
path: path.clone(),
offset: 0,
len: *size,
})
.collect();
for ((path, attrs, size), got) in to_read.iter().zip(session.fs.read_ranges(&reqs).await) {
let Ok(body) = got else {
continue;
};
if body.len() as u64 != *size {
continue;
}
self.cache.put_body(path, attrs, Bytes::from(body));
}
}
async fn listings_along(
&self,
session: &Session,
chain: &[(String, String)],
) -> Result<HashMap<String, Vec<Entry>>, (String, String)> {
let mut held: HashMap<String, Vec<Entry>> = HashMap::new();
let mut missing: Vec<String> = Vec::new();
for (dir, _) in chain {
if held.contains_key(dir) {
continue;
}
match self.cache.listing_entries(dir) {
Some(entries) => {
held.insert(dir.clone(), entries);
}
None if !missing.contains(dir) => missing.push(dir.clone()),
None => {}
}
}
if missing.is_empty() {
return Ok(held);
}
for (dir, result) in missing.iter().zip(session.fs.list_dirs(&missing).await) {
match result {
Ok(entries) => {
self.cache.put_listing(dir, &entries);
held.insert(dir.clone(), entries);
}
Err(e) if crate::fs::is_absent(&e) => {}
Err(e) => return Err((dir.clone(), format!("{e:#}"))),
}
}
Ok(held)
}
fn listable(&self, base: &str, dir: &str) -> bool {
if dir.trim_end_matches('/') == base.trim_end_matches('/') {
return true;
}
components(base, dir).iter().all(|(parent, name)| {
self.cache
.attrs_of(parent, name)
.is_some_and(|a| a.is_dir() && !a.is_symlink())
})
}
async fn held_listings(
&self,
session: &Session,
chain: &[(String, String)],
) -> HashMap<String, Vec<Entry>> {
self.listings_along(session, chain)
.await
.unwrap_or_default()
}
}
impl Origin {
fn first_symlink_cached(&self, chain: &[(String, String)]) -> Option<String> {
chain.iter().find_map(|(dir, name)| {
self.cache
.attrs_of(dir, name)
.filter(Attrs::is_symlink)
.map(|_| format!("{dir}/{name}"))
})
}
}
fn attrs_in(held: &HashMap<String, Vec<Entry>>, dir: &str, name: &str) -> Option<Attrs> {
held.get(dir)
.and_then(|entries| entries.iter().find(|e| e.name == name))
.map(|e| e.attrs)
}
fn first_symlink(held: &HashMap<String, Vec<Entry>>, chain: &[(String, String)]) -> Option<String> {
chain.iter().find_map(|(dir, name)| {
attrs_in(held, dir, name)
.filter(Attrs::is_symlink)
.map(|_| format!("{dir}/{name}"))
})
}
impl Origin {
async fn alias_index(&self) -> String {
let names = self.alias_names().await;
let mut s = String::from(
"<!doctype html><html><head><meta charset=\"utf-8\"><title>ssh-browser</title></head><body><h1>ssh-browser</h1><ul>",
);
for name in names {
let href = self.site_url(&name);
s.push_str("<li><a href=\"");
s.push_str(&escape(&href));
s.push_str("\">");
s.push_str(&escape(&href));
s.push_str("</a></li>");
}
s.push_str("</ul></body></html>");
s
}
}
fn components(base: &str, file: &str) -> Vec<(String, String)> {
let base = base.trim_end_matches('/');
let relative = file
.strip_prefix(base)
.unwrap_or("")
.trim_start_matches('/');
let mut out = Vec::new();
let mut dir = base.to_string();
for name in relative.split('/').filter(|s| !s.is_empty()) {
out.push((dir.clone(), name.to_string()));
dir = format!("{dir}/{name}");
}
out
}
const MAX_CONTROL_BODY: usize = 256 * 1024;
async fn read_body<B>(body: B) -> Result<Bytes, String>
where
B: hyper::body::Body,
B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
{
use http_body_util::{BodyExt, Limited};
Limited::new(body, MAX_CONTROL_BODY)
.collect()
.await
.map(|collected| collected.to_bytes())
.map_err(|e| format!("reading the request body: {e}"))
}
fn header<B>(req: &Request<B>, name: HeaderName) -> Option<String> {
req.headers()
.get(name)
.and_then(|v| v.to_str().ok())
.map(str::to_string)
}
fn respond(
file: &str,
body: Bytes,
tag: Option<&str>,
wanted: &range::Resolved,
size: u64,
) -> Response<Full<Bytes>> {
match wanted {
range::Resolved::Part { start, end } => {
let lo = usize::try_from(*start)
.unwrap_or(usize::MAX)
.min(body.len());
let hi = usize::try_from(end.saturating_add(1))
.unwrap_or(usize::MAX)
.min(body.len())
.max(lo);
partial(
mime::guess(file),
body.slice(lo..hi),
tag,
*start,
*end,
size,
)
}
_ => served(mime::guess(file), body, tag),
}
}
fn partial(
content_type: &str,
body: Bytes,
tag: Option<&str>,
start: u64,
end: u64,
size: u64,
) -> Response<Full<Bytes>> {
let mut b = Response::builder()
.status(StatusCode::PARTIAL_CONTENT)
.header(CONTENT_TYPE, content_type)
.header(CACHE_CONTROL, "no-cache")
.header(ACCEPT_RANGES, "bytes")
.header(CONTENT_RANGE, format!("bytes {start}-{end}/{size}"));
if let Some(tag) = tag {
b = b.header(ETAG, tag);
}
b.body(Full::new(body))
.unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR, "malformed 206"))
}
fn unsatisfiable(size: u64) -> Response<Full<Bytes>> {
Response::builder()
.status(StatusCode::RANGE_NOT_SATISFIABLE)
.header(CONTENT_TYPE, "text/plain; charset=utf-8")
.header(CONTENT_RANGE, format!("bytes */{size}"))
.body(Full::new(Bytes::from_static(b"range not satisfiable")))
.unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR, "malformed 416"))
}
fn host_of<B>(req: &Request<B>) -> Option<String> {
req.headers()
.get(HOST)
.and_then(|v| v.to_str().ok())
.map(str::to_string)
.or_else(|| req.uri().host().map(str::to_string))
}
fn served(content_type: &str, body: Bytes, tag: Option<&str>) -> Response<Full<Bytes>> {
let mut b = Response::builder()
.status(StatusCode::OK)
.header(CONTENT_TYPE, content_type)
.header(CACHE_CONTROL, "no-cache")
.header(ACCEPT_RANGES, "bytes");
if let Some(tag) = tag {
b = b.header(ETAG, tag);
}
b.body(Full::new(body))
.unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR, "malformed response"))
}
fn plain_ok(content_type: &str, body: Bytes) -> Response<Full<Bytes>> {
served(content_type, body, None)
}
fn not_modified(tag: &str) -> Response<Full<Bytes>> {
Response::builder()
.status(StatusCode::NOT_MODIFIED)
.header(ETAG, tag)
.header(CACHE_CONTROL, "no-cache")
.body(Full::new(Bytes::new()))
.unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR, "malformed 304"))
}
fn fail(status: StatusCode, detail: impl Into<String>) -> Response<Full<Bytes>> {
Response::builder()
.status(status)
.header(CONTENT_TYPE, "text/plain; charset=utf-8")
.body(Full::new(Bytes::from(detail.into())))
.expect("a plain-text body with static headers always builds")
}
fn redirect(to: &str) -> Response<Full<Bytes>> {
Response::builder()
.status(StatusCode::MOVED_PERMANENTLY)
.header(LOCATION, to)
.body(Full::new(Bytes::new()))
.unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR, "bad redirect target"))
}
fn entry_for<'a>(known: &'a [ssh_config::Host], name: &str) -> Option<&'a ssh_config::Host> {
known
.iter()
.find(|h| h.alias == name || h.host.eq_ignore_ascii_case(name))
}
#[derive(Debug, Default)]
struct Handshakes {
completed: std::sync::atomic::AtomicU64,
failed: std::sync::atomic::AtomicU64,
}
fn serving_config(suffix: &str) -> Result<(rustls::ServerConfig, String)> {
let (authority, found) = tls::load_or_create_reporting(suffix)?;
let path = tls::certificate_path()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "the state directory".to_string());
let lines: Vec<String> = match found {
tls::Found::Created => vec![
"this run made a local certificate authority, and nothing trusts it yet.".to_string(),
"Until it is, the browser will refuse every page here:".to_string(),
String::new(),
" ssh-browser trust".to_string(),
String::new(),
"prints the command for your platform, and the one that undoes it.".to_string(),
format!("The certificate is {path}"),
],
tls::Found::Existing => vec![
"https: if the browser refuses a page, the local authority is not trusted.".to_string(),
"`ssh-browser trust` prints how.".to_string(),
format!("The certificate is {path}"),
],
};
let advice = lines.join("\n");
let mut config = rustls::ServerConfig::builder()
.with_no_client_auth()
.with_cert_resolver(Arc::new(PerName {
authority,
suffix: suffix.to_string(),
minted: Mutex::new(HashMap::new()),
}));
config.alpn_protocols = vec![b"http/1.1".to_vec()];
Ok((config, advice))
}
struct PerName {
authority: tls::Authority,
suffix: String,
minted: Mutex<HashMap<String, Arc<rustls::sign::CertifiedKey>>>,
}
impl std::fmt::Debug for PerName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PerName")
.field("suffix", &self.suffix)
.finish()
}
}
impl rustls::server::ResolvesServerCert for PerName {
fn resolve(
&self,
hello: rustls::server::ClientHello<'_>,
) -> Option<Arc<rustls::sign::CertifiedKey>> {
let name = hello.server_name()?.to_string();
if let Some(found) = self
.minted
.lock()
.ok()
.and_then(|held| held.get(&name).cloned())
{
return Some(found);
}
let leaf = self.authority.leaf_for(&name).ok()?;
let certs =
rustls_pki_types::CertificateDer::pem_slice_iter(leaf.certificate_pem.as_bytes())
.collect::<std::result::Result<Vec<_>, _>>()
.ok()?;
let key = rustls_pki_types::PrivateKeyDer::from_pem_slice(leaf.key_pem.as_bytes()).ok()?;
let signing = rustls::crypto::ring::default_provider()
.key_provider
.load_private_key(key)
.ok()?;
let certified = Arc::new(rustls::sign::CertifiedKey::new(certs, signing));
if let Ok(mut held) = self.minted.lock() {
held.insert(name, Arc::clone(&certified));
}
Some(certified)
}
}
fn hidden(name: &str) -> bool {
name.starts_with('.')
}
struct Row {
name: String,
dir: bool,
site: bool,
size: Option<String>,
modified: Option<String>,
kind: &'static str,
}
fn rows_of(entries: &[Entry], sites: &HashSet<String>) -> Vec<Row> {
let mut visible: Vec<&Entry> = entries
.iter()
.filter(|e| e.name != "." && e.name != ".." && !hidden(&e.name))
.collect();
visible.sort_by(|a, b| (rank(a, sites), &a.name).cmp(&(rank(b, sites), &b.name)));
visible
.into_iter()
.map(|e| {
let dir = e.attrs.is_dir();
Row {
name: e.name.clone(),
dir,
site: dir && sites.contains(&e.name),
size: if dir {
None
} else {
e.attrs.size.map(human_size)
},
modified: e.attrs.mtime.map(utc_stamp),
kind: if dir { "dir" } else { family(&e.name) },
}
})
.collect()
}
fn rank(e: &Entry, sites: &HashSet<String>) -> (u8, u8) {
if e.attrs.is_dir() {
(0, u8::from(!sites.contains(&e.name)))
} else {
(1, u8::from(!is_page(&e.name)))
}
}
fn is_page(name: &str) -> bool {
matches!(extension_of(name).as_deref(), Some("html" | "htm"))
}
fn family(name: &str) -> &'static str {
match extension_of(name).as_deref() {
Some("html" | "htm") => "k-page",
Some("md" | "txt" | "rst" | "tex" | "bib" | "pdf" | "org" | "adoc") => "k-doc",
Some("json" | "toml" | "yaml" | "yml" | "csv" | "tsv" | "xml" | "ini" | "lock") => "k-data",
Some(
"rs" | "jl" | "py" | "ts" | "js" | "mjs" | "sh" | "c" | "h" | "cpp" | "go" | "rb"
| "lua" | "css" | "scss" | "lean" | "hs" | "java" | "kt" | "swift" | "sql",
) => "k-code",
Some(
"png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "avif" | "ico" | "mp4" | "webm"
| "mov" | "mp3" | "wav",
) => "k-media",
_ => "k-plain",
}
}
fn extension_of(name: &str) -> Option<String> {
let dot = name.rfind('.')?;
if dot == 0 || dot + 1 == name.len() {
return None;
}
Some(name[dot + 1..].to_ascii_lowercase())
}
fn human_size(n: u64) -> String {
const UNITS: [&str; 5] = ["KiB", "MiB", "GiB", "TiB", "PiB"];
if n < 1024 {
return format!("{n} B");
}
let mut v = n as f64 / 1024.0;
let mut unit = 0;
while v >= 1024.0 && unit + 1 < UNITS.len() {
v /= 1024.0;
unit += 1;
}
if v < 10.0 {
format!("{v:.1} {}", UNITS[unit])
} else {
format!("{v:.0} {}", UNITS[unit])
}
}
fn utc_stamp(secs: u32) -> String {
let secs = i64::from(secs);
let (y, m, d) = civil_from_days(secs.div_euclid(86_400));
let rest = secs.rem_euclid(86_400);
let (hh, mm) = (rest / 3600, (rest % 3600) / 60);
format!("{y:04}-{m:02}-{d:02} {hh:02}:{mm:02}")
}
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 }.div_euclid(146_097);
let doe = (z - era * 146_097) as u64;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = u32::try_from(if mp < 10 { mp + 3 } else { mp - 9 }).unwrap_or(1);
(if m <= 2 { y + 1 } else { y }, m, d)
}
const LISTING_CSS: &str = "\
*{box-sizing:border-box}\
html{background:var(--bg)}\
body{color:var(--fg);font:13px/1.5 system-ui,-apple-system,Segoe UI,sans-serif;margin:0}\
header{align-items:baseline;background:var(--bg);border-bottom:1px solid var(--line);\
display:flex;gap:6px;padding:7px 12px;position:sticky;top:0;z-index:1}\
header b{font-size:12px;font-weight:600;letter-spacing:.04em}\
header span{color:var(--dim);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;\
font-size:11px;overflow-wrap:anywhere}\
#tree{padding:4px 0 40px}\
ul{list-style:none;margin:0;padding:0}\
li ul{border-left:1px solid var(--line);margin-left:15px}\
li>ul{display:none}\
li.open>ul{display:block}\
.row{align-items:center;color:inherit;display:grid;gap:6px;\
grid-template-columns:14px 14px 1fr auto auto;line-height:22px;padding-right:12px;\
text-decoration:none;white-space:nowrap}\
.row:hover{background:var(--hover)}\
.row.here{background:var(--sel)}\
.row.here .size,.row.here .when{color:var(--dim)}\
.row:focus-visible{outline:1px solid var(--accent);outline-offset:-1px}\
.tw{color:var(--dim);font-size:11px;line-height:22px;text-align:center;\
transition:transform .1s linear}\
li.open>.row .tw{transform:rotate(90deg)}\
.ico{border-radius:2px;height:9px;justify-self:center;width:9px}\
.dir>.ico{background:var(--dim);border-radius:1px 3px 3px 3px}\
.site>.ico{background:var(--accent);border-radius:1px 3px 3px 3px}\
.site>.name{color:var(--accent)}\
.k-page>.ico{background:var(--k-page)}\
.k-page>.name{color:var(--k-page)}\
.k-doc>.ico{background:var(--k-doc)}\
.k-data>.ico{background:var(--k-data)}\
.k-code>.ico{background:var(--k-code)}\
.k-media>.ico{background:var(--k-media)}\
.k-plain>.ico{background:var(--k-plain)}\
.name{overflow:hidden;text-overflow:ellipsis}\
.size,.when{color:var(--faint);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;\
font-size:11px;font-variant-numeric:tabular-nums}\
.size{text-align:right}\
.row.busy .tw{opacity:.4}\
.row.failed .when{color:var(--k-page)}\
.empty{color:var(--faint);padding:10px 16px}\
@media(max-width:620px){.when{display:none}}";
const LISTING_JS: &str = "\
const tree=document.getElementById('tree');\
tree.addEventListener('click',async e=>{\
const row=e.target.closest('a.row');\
if(!row||row.dataset.dir!=='1')return;\
e.preventDefault();\
const li=row.parentElement;\
if(li.querySelector(':scope>ul')){li.classList.toggle('open');mark(row);return;}\
row.classList.add('busy');\
try{\
const res=await fetch(row.getAttribute('href')+'?ls');\
if(!res.ok)throw new Error(res.status);\
li.insertAdjacentHTML('beforeend',await res.text());\
li.classList.add('open');mark(row);\
}catch(err){row.classList.add('failed');\
row.querySelector('.when').textContent='could not be listed: '+err.message;}\
finally{row.classList.remove('busy');}\
});\
function mark(row){\
for(const other of tree.querySelectorAll('a.row.here'))other.classList.remove('here');\
row.classList.add('here');\
history.replaceState(null,'',row.getAttribute('href'));\
document.querySelector('header span').textContent=\
decodeURIComponent(new URL(row.href).pathname);\
}";
fn render_level(out: &mut String, path: &str, rows: &[Row], open: &[(String, Vec<Row>)]) {
out.push_str("<ul>");
for row in rows {
let here = format!("{path}/{}", row.name);
let deeper = open.first().filter(|(next, _)| *next == here);
out.push_str(if deeper.is_some() {
"<li class=\"open\">"
} else {
"<li>"
});
out.push_str("<a class=\"row ");
out.push_str(match (row.dir, row.site) {
(true, true) => "site",
(true, false) => "dir",
(false, _) => row.kind,
});
if deeper.is_some() && open.len() == 1 {
out.push_str(" here");
}
out.push_str("\" href=\"");
out.push_str(path);
out.push('/');
out.push_str(&url_escape(&row.name));
if row.dir {
out.push('/');
}
out.push_str(if row.dir {
"\" data-dir=\"1\"><span class=\"tw\">\u{25b8}</span>"
} else {
"\"><span class=\"tw\"></span>"
});
out.push_str("<span class=\"ico\"></span><span class=\"name\">");
out.push_str(&escape(&row.name));
out.push_str("</span><span class=\"size\">");
out.push_str(row.size.as_deref().unwrap_or(""));
out.push_str("</span><span class=\"when\">");
out.push_str(row.modified.as_deref().unwrap_or(""));
out.push_str("</span></a>");
if let Some((next, rows)) = deeper {
render_level(out, next, rows, &open[1..]);
}
out.push_str("</li>");
}
out.push_str("</ul>");
}
fn autoindex(alias: &str, rel: &str, levels: &[(String, Vec<Row>)], theme: &str) -> String {
let shown = if rel.is_empty() { "/" } else { rel };
let mut s = String::from("<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">");
s.push_str("<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>");
s.push_str(&escape(&format!("{shown} \u{b7} {alias}")));
s.push_str("</title><style>");
s.push_str(&theme::css_for(theme));
s.push_str(LISTING_CSS);
s.push_str("</style></head><body><header><b>");
s.push_str(&escape(alias));
s.push_str("</b><span>");
s.push_str(&escape(shown));
s.push_str("</span></header><div id=\"tree\">");
match levels.split_first() {
Some(((path, rows), rest)) if !rows.is_empty() => render_level(&mut s, path, rows, rest),
_ => s.push_str("<p class=\"empty\">This directory is empty.</p>"),
}
s.push_str("</div><script>");
s.push_str(LISTING_JS);
s.push_str("</script></body></html>");
s
}
fn escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
fn url_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char);
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sftp::wire::Attrs;
use crate::testing::{FakeRemote, dir_attrs, file_attrs, symlink_attrs};
use http_body_util::{BodyExt, Empty};
const TEST_TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
async fn body_of(res: Response<Full<Bytes>>) -> Bytes {
res.into_body()
.collect()
.await
.expect("a Full body always collects")
.to_bytes()
}
fn loopback(path: &str, token: Option<&str>) -> Request<Empty<Bytes>> {
let mut b = Request::builder().uri(path).header(HOST, "127.0.0.1:7391");
if let Some(t) = token {
b = b.header(control::TOKEN_HEADER, t);
}
b.body(Empty::<Bytes>::new()).expect("request builds")
}
fn from_site(path: &str, site: Option<&str>) -> Request<Empty<Bytes>> {
let mut b = Request::builder().uri(path).header(HOST, "127.0.0.1:7391");
if let Some(site) = site {
b = b.header(control::FETCH_SITE_HEADER, site);
}
b.body(Empty::<Bytes>::new()).expect("request builds")
}
fn control_post(path: &str, token: Option<&str>, body: &str) -> Request<Full<Bytes>> {
let mut b = Request::builder()
.method(Method::POST)
.uri(path)
.header(HOST, "127.0.0.1:7391");
if let Some(t) = token {
b = b.header(control::TOKEN_HEADER, t);
}
b.body(Full::new(Bytes::from(body.to_string())))
.expect("request builds")
}
fn ranged(path: &str, range: &str) -> Request<Empty<Bytes>> {
Request::builder()
.uri(format!("http://docs.ssh-browser{path}"))
.header(HOST, "docs.ssh-browser")
.header(RANGE, range)
.body(Empty::new())
.expect("request builds")
}
async fn origin_with(remote: FakeRemote) -> Origin {
origin_with_cache(remote, Cache::default()).await
}
async fn origin_with_cache(remote: FakeRemote, cache: Cache) -> Origin {
let fs = remote.spawn().await;
let mut sessions = HashMap::new();
sessions.insert(
"docs".to_string(),
Arc::new(Session {
host: "nowhere".to_string(),
base: "/srv".to_string(),
fs,
}),
);
Origin {
suffix: "ssh-browser".to_string(),
scheme: "http".to_string(),
tls: None,
port: 7391,
sessions: RwLock::new(sessions),
cache,
theme: RwLock::new(theme::DEFAULT.to_string()),
token: Token::from_hex(TEST_TOKEN),
reachable: RwLock::new(reachable::Set::default()),
handshakes: Handshakes::default(),
}
}
fn get(path: &str, if_none_match: Option<&str>) -> Request<Empty<Bytes>> {
let mut b = Request::builder()
.uri(format!("http://docs.ssh-browser{path}"))
.header(HOST, "docs.ssh-browser");
if let Some(tag) = if_none_match {
b = b.header(IF_NONE_MATCH, tag);
}
b.body(Empty::new()).expect("request builds")
}
async fn trips(origin: &Origin) -> u64 {
origin
.sessions
.read()
.await
.values()
.map(|s| s.fs.round_trips())
.sum()
}
fn one_page() -> FakeRemote {
FakeRemote::new()
.dir("/srv", vec![("a.html", file_attrs(5, 100))])
.file("/srv/a.html", b"hello")
}
fn page_with_subresources(n: usize) -> FakeRemote {
let mut html = String::from(
"<!doctype html><html><head><link rel=\"stylesheet\" href=\"assets/style.css\"><script src=\"assets/app.js\"></script></head><body>",
);
for i in 0..n {
html.push_str(&format!("<img src=\"assets/{i}.png\">"));
}
html.push_str("</body></html>");
let mut assets = vec!["style.css".to_string(), "app.js".to_string()];
assets.extend((0..n).map(|i| format!("{i}.png")));
let mut remote = FakeRemote::new()
.dir(
"/srv",
vec![
("index.html", file_attrs(html.len() as u64, 100)),
("assets", dir_attrs()),
],
)
.dir(
"/srv/assets",
assets
.iter()
.map(|name| (name.as_str(), file_attrs(3, 1)))
.collect(),
)
.file("/srv/index.html", html.as_bytes());
for name in &assets {
remote = remote.file(&format!("/srv/assets/{name}"), b"xxx");
}
remote
}
#[tokio::test]
async fn a_pages_subresources_are_already_held_when_the_browser_asks_for_them() {
const N: usize = 40;
let origin = origin_with(page_with_subresources(N)).await;
let res = origin.handle(get("/index.html", None)).await;
assert_eq!(res.status(), StatusCode::OK);
let before = trips(&origin).await;
for i in 0..N {
let path = format!("/assets/{i}.png");
let res = origin.handle(get(&path, None)).await;
assert_eq!(res.status(), StatusCode::OK, "{path}");
assert_eq!(&body_of(res).await[..], b"xxx", "{path}");
}
for name in ["style.css", "app.js"] {
let res = origin.handle(get(&format!("/assets/{name}"), None)).await;
assert_eq!(res.status(), StatusCode::OK, "{name}");
}
assert_eq!(
trips(&origin).await - before,
0,
"reading the page's own references is what makes these free"
);
}
#[tokio::test]
async fn serving_a_page_costs_the_same_however_many_subresources_it_has() {
async fn cost(n: usize) -> u64 {
let origin = origin_with(page_with_subresources(n)).await;
let before = trips(&origin).await;
let res = origin.handle(get("/index.html", None)).await;
assert_eq!(res.status(), StatusCode::OK);
trips(&origin).await - before
}
assert_eq!(cost(4).await, cost(40).await);
}
fn page_referring_to(refs: &[&str], extra: Vec<(&'static str, Attrs)>) -> FakeRemote {
let mut html = String::from("<!doctype html><html><body>");
for r in refs {
html.push_str(&format!("<img src=\"{r}\">"));
}
html.push_str("</body></html>");
let mut entries = vec![("index.html", file_attrs(html.len() as u64, 100))];
entries.extend(extra);
FakeRemote::new()
.dir("/srv", entries)
.file("/srv/index.html", html.as_bytes())
}
async fn cost_of_serving(refs: &[&str], extra: Vec<(&'static str, Attrs)>) -> u64 {
let origin = origin_with(page_referring_to(refs, extra)).await;
let before = trips(&origin).await;
let res = origin.handle(get("/index.html", None)).await;
assert_eq!(res.status(), StatusCode::OK);
trips(&origin).await - before
}
#[tokio::test]
async fn a_page_cannot_prefetch_its_way_out_of_the_alias_base() {
let baseline = cost_of_serving(&[], vec![]).await;
assert_eq!(
cost_of_serving(&["../../../etc/passwd", "/../../etc/shadow"], vec![]).await,
baseline,
"an escaping reference is gone before anything is listed or read"
);
}
#[tokio::test]
async fn a_page_cannot_prefetch_through_a_symlink() {
let link = || vec![("link", symlink_attrs())];
let baseline = cost_of_serving(&[], link()).await;
assert_eq!(
cost_of_serving(&["link/inside.png"], link()).await,
baseline,
"the symlink is known from the listing the page itself needed"
);
let origin = origin_with(page_referring_to(&["link/inside.png"], link())).await;
assert_eq!(
origin.handle(get("/index.html", None)).await.status(),
StatusCode::OK
);
assert_eq!(
origin.handle(get("/link/inside.png", None)).await.status(),
StatusCode::FORBIDDEN
);
}
#[tokio::test]
async fn a_page_cannot_get_a_symlink_below_an_unlisted_directory_opened() {
let html = "<!doctype html><html><body><img src=\"assets/link/secret.txt\"></body></html>";
let origin = origin_with(
FakeRemote::new()
.dir(
"/srv",
vec![
("index.html", file_attrs(html.len() as u64, 100)),
("assets", dir_attrs()),
],
)
.dir("/srv/assets", vec![("link", symlink_attrs())])
.dir("/srv/assets/link", vec![("secret.txt", file_attrs(9, 1))])
.file("/srv/index.html", html.as_bytes())
.file("/srv/assets/link/secret.txt", b"elsewhere"),
)
.await;
assert_eq!(
origin.handle(get("/index.html", None)).await.status(),
StatusCode::OK
);
assert!(
!origin.cache.has_listing("/srv/assets/link"),
"the daemon listed the directory a symlink points at"
);
assert_eq!(
origin
.handle(get("/assets/link/secret.txt", None))
.await
.status(),
StatusCode::FORBIDDEN
);
}
#[tokio::test]
async fn a_reference_in_a_real_subdirectory_is_still_prefetched() {
let html = "<!doctype html><html><body><img src=\"assets/x.png\"></body></html>";
let origin = origin_with(
FakeRemote::new()
.dir(
"/srv",
vec![
("index.html", file_attrs(html.len() as u64, 100)),
("assets", dir_attrs()),
],
)
.dir("/srv/assets", vec![("x.png", file_attrs(3, 1))])
.file("/srv/index.html", html.as_bytes())
.file("/srv/assets/x.png", b"xxx"),
)
.await;
assert_eq!(
origin.handle(get("/index.html", None)).await.status(),
StatusCode::OK
);
let before = trips(&origin).await;
let res = origin.handle(get("/assets/x.png", None)).await;
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(&body_of(res).await[..], b"xxx");
assert_eq!(
trips(&origin).await - before,
0,
"a subdirectory one level down must still be warmed"
);
}
#[tokio::test]
async fn a_large_subresource_costs_what_a_small_one_costs() {
async fn cost(bytes: usize) -> u64 {
let html = "<!doctype html><html><body><img src=\"assets/big.bin\"></body></html>";
let origin = origin_with(
FakeRemote::new()
.dir(
"/srv",
vec![
("index.html", file_attrs(html.len() as u64, 100)),
("assets", dir_attrs()),
],
)
.dir(
"/srv/assets",
vec![("big.bin", file_attrs(bytes as u64, 1))],
)
.file("/srv/index.html", html.as_bytes())
.file("/srv/assets/big.bin", &vec![b'x'; bytes]),
)
.await;
let before = trips(&origin).await;
assert_eq!(
origin.handle(get("/index.html", None)).await.status(),
StatusCode::OK
);
let spent = trips(&origin).await - before;
let at = trips(&origin).await;
let res = origin.handle(get("/assets/big.bin", None)).await;
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(body_of(res).await.len(), bytes);
assert_eq!(
trips(&origin).await - at,
0,
"{bytes} bytes should have been held"
);
spent
}
assert_eq!(cost(1024).await, cost(200 * 1024).await);
}
#[tokio::test]
async fn a_large_file_asked_for_directly_costs_what_a_small_one_costs() {
async fn cost(bytes: usize) -> u64 {
let origin = origin_with(
FakeRemote::new()
.dir("/srv", vec![("big.bin", file_attrs(bytes as u64, 1))])
.file("/srv/big.bin", &vec![b'x'; bytes]),
)
.await;
let before = trips(&origin).await;
let res = origin.handle(get("/big.bin", None)).await;
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(body_of(res).await.len(), bytes);
trips(&origin).await - before
}
assert_eq!(cost(1024).await, cost(500 * 1024).await);
}
#[tokio::test]
async fn a_subresource_the_listing_calls_empty_is_not_prefetched() {
let html = "<!doctype html><html><body><img src=\"assets/x.png\"></body></html>";
let sizeless = Attrs {
permissions: Some(0o100644),
mtime: Some(1),
..Attrs::default()
};
let origin = origin_with(
FakeRemote::new()
.dir(
"/srv",
vec![
("index.html", file_attrs(html.len() as u64, 100)),
("assets", dir_attrs()),
],
)
.dir("/srv/assets", vec![("x.png", sizeless)])
.file("/srv/index.html", html.as_bytes())
.file("/srv/assets/x.png", b"xxx"),
)
.await;
assert_eq!(
origin.handle(get("/index.html", None)).await.status(),
StatusCode::OK
);
let res = origin.handle(get("/assets/x.png", None)).await;
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(
&body_of(res).await[..],
b"xxx",
"the real request must still serve the whole file"
);
}
#[tokio::test]
async fn an_oversized_subresource_is_not_prefetched() {
async fn cost(size: u64) -> u64 {
let html =
"<!doctype html><html><body><video src=\"assets/film.mp4\"></video></body></html>";
let origin = origin_with(
FakeRemote::new()
.dir(
"/srv",
vec![
("index.html", file_attrs(html.len() as u64, 100)),
("assets", dir_attrs()),
],
)
.dir("/srv/assets", vec![("film.mp4", file_attrs(size, 1))])
.file("/srv/index.html", html.as_bytes())
.file("/srv/assets/film.mp4", b"xxx"),
)
.await;
let before = trips(&origin).await;
assert_eq!(
origin.handle(get("/index.html", None)).await.status(),
StatusCode::OK
);
trips(&origin).await - before
}
let read_it = cost(3).await;
let skipped = cost(CACHE_WHOLE_MAX + 1).await;
assert!(
skipped < read_it,
"an oversized subresource cost {skipped} against {read_it} for a small one"
);
}
#[tokio::test]
async fn the_port_is_taken_before_any_host_is_connected() {
let held = TcpListener::bind(("127.0.0.1", 0))
.await
.expect("a free port");
let port = held.local_addr().expect("its address").port();
const NOWHERE: &str = "a-host-that-cannot-resolve.invalid";
let result = Origin::bind(
vec![Alias::new("docs", NOWHERE, Some("/srv")).expect("a valid alias")],
reachable::Set::default(),
"ssh-browser".to_string(),
"http".to_string(),
port,
Token::from_hex(TEST_TOKEN),
theme::DEFAULT.to_string(),
)
.await;
let Err(e) = result else {
panic!("binding a port that is already held must fail");
};
let text = format!("{e:#}");
assert!(
text.contains(&format!("bind 127.0.0.1:{port}")),
"the error should name the port, got: {text}"
);
assert!(
!text.contains(NOWHERE),
"the ssh host was reached before the port was taken: {text}"
);
}
#[tokio::test]
async fn a_dot_name_is_never_served() {
let origin = origin_with(
FakeRemote::new()
.dir(
"/srv",
vec![
("Vault", dir_attrs()),
(".ssh", dir_attrs()),
(".netrc", file_attrs(9, 1)),
],
)
.dir("/srv/.ssh", vec![("id_ed25519", file_attrs(9, 1))])
.dir("/srv/Vault", vec![(".git", dir_attrs())])
.dir("/srv/Vault/.git", vec![("config", file_attrs(9, 1))])
.file("/srv/.ssh/id_ed25519", b"a-secret-")
.file("/srv/.netrc", b"a-secret-")
.file("/srv/Vault/.git/config", b"a-secret-"),
)
.await;
for path in [
"/.ssh/id_ed25519",
"/.netrc",
"/Vault/.git/config",
"/.ssh/",
] {
assert_eq!(
origin.handle(get(path, None)).await.status(),
StatusCode::FORBIDDEN,
"{path}"
);
}
}
#[tokio::test]
async fn a_listing_does_not_mention_dot_names() {
let origin = origin_with(FakeRemote::new().dir(
"/srv",
vec![
("Vault", dir_attrs()),
(".ssh", dir_attrs()),
(".obsidian", dir_attrs()),
],
))
.await;
let body = body_of(origin.handle(get("/", None)).await).await;
let listing = String::from_utf8_lossy(&body);
assert!(listing.contains("Vault"), "the ordinary entry is listed");
assert!(!listing.contains(".ssh"), "got: {listing}");
assert!(!listing.contains(".obsidian"), "got: {listing}");
}
#[tokio::test]
async fn a_page_cannot_prefetch_a_dot_name() {
let html = "<!doctype html><html><body><img src=\".ssh/id_ed25519\"></body></html>";
let origin = origin_with(
FakeRemote::new()
.dir(
"/srv",
vec![
("index.html", file_attrs(html.len() as u64, 100)),
(".ssh", dir_attrs()),
],
)
.dir("/srv/.ssh", vec![("id_ed25519", file_attrs(9, 1))])
.file("/srv/index.html", html.as_bytes())
.file("/srv/.ssh/id_ed25519", b"a-secret-"),
)
.await;
assert_eq!(
origin.handle(get("/index.html", None)).await.status(),
StatusCode::OK
);
assert!(
!origin.cache.has_listing("/srv/.ssh"),
"the page got the daemon to list a directory it will not serve"
);
assert_eq!(
origin.handle(get("/.ssh/id_ed25519", None)).await.status(),
StatusCode::FORBIDDEN
);
}
fn deep_tree() -> FakeRemote {
FakeRemote::new()
.dir("/srv", vec![("a", dir_attrs())])
.dir("/srv/a", vec![("b", dir_attrs())])
.dir("/srv/a/b", vec![("c", dir_attrs())])
.dir("/srv/a/b/c", vec![("d.html", file_attrs(5, 100))])
.file("/srv/a/b/c/d.html", b"deep!")
}
fn entry(name: &str, dir: bool) -> Entry {
Entry {
name: name.to_string(),
attrs: Attrs {
permissions: Some(if dir { 0o040755 } else { 0o100644 }),
..Attrs::default()
},
}
}
fn listing(alias: &str, rel: &str, entries: &[Entry]) -> String {
let levels = vec![(rel.to_string(), rows_of(entries, &HashSet::new()))];
autoindex(alias, rel, &levels, theme::DEFAULT)
}
#[test]
fn a_hostile_filename_cannot_inject_script_into_our_origin() {
let page = listing("docs", "", &[entry("<script>alert(1)</script>", false)]);
assert!(!page.contains("<script>alert"));
assert!(page.contains("<script>"));
}
#[test]
fn directories_come_first_and_pages_lead_the_files() {
let page = listing(
"docs",
"",
&[
entry("b.txt", false),
entry("z-dir", true),
entry("a.txt", false),
entry("report.html", false),
],
);
let dir = page.find("z-dir").expect("dir listed");
let html = page.find("report.html").expect("page listed");
let a = page.find("a.txt").expect("a listed");
let b = page.find("b.txt").expect("b listed");
assert!(
dir < html,
"directories come first, whatever they are called"
);
assert!(html < a, "then the pages, ahead of the other files");
assert!(a < b, "and the rest by name");
assert!(!page.contains("<h2"), "{page}");
}
#[test]
fn only_html_counts_as_a_page() {
let page = listing(
"docs",
"",
&[
entry("a.htm", false),
entry("b.html.bak", false),
entry("c.xhtml", false),
],
);
let htm = page.find("a.htm").expect("htm listed");
let bak = page.find("b.html.bak").expect("bak listed");
let xhtml = page.find("c.xhtml").expect("xhtml listed");
assert!(htm < bak && htm < xhtml, "only the .htm leads: {page}");
assert!(
page.contains("class=\"row k-page\" href=\"/a.htm\""),
"{page}"
);
}
#[test]
fn hrefs_are_url_escaped() {
let page = listing("docs", "", &[entry("a b#c.html", false)]);
assert!(page.contains("href=\"/a%20b%23c.html\""));
}
#[test]
fn the_header_names_the_alias_and_where_you_are() {
let page = listing("panza", "/Vault/infra", &[]);
assert!(page.contains("<b>panza</b>"), "{page}");
assert!(page.contains("<span>/Vault/infra</span>"), "{page}");
}
#[tokio::test]
async fn the_whole_path_is_expanded_and_the_deepest_is_selected() {
let origin = origin_with(
FakeRemote::new()
.dir("/srv", vec![("a", dir_attrs()), ("elsewhere", dir_attrs())])
.dir("/srv/a", vec![("b", dir_attrs()), ("sibling", dir_attrs())])
.dir("/srv/a/b", vec![("leaf.txt", file_attrs(3, 1))])
.dir("/srv/elsewhere", vec![])
.dir("/srv/a/sibling", vec![]),
)
.await;
let body = String::from_utf8(
body_of(origin.handle(get("/a/b/", None)).await)
.await
.to_vec(),
)
.expect("utf-8");
assert!(body.contains("<li class=\"open\">"), "{body}");
assert!(body.contains("href=\"/a/\""), "{body}");
assert!(body.contains("row dir here\" href=\"/a/b/\""), "{body}");
assert!(body.contains("leaf.txt"), "{body}");
assert!(body.contains("elsewhere"), "{body}");
assert!(body.contains("sibling"), "{body}");
}
#[tokio::test]
async fn the_tree_costs_what_one_directory_cost() {
let deep = origin_with(deep_tree()).await;
let before = trips(&deep).await;
assert_eq!(
deep.handle(get("/a/b/c/", None)).await.status(),
StatusCode::OK
);
let four = trips(&deep).await - before;
let shallow = origin_with(one_page()).await;
let before = trips(&shallow).await;
assert_eq!(
shallow.handle(get("/", None)).await.status(),
StatusCode::OK
);
let one = trips(&shallow).await - before;
assert!(
four <= one + 2,
"a tree four deep cost {four} round trips against {one} for one directory"
);
}
#[tokio::test]
async fn asking_for_one_level_answers_with_its_rows() {
let origin = origin_with(
FakeRemote::new()
.dir("/srv", vec![("sub", dir_attrs())])
.dir("/srv/sub", vec![("inner.md", file_attrs(4, 1))]),
)
.await;
let req = Request::builder()
.uri("http://docs.ssh-browser/sub/?ls")
.header(HOST, "docs.ssh-browser")
.body(Empty::<Bytes>::new())
.expect("request builds");
let res = origin.handle(req).await;
assert_eq!(res.status(), StatusCode::OK);
let body = String::from_utf8(body_of(res).await.to_vec()).expect("utf-8");
assert!(body.starts_with("<ul>"), "{body}");
assert!(!body.contains("<html"), "{body}");
assert!(body.contains("href=\"/sub/inner.md\""), "{body}");
}
#[tokio::test]
async fn asking_for_one_level_does_not_mention_dot_names() {
let origin = origin_with(
FakeRemote::new()
.dir("/srv", vec![("sub", dir_attrs())])
.dir(
"/srv/sub",
vec![("shown.md", file_attrs(4, 1)), (".hidden", dir_attrs())],
),
)
.await;
let req = Request::builder()
.uri("http://docs.ssh-browser/sub/?ls")
.header(HOST, "docs.ssh-browser")
.body(Empty::<Bytes>::new())
.expect("request builds");
let body =
String::from_utf8(body_of(origin.handle(req).await).await.to_vec()).expect("utf-8");
assert!(body.contains("shown.md"), "{body}");
assert!(!body.contains(".hidden"), "{body}");
}
#[test]
fn sizes_read_the_way_a_file_manager_shows_them() {
assert_eq!(human_size(0), "0 B");
assert_eq!(human_size(999), "999 B");
assert_eq!(human_size(1024), "1.0 KiB");
assert_eq!(human_size(1536), "1.5 KiB");
assert_eq!(human_size(10 * 1024 * 1024), "10 MiB");
assert_eq!(human_size(9_961_472), "9.5 MiB");
assert_eq!(human_size(3 * 1024 * 1024 * 1024), "3.0 GiB");
}
#[test]
fn timestamps_are_the_utc_civil_date() {
assert_eq!(utc_stamp(0), "1970-01-01 00:00");
assert_eq!(utc_stamp(86_399), "1970-01-01 23:59");
assert_eq!(utc_stamp(86_400), "1970-01-02 00:00");
assert_eq!(utc_stamp(951_782_400), "2000-02-29 00:00");
assert_eq!(utc_stamp(4_107_456_000), "2100-02-28 00:00");
assert_eq!(utc_stamp(4_107_542_400), "2100-03-01 00:00");
assert_eq!(utc_stamp(1_757_745_840), "2025-09-13 06:44");
}
#[test]
fn an_empty_directory_says_it_is_empty() {
let page = listing("docs", "/nothing", &[]);
assert!(page.contains("This directory is empty"), "{page}");
}
#[test]
fn the_component_chain_walks_from_the_base_down() {
assert_eq!(
components("/srv", "/srv/a/b/c.html"),
vec![
("/srv".to_string(), "a".to_string()),
("/srv/a".to_string(), "b".to_string()),
("/srv/a/b".to_string(), "c.html".to_string()),
]
);
assert_eq!(
components("/srv", "/srv/index.html"),
vec![("/srv".to_string(), "index.html".to_string())]
);
assert_eq!(
components("/srv/", "/srv/a.html"),
vec![("/srv".to_string(), "a.html".to_string())]
);
assert!(components("/srv", "/srv").is_empty());
}
#[tokio::test]
async fn a_revisit_costs_no_remote_round_trips() {
let origin = origin_with(one_page()).await;
let first = origin.handle(get("/a.html", None)).await;
assert_eq!(first.status(), StatusCode::OK);
let after_first = trips(&origin).await;
assert!(after_first > 0, "the first request has to fetch something");
let second = origin.handle(get("/a.html", None)).await;
assert_eq!(second.status(), StatusCode::OK);
assert_eq!(
trips(&origin).await,
after_first,
"a revisit must be answered entirely from cache"
);
}
#[tokio::test]
async fn a_conditional_get_is_answered_without_the_remote() {
let origin = origin_with(one_page()).await;
let first = origin.handle(get("/a.html", None)).await;
let tag = first
.headers()
.get(ETAG)
.expect("a validator is offered")
.to_str()
.expect("ascii")
.to_string();
let after_first = trips(&origin).await;
let second = origin.handle(get("/a.html", Some(&tag))).await;
assert_eq!(second.status(), StatusCode::NOT_MODIFIED);
assert_eq!(
trips(&origin).await,
after_first,
"a 304 must not touch the remote"
);
}
#[tokio::test]
async fn a_missing_file_is_a_404_from_the_cached_listing() {
let origin = origin_with(one_page()).await;
origin.handle(get("/a.html", None)).await;
let warm = trips(&origin).await;
let missing = origin.handle(get("/nope.html", None)).await;
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
assert_eq!(
trips(&origin).await,
warm,
"a 404 for a listed-but-absent name must cost nothing"
);
}
#[tokio::test]
async fn a_symlink_is_refused() {
let origin = origin_with(
FakeRemote::new()
.dir("/srv", vec![("link.html", symlink_attrs())])
.file("/srv/link.html", b"whatever the target is"),
)
.await;
let res = origin.handle(get("/link.html", None)).await;
assert_eq!(res.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn a_directory_without_a_trailing_slash_redirects() {
let origin = origin_with(
FakeRemote::new()
.dir("/srv", vec![("sub", dir_attrs())])
.dir("/srv/sub", vec![("b.html", file_attrs(1, 1))]),
)
.await;
let res = origin.handle(get("/sub", None)).await;
assert_eq!(res.status(), StatusCode::MOVED_PERMANENTLY);
assert_eq!(
res.headers().get(LOCATION).and_then(|v| v.to_str().ok()),
Some("/sub/")
);
}
#[tokio::test]
async fn a_listing_proven_wrong_is_forgotten() {
let origin =
origin_with(FakeRemote::new().dir("/srv", vec![("ghost.html", file_attrs(5, 100))]))
.await;
let res = origin.handle(get("/ghost.html", None)).await;
assert_eq!(res.status(), StatusCode::NOT_FOUND);
assert!(
!origin.cache.has_listing("/srv"),
"a listing contradicted by the remote must be dropped"
);
}
#[tokio::test]
async fn a_directory_without_an_index_is_listed() {
let origin =
origin_with(FakeRemote::new().dir("/srv", vec![("only.txt", file_attrs(2, 1))])).await;
let res = origin.handle(get("/", None)).await;
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(
res.headers()
.get(CONTENT_TYPE)
.and_then(|v| v.to_str().ok()),
Some("text/html; charset=utf-8")
);
}
#[tokio::test]
async fn a_symlinked_directory_higher_up_the_path_is_refused() {
let origin = origin_with(
FakeRemote::new()
.dir("/srv", vec![("link", symlink_attrs())])
.dir("/srv/link", vec![("inside.html", file_attrs(2, 1))])
.file("/srv/link/inside.html", b"hi"),
)
.await;
let res = origin.handle(get("/link/inside.html", None)).await;
assert_eq!(res.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn a_deep_path_costs_what_a_shallow_one_costs() {
let deep = origin_with(deep_tree()).await;
assert_eq!(
deep.handle(get("/a/b/c/d.html", None)).await.status(),
StatusCode::OK
);
let shallow = origin_with(one_page()).await;
assert_eq!(
shallow.handle(get("/a.html", None)).await.status(),
StatusCode::OK
);
let (d, sh) = (trips(&deep).await, trips(&shallow).await);
assert!(
d <= sh + 2,
"depth 4 cost {d} round trips against depth 1's {sh}"
);
}
#[tokio::test]
async fn a_file_used_as_a_directory_is_a_404() {
let origin = origin_with(one_page()).await;
let res = origin.handle(get("/a.html/b.html", None)).await;
assert_eq!(res.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn a_deep_path_serves_its_body() {
let origin = origin_with(deep_tree()).await;
let res = origin.handle(get("/a/b/c/d.html", None)).await;
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(
res.headers()
.get(CONTENT_TYPE)
.and_then(|v| v.to_str().ok()),
Some("text/html; charset=utf-8")
);
}
#[tokio::test]
async fn a_range_is_sliced_out_of_the_cached_body() {
let origin = origin_with(one_page()).await;
assert_eq!(
origin.handle(get("/a.html", None)).await.status(),
StatusCode::OK
);
let warm = trips(&origin).await;
let res = origin.handle(ranged("/a.html", "bytes=1-3")).await;
assert_eq!(res.status(), StatusCode::PARTIAL_CONTENT);
assert_eq!(
res.headers()
.get(CONTENT_RANGE)
.and_then(|v| v.to_str().ok()),
Some("bytes 1-3/5")
);
assert_eq!(&body_of(res).await[..], b"ell");
assert_eq!(
trips(&origin).await,
warm,
"slicing a held body must cost no round trip"
);
}
#[tokio::test]
async fn a_range_on_a_cold_small_file_works_and_warms_the_cache() {
let origin = origin_with(one_page()).await;
let res = origin.handle(ranged("/a.html", "bytes=0-1")).await;
assert_eq!(res.status(), StatusCode::PARTIAL_CONTENT);
assert_eq!(&body_of(res).await[..], b"he");
let warm = trips(&origin).await;
let again = origin.handle(ranged("/a.html", "bytes=2-4")).await;
assert_eq!(&body_of(again).await[..], b"llo");
assert_eq!(
trips(&origin).await,
warm,
"a small file fetched for a range should be held whole"
);
}
#[tokio::test]
async fn a_range_past_the_end_is_a_416_carrying_the_real_size() {
let origin = origin_with(one_page()).await;
let res = origin.handle(ranged("/a.html", "bytes=99-")).await;
assert_eq!(res.status(), StatusCode::RANGE_NOT_SATISFIABLE);
assert_eq!(
res.headers()
.get(CONTENT_RANGE)
.and_then(|v| v.to_str().ok()),
Some("bytes */5")
);
}
#[tokio::test]
async fn a_full_response_advertises_ranges() {
let origin = origin_with(one_page()).await;
let res = origin.handle(get("/a.html", None)).await;
assert_eq!(
res.headers()
.get(ACCEPT_RANGES)
.and_then(|v| v.to_str().ok()),
Some("bytes")
);
}
#[tokio::test]
async fn if_range_yields_the_whole_file() {
let origin = origin_with(one_page()).await;
let req = Request::builder()
.uri("http://docs.ssh-browser/a.html")
.header(HOST, "docs.ssh-browser")
.header(RANGE, "bytes=1-3")
.header(IF_RANGE, "W/\"64-5\"")
.body(Empty::<Bytes>::new())
.expect("request builds");
let res = origin.handle(req).await;
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(&body_of(res).await[..], b"hello");
}
#[tokio::test]
async fn a_large_file_is_served_by_range_and_not_held() {
let body: Vec<u8> = (0..64u8).collect();
let origin = origin_with(
FakeRemote::new()
.dir("/srv", vec![("big.bin", file_attrs(9 * 1024 * 1024, 7))])
.file("/srv/big.bin", &body),
)
.await;
let res = origin.handle(ranged("/big.bin", "bytes=0-9")).await;
assert_eq!(res.status(), StatusCode::PARTIAL_CONTENT);
assert_eq!(&body_of(res).await[..], &body[0..10]);
let after = trips(&origin).await;
let second = origin.handle(ranged("/big.bin", "bytes=10-19")).await;
assert_eq!(&body_of(second).await[..], &body[10..20]);
assert!(
trips(&origin).await > after,
"a file over the threshold must not be held"
);
}
#[tokio::test]
async fn an_alias_origin_has_no_control_api_on_it() {
let origin = origin_with(one_page()).await;
let res = origin.handle(get("/_control/hello", None)).await;
assert_eq!(res.status(), StatusCode::NOT_FOUND);
assert_ne!(
res.status(),
StatusCode::UNAUTHORIZED,
"a 401 would mean the control router was reached from an alias origin"
);
}
#[tokio::test]
async fn an_alias_origin_with_a_valid_token_still_has_no_control_api() {
let origin = origin_with(one_page()).await;
let req = Request::builder()
.uri("http://docs.ssh-browser/_control/hello")
.header(HOST, "docs.ssh-browser")
.header(control::TOKEN_HEADER, TEST_TOKEN)
.body(Empty::<Bytes>::new())
.expect("request builds");
assert_eq!(origin.handle(req).await.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn the_alias_origin_refuses_writes() {
let origin = origin_with(one_page()).await;
for method in [Method::POST, Method::PUT, Method::DELETE, Method::PATCH] {
let req = Request::builder()
.method(method.clone())
.uri("http://docs.ssh-browser/a.html")
.header(HOST, "docs.ssh-browser")
.body(Empty::<Bytes>::new())
.expect("request builds");
assert_eq!(
origin.handle(req).await.status(),
StatusCode::METHOD_NOT_ALLOWED,
"{method} should be refused on the read-only origin"
);
}
}
#[tokio::test]
async fn the_control_api_answers_on_loopback_with_the_token() {
let origin = origin_with(one_page()).await;
let res = origin
.handle(loopback("/_control/hello", Some(TEST_TOKEN)))
.await;
assert_eq!(res.status(), StatusCode::OK);
let body = body_of(res).await;
let text = String::from_utf8_lossy(&body);
assert!(
text.contains("\"protocol\""),
"hello must negotiate: {text}"
);
assert!(text.contains("\"docs\""), "hello must list aliases: {text}");
}
#[tokio::test]
async fn the_control_api_refuses_loopback_without_the_token() {
let origin = origin_with(one_page()).await;
assert_eq!(
origin
.handle(loopback("/_control/hello", None))
.await
.status(),
StatusCode::UNAUTHORIZED
);
assert_eq!(
origin
.handle(loopback("/_control/hello", Some("wrong")))
.await
.status(),
StatusCode::UNAUTHORIZED
);
}
#[tokio::test]
async fn the_loopback_path_still_serves_files() {
let origin = origin_with(one_page()).await;
let res = origin.handle(loopback("/docs/a.html", None)).await;
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(&body_of(res).await[..], b"hello");
}
#[tokio::test]
async fn a_base_may_be_written_relative_to_the_home_directory() {
let fs = FakeRemote::new().home("/home/souta").spawn().await;
assert_eq!(
resolve_base(Some("~/work"), &fs).await.expect("resolves"),
"/home/souta/work"
);
}
#[tokio::test]
async fn a_bare_tilde_and_no_base_are_both_the_home_directory() {
let fs = FakeRemote::new().home("/home/souta").spawn().await;
assert_eq!(
resolve_base(None, &fs).await.expect("resolves"),
"/home/souta"
);
assert_eq!(
resolve_base(Some("~"), &fs).await.expect("resolves"),
"/home/souta"
);
}
#[tokio::test]
async fn an_absolute_base_costs_no_round_trip() {
let fs = FakeRemote::new().home("/home/souta").spawn().await;
let before = fs.round_trips();
assert_eq!(
resolve_base(Some("/srv/docs"), &fs)
.await
.expect("resolves"),
"/srv/docs"
);
assert_eq!(fs.round_trips(), before, "an absolute base must not ask");
}
#[test]
fn a_base_that_could_climb_out_of_the_home_directory_is_refused() {
for bad in [
"~/..",
"~/../.ssh",
"~/work/../..",
"~/./x",
"~work",
"work",
"",
] {
assert!(!is_base(bad), "should have been refused: {bad:?}");
assert!(
Alias::new("docs", "h", Some(bad)).is_err(),
"should have been refused: {bad:?}"
);
}
for good in ["/", "/srv", "~", "~/work", "~/a/b/c"] {
assert!(is_base(good), "should have been accepted: {good:?}");
}
}
#[tokio::test]
async fn a_root_home_does_not_produce_a_doubled_slash() {
let fs = FakeRemote::new().home("/").spawn().await;
assert_eq!(resolve_base(None, &fs).await.expect("resolves"), "/");
assert_eq!(
resolve_base(Some("~/work"), &fs).await.expect("resolves"),
"/work"
);
}
#[tokio::test]
async fn the_host_list_is_a_control_route() {
let origin = origin_with(one_page()).await;
let res = origin
.handle(loopback("/_control/hosts", Some(TEST_TOKEN)))
.await;
assert_eq!(res.status(), StatusCode::OK);
let text = String::from_utf8(body_of(res).await.to_vec()).expect("utf-8");
let parsed: serde_json::Value = serde_json::from_str(&text).expect("json");
assert!(parsed.get("hosts").is_some_and(|h| h.is_array()), "{text}");
assert!(
parsed.get("unusable").is_some_and(|u| u.is_array()),
"{text}"
);
}
#[tokio::test]
async fn the_host_list_reports_round_trips_and_they_grow() {
let origin = origin_with(one_page()).await;
async fn trips_now(origin: &Origin) -> u64 {
let res = origin
.handle(loopback("/_control/hosts", Some(TEST_TOKEN)))
.await;
assert_eq!(res.status(), StatusCode::OK);
let text = String::from_utf8(body_of(res).await.to_vec()).expect("utf-8");
let parsed: serde_json::Value = serde_json::from_str(&text).expect("json");
let open = parsed["open"].as_array().expect("open is an array");
assert_eq!(open.len(), 1, "{text}");
open[0]["trips"].as_u64().expect("trips is a number")
}
let before = trips_now(&origin).await;
let res = origin.handle(get("/a.html", None)).await;
assert_eq!(res.status(), StatusCode::OK);
let after = trips_now(&origin).await;
assert!(
after > before,
"serving a page reported no round trips ({before} -> {after})"
);
}
#[test]
fn a_remembered_label_finds_the_host_it_came_from() {
let known = vec![
ssh_config::Host {
host: "Panza".to_string(),
alias: "panza".to_string(),
},
ssh_config::Host {
host: "issp-ohtaka".to_string(),
alias: "issp-ohtaka".to_string(),
},
];
assert_eq!(
entry_for(&known, "panza").map(|h| h.host.as_str()),
Some("Panza")
);
assert_eq!(
entry_for(&known, "Panza").map(|h| h.host.as_str()),
Some("Panza")
);
assert_eq!(
entry_for(&known, "issp-ohtaka").map(|h| h.host.as_str()),
Some("issp-ohtaka")
);
}
#[test]
fn a_name_ssh_config_does_not_know_resolves_to_nothing() {
let known = vec![ssh_config::Host {
host: "Panza".to_string(),
alias: "panza".to_string(),
}];
assert!(entry_for(&known, "not-a-host-anywhere").is_none());
assert!(entry_for(&known, "").is_none());
assert!(entry_for(&known, "panz").is_none());
}
#[tokio::test]
async fn enabling_a_host_ssh_does_not_know_is_refused() {
let origin = origin_with(one_page()).await;
let res = origin
.handle(control_post(
"/_control/enabled",
Some(TEST_TOKEN),
r#"{"host":"not-a-host-in-anyones-ssh-config.invalid","enabled":true}"#,
))
.await;
assert_eq!(res.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn enabling_a_host_without_the_token_is_refused() {
let origin = origin_with(one_page()).await;
let res = origin
.handle(control_post(
"/_control/enabled",
None,
r#"{"host":"anything","enabled":true}"#,
))
.await;
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn enabling_needs_to_say_which_way() {
let origin = origin_with(one_page()).await;
for body in [
r#"{"host":"anything"}"#,
r#"{"enabled":true}"#,
"{}",
"not json",
] {
let res = origin
.handle(control_post("/_control/enabled", Some(TEST_TOKEN), body))
.await;
assert_eq!(
res.status(),
StatusCode::BAD_REQUEST,
"{body} should not have been accepted"
);
}
}
#[tokio::test]
async fn disabling_a_host_closes_it_now() {
let origin = origin_with(one_page()).await;
assert!(origin.session("docs").await.is_some());
origin.sessions.write().await.remove("docs");
assert!(
origin.session("docs").await.is_none(),
"removing the session is what disabling does, and a request must then 404"
);
let res = origin.handle(get("/a.html", None)).await;
assert_eq!(res.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn an_alias_origin_cannot_read_the_host_list() {
let origin = origin_with(one_page()).await;
for path in ["/_control/hosts", "/_control/enabled", "/_control/hello"] {
let res = origin.handle(get(path, None)).await;
assert_ne!(
res.status(),
StatusCode::OK,
"{path} answered a request from an alias origin"
);
}
}
#[tokio::test]
async fn opening_a_host_ssh_does_not_know_is_refused() {
let origin = origin_with(one_page()).await;
let res = origin
.handle(control_post(
"/_control/open",
Some(TEST_TOKEN),
r#"{"host":"not-a-host-in-anyones-ssh-config.invalid"}"#,
))
.await;
assert_eq!(res.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn an_open_request_that_is_not_one_is_refused() {
let origin = origin_with(one_page()).await;
for body in [
"",
"{}",
r#"{"base":"/srv"}"#,
r#"{"host":"docs","base_path":"/srv"}"#,
] {
let res = origin
.handle(control_post("/_control/open", Some(TEST_TOKEN), body))
.await;
assert_eq!(
res.status(),
StatusCode::BAD_REQUEST,
"should have been refused: {body}"
);
}
}
#[tokio::test]
async fn opening_a_host_needs_the_token() {
let origin = origin_with(one_page()).await;
for token in [None, Some("wrong")] {
let res = origin
.handle(control_post("/_control/open", token, r#"{"host":"docs"}"#))
.await;
assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "token {token:?}");
}
}
#[tokio::test]
async fn the_token_is_handed_over_to_something_that_is_not_a_page() {
let origin = origin_with(one_page()).await;
for site in [None, Some("none")] {
let res = origin.handle(from_site("/_control/token", site)).await;
assert_eq!(res.status(), StatusCode::OK, "site {site:?}");
let body = String::from_utf8(body_of(res).await.to_vec()).expect("utf-8");
assert_eq!(body.trim(), TEST_TOKEN, "site {site:?}");
}
}
#[tokio::test]
async fn a_page_is_not_handed_the_token() {
let origin = origin_with(one_page()).await;
for site in ["same-origin", "same-site", "cross-site"] {
let res = origin
.handle(from_site("/_control/token", Some(site)))
.await;
assert_eq!(res.status(), StatusCode::FORBIDDEN, "site {site}");
let body = String::from_utf8(body_of(res).await.to_vec()).expect("utf-8");
assert!(!body.contains(TEST_TOKEN), "the refusal leaked it: {body}");
}
}
#[tokio::test]
async fn a_page_with_the_token_still_cannot_use_the_control_api() {
let origin = origin_with(one_page()).await;
let req = Request::builder()
.uri("http://127.0.0.1:7391/_control/hello")
.header(HOST, "127.0.0.1:7391")
.header(control::TOKEN_HEADER, TEST_TOKEN)
.header(control::FETCH_SITE_HEADER, "same-origin")
.body(Full::new(Bytes::new()))
.expect("request builds");
assert_eq!(origin.handle(req).await.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn an_alias_can_be_closed_and_is_then_gone() {
let origin = origin_with(one_page()).await;
assert_eq!(
origin.handle(get("/a.html", None)).await.status(),
StatusCode::OK
);
let res = origin
.handle(control_post(
"/_control/close",
Some(TEST_TOKEN),
r#"{"alias":"docs"}"#,
))
.await;
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(
origin.handle(get("/a.html", None)).await.status(),
StatusCode::NOT_FOUND
);
}
#[tokio::test]
async fn closing_an_alias_that_is_not_open_says_so() {
let origin = origin_with(one_page()).await;
let res = origin
.handle(control_post(
"/_control/close",
Some(TEST_TOKEN),
r#"{"alias":"nope"}"#,
))
.await;
assert_eq!(res.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn closing_an_alias_needs_the_token() {
let origin = origin_with(one_page()).await;
let res = origin
.handle(control_post("/_control/close", None, r#"{"alias":"docs"}"#))
.await;
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
assert_eq!(
origin.handle(get("/a.html", None)).await.status(),
StatusCode::OK
);
}
#[tokio::test]
async fn a_directory_holding_an_index_is_listed_as_a_site() {
let origin = origin_with(
FakeRemote::new()
.dir("/srv", vec![("ft-demo", dir_attrs()), ("src", dir_attrs())])
.dir("/srv/ft-demo", vec![("index.html", file_attrs(5, 1))])
.dir("/srv/src", vec![("main.jl", file_attrs(5, 1))])
.file("/srv/ft-demo/index.html", b"board"),
)
.await;
let body = String::from_utf8(body_of(origin.handle(get("/", None)).await).await.to_vec())
.expect("utf-8");
assert!(
body.contains("class=\"row site\" href=\"/ft-demo/\""),
"{body}"
);
let demo = body.find("ft-demo/").expect("the site listed");
let src = body.find("src/").expect("the folder listed");
assert!(demo < src, "a site leads the other directories: {body}");
}
#[tokio::test]
async fn the_site_scan_costs_the_same_however_many_subdirectories() {
async fn trips_for(n: usize) -> u64 {
let names: Vec<String> = (0..n).map(|i| format!("d{i:02}")).collect();
let mut remote = FakeRemote::new().dir(
"/srv",
names.iter().map(|s| (s.as_str(), dir_attrs())).collect(),
);
for name in &names {
remote = remote.dir(&format!("/srv/{name}"), vec![("a.txt", file_attrs(1, 1))]);
}
let origin = origin_with(remote).await;
let before = trips(&origin).await;
assert_eq!(origin.handle(get("/", None)).await.status(), StatusCode::OK);
trips(&origin).await - before
}
let few = trips_for(2).await;
let many = trips_for(20).await;
assert_eq!(
few, many,
"{many} round trips for twenty subdirectories against {few} for two"
);
}
#[tokio::test]
async fn the_scan_leaves_the_next_click_paid_for() {
let origin = origin_with(
FakeRemote::new()
.dir("/srv", vec![("sub", dir_attrs())])
.dir("/srv/sub", vec![("a.txt", file_attrs(1, 1))]),
)
.await;
assert_eq!(origin.handle(get("/", None)).await.status(), StatusCode::OK);
let before = trips(&origin).await;
assert_eq!(
origin.handle(get("/sub/", None)).await.status(),
StatusCode::OK
);
assert_eq!(
trips(&origin).await,
before,
"the listing the scan fetched should still be the one that answers"
);
}
#[tokio::test]
async fn a_listing_that_expires_mid_request_does_not_lose_the_path() {
let origin =
origin_with_cache(deep_tree(), Cache::new(std::time::Duration::ZERO, 1 << 20)).await;
assert_eq!(
origin.handle(get("/a/b/c/d.html", None)).await.status(),
StatusCode::OK,
"a path four deep must survive its own listings expiring"
);
assert_eq!(
origin.handle(get("/a/b/c/", None)).await.status(),
StatusCode::OK
);
}
#[tokio::test]
async fn a_directory_the_remote_refuses_says_why() {
let origin = origin_with(
FakeRemote::new()
.dir("/srv", vec![("locked", dir_attrs())])
.refuses_listing("/srv/locked", 3),
)
.await;
let res = origin.handle(get("/locked/x.html", None)).await;
assert_eq!(res.status(), StatusCode::BAD_GATEWAY);
let said = String::from_utf8(body_of(res).await.to_vec()).expect("utf-8");
assert!(said.contains("/srv/locked"), "{said}");
assert!(
!said.contains("cannot list"),
"the old wording said nothing the reader could act on: {said}"
);
}
}