use std::fmt::Write as _;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use axum::Router;
use axum::extract::{Path as UrlPath, Query, State};
use axum::http::{StatusCode, header};
use axum::response::{Html, IntoResponse, Redirect, Response};
use axum::routing::get;
use rto_render::okf::view;
#[derive(Clone)]
struct Viewer {
root: Arc<PathBuf>,
nav: Arc<Nav>,
cache: Arc<Mutex<Option<Cached>>>,
base: Arc<String>,
}
struct Cached {
stamp: Stamp,
bundle: Arc<view::Bundle>,
overview: Option<Arc<view::BundleView>>,
graph: Option<Arc<view::GraphView>>,
}
#[derive(PartialEq, Eq, Clone, Copy)]
struct Stamp {
files: usize,
newest: Option<std::time::SystemTime>,
}
fn stamp(root: &std::path::Path) -> Stamp {
fn walk(dir: &std::path::Path, out: &mut Stamp) {
if let Ok(modified) = std::fs::metadata(dir).and_then(|m| m.modified()) {
out.newest = Some(out.newest.map_or(modified, |n| n.max(modified)));
}
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let Ok(kind) = entry.file_type() else {
continue;
};
let path = entry.path();
if kind.is_dir() {
walk(&path, out);
} else {
out.files += 1;
if let Ok(modified) = std::fs::symlink_metadata(&path).and_then(|m| m.modified()) {
out.newest = Some(out.newest.map_or(modified, |n| n.max(modified)));
}
}
}
}
let mut out = Stamp {
files: 0,
newest: None,
};
walk(root, &mut out);
out
}
impl Viewer {
fn bundle(&self) -> Result<Arc<view::Bundle>, rto_render::okf::inspect::InspectError> {
self.with_cache(|cached| Arc::clone(&cached.bundle))
}
fn overview(&self) -> Result<Arc<view::BundleView>, rto_render::okf::inspect::InspectError> {
let root = self.root.display().to_string();
self.with_cache(|cached| {
Arc::clone(
cached
.overview
.get_or_insert_with(|| Arc::new(view::overview_in(&cached.bundle, &root))),
)
})
}
fn graph(&self) -> Result<Arc<view::GraphView>, rto_render::okf::inspect::InspectError> {
self.with_cache(|cached| {
Arc::clone(
cached
.graph
.get_or_insert_with(|| Arc::new(view::graph_in(&cached.bundle))),
)
})
}
fn with_cache<T>(
&self,
f: impl FnOnce(&mut Cached) -> T,
) -> Result<T, rto_render::okf::inspect::InspectError> {
let current = stamp(&self.root);
let mut cache = self
.cache
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let stale = cache.as_ref().is_none_or(|cached| cached.stamp != current);
if stale {
*cache = Some(Cached {
stamp: current,
bundle: Arc::new(view::load(&self.root)?),
overview: None,
graph: None,
});
}
Ok(f(cache.as_mut().expect("just populated")))
}
}
async fn blocking<T, F>(work: F) -> Option<T>
where
F: FnOnce() -> T + Send + 'static,
T: Send + 'static,
{
tokio::task::spawn_blocking(work).await.ok()
}
fn spawn_failed() -> Response {
(
StatusCode::INTERNAL_SERVER_ERROR,
[(header::CONTENT_SECURITY_POLICY, CSP)],
Html("<p>The viewer failed to read the bundle.</p>"),
)
.into_response()
}
#[derive(Debug, Clone, Default)]
pub struct Nav {
pub bundle: Option<String>,
pub bundles: Option<String>,
pub explorer: Option<String>,
}
#[derive(Debug, Clone)]
pub struct Mount {
pub slug: String,
pub label: String,
pub origin: String,
pub root: PathBuf,
}
#[must_use]
pub fn slug(raw: &str) -> String {
let mut out = String::with_capacity(raw.len());
for c in raw.chars() {
if c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-' {
out.push(c);
} else if !out.ends_with('-') {
out.push('-');
}
}
let trimmed = out.trim_matches('-');
if trimmed.is_empty() {
"bundle".to_owned()
} else {
trimmed.to_owned()
}
}
const RESERVED_SLUGS: &[&str] = &["okf-viewer.css"];
pub fn disambiguate(mounts: &mut [Mount]) {
let mut seen: std::collections::BTreeSet<String> =
RESERVED_SLUGS.iter().map(|s| (*s).to_owned()).collect();
for m in mounts.iter_mut() {
if seen.insert(m.slug.clone()) {
continue;
}
for n in 2.. {
let candidate = format!("{}-{n}", m.slug);
if seen.insert(candidate.clone()) {
m.slug = candidate;
break;
}
}
}
}
pub fn mounts_router(base: &str, mounts: Vec<Mount>, explorer: Option<String>) -> Router {
assert_mountable(base);
let mut app = Router::new();
for m in &mounts {
let prefix = format!("{base}/{}", m.slug);
let nav = Nav {
bundle: Some(prefix.clone()),
bundles: (mounts.len() > 1).then(|| base.to_owned()),
explorer: explorer.clone(),
};
app = app.nest(&prefix, router(m.root.clone(), &prefix, nav));
}
let at = base.to_owned();
let owned = Arc::new(base.to_owned());
let shared = Arc::new(mounts);
let app = app.route(&format!("{base}/okf-viewer.css"), get(stylesheet));
app.route(
&at,
get(move || {
let (base, mounts) = (Arc::clone(&owned), Arc::clone(&shared));
let explorer = explorer.clone();
async move { chooser(&base, &mounts, explorer.as_deref()) }
}),
)
}
fn index_href(base: &str) -> &str {
if base.is_empty() { "/" } else { base }
}
fn chooser(base: &str, mounts: &[Mount], explorer: Option<&str>) -> Response {
if let [only] = mounts {
return Redirect::temporary(&format!("{base}/{}", only.slug)).into_response();
}
let mut body = String::with_capacity(512 + mounts.len() * 256);
if mounts.is_empty() {
body.push_str(
"<article><h1>OKF bundles</h1><p class=\"scope\">No bundle is mounted. \
A project gets one when <code>roteiro render okf</code> writes it, and \
this server picks it up on the next start.</p></article>",
);
} else {
let _ = write!(
body,
"<article><h1>OKF bundles</h1><p class=\"scope\">{} bundles are mounted \
here. Each is served read-only from the directory named beside it.</p>\
<ol class=\"hubs\">",
mounts.len()
);
for m in mounts {
let _ = write!(
body,
"<li><a href=\"{}/{}\">{}</a> <span class=\"deg\">{}</span></li>",
escape(base),
escape(&m.slug),
escape(&m.label),
escape(&m.origin)
);
}
body.push_str("</ol></article>");
}
page(
"OKF bundles",
"",
base,
&Nav {
bundle: None,
bundles: None,
explorer: explorer.map(ToOwned::to_owned),
},
&body,
)
}
fn assert_mountable(base: &str) {
assert!(
base.is_empty()
|| (base.starts_with('/')
&& base.split('/').skip(1).all(|seg| !seg.is_empty()
&& seg
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')))),
"mount path is not slug-safe and must not be written into markup: {base:?}"
);
}
pub fn router(root: PathBuf, base: &str, nav: Nav) -> Router {
assert_mountable(base);
let state = Viewer {
root: Arc::new(root),
base: Arc::new(base.to_owned()),
nav: Arc::new(nav),
cache: Arc::new(Mutex::new(None)),
};
Router::new()
.route("/", get(index))
.route("/graph", get(graph_page))
.route("/api/graph.json", get(graph_json))
.route("/c/{*id}", get(concept))
.route("/f/{*path}", get(file))
.route("/okf-viewer.css", get(stylesheet))
.route("/cytoscape.min.js", get(cytoscape))
.with_state(state)
}
const STYLE: &str = include_str!("assets/okf-viewer.css");
const CYTOSCAPE: &str = include_str!("assets/cytoscape.min.js");
const CSP: &str = "default-src 'self'; img-src 'self'; object-src 'none'; base-uri 'none'";
const FILE_CSP: &str = "default-src 'none'; sandbox; base-uri 'none'";
const MAX_FILE_BYTES: u64 = 32 * 1024 * 1024;
const CACHE_ASSET: &str = "public, max-age=3600";
fn page(title: &str, root: &str, base: &str, nav: &Nav, body: &str) -> Response {
let mut up = String::new();
if let Some(bundles) = &nav.bundles {
let _ = write!(up, "<a href=\"{}\">All bundles</a>", escape(bundles));
}
if let Some(explorer) = &nav.explorer {
let _ = write!(up, "<a href=\"{}\">Explorer</a>", escape(explorer));
}
let mut here = String::new();
if let Some(bundle) = &nav.bundle {
let _ = write!(
here,
"<a href=\"{}\">Concepts</a><a href=\"{}/graph\">Graph</a>",
escape(index_href(bundle)),
escape(bundle)
);
}
let mut out = String::with_capacity(body.len() + 2048);
let _ = write!(
out,
"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\
<link rel=\"stylesheet\" href=\"{base}/okf-viewer.css\">\
<title>{} — OKF viewer</title></head><body>\
<header><span class=\"name\">OKF viewer</span>\
<span class=\"root\">{}</span>\
<nav>{here}\
{up}</nav></header>\
<main>{body}</main>\
<footer>Read-only. Nothing here is imported into the graph — \
<code>roteiro import --from okf</code> is still the only path that does, \
and it asks first.</footer></body></html>",
escape(title),
escape(root),
);
(
[
(header::CONTENT_TYPE, "text/html; charset=utf-8"),
(header::CONTENT_SECURITY_POLICY, CSP),
],
Html(out),
)
.into_response()
}
fn escape(raw: &str) -> String {
let mut out = String::with_capacity(raw.len());
for c in raw.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(c),
}
}
out
}
fn unreadable(err: &rto_render::okf::inspect::InspectError) -> Response {
(
StatusCode::NOT_FOUND,
[(header::CONTENT_SECURITY_POLICY, CSP)],
Html(format!(
"<p>Not a readable OKF bundle: {}</p>",
escape(&err.to_string())
)),
)
.into_response()
}
fn pill(class: &str, text: &str) -> String {
format!(
"<span class=\"tier tier-{}\">{}</span>",
escape(class),
escape(text)
)
}
async fn index(State(v): State<Viewer>) -> Response {
let state = v.clone();
let built = blocking(move || state.overview()).await;
let view = match built {
Some(Ok(view)) => view,
Some(Err(e)) => return unreadable(&e),
None => return spawn_failed(),
};
let base = v.base.as_str();
let mut body = String::new();
body.push_str("<aside>");
let _ = write!(
body,
"<div class=\"group\">{} concept(s)</div><ol>",
view.concepts.len()
);
for c in &view.concepts {
let _ = write!(
body,
"<li><a href=\"{base}/c/{}\">{}</a></li>",
escape(&c.id),
escape(&c.title)
);
}
body.push_str("</ol></aside><article>");
let _ = write!(
body,
"<h1>Bundle</h1><ul class=\"counts\">\
<li><span class=\"n\">{}</span> human-reviewed</li>\
<li><span class=\"n\">{}</span> machine-confirmed</li>\
<li><span class=\"n\">{}</span> unverified</li>\
<li><span class=\"n\">{}</span> unresolved link(s)</li>\
<li>okf_version {}</li></ul>",
view.human_reviewed,
view.machine_confirmed,
view.unverified,
view.broken_links,
view.okf_version
.as_deref()
.map_or_else(|| "not declared".to_owned(), escape),
);
if !view.flagged.is_empty() {
let _ = write!(
body,
"<div class=\"screened\"><strong>{} concept(s) tripped the content screener.</strong> \
This is a bundle somebody else wrote, and the screener looks for text shaped to be \
read as instructions rather than as content. Nothing has been imported.<ul>",
view.flagged.len()
);
for f in &view.flagged {
let _ = write!(
body,
"<li><a href=\"{base}/c/{}\">{}</a> — {} ({})</li>",
escape(&f.id),
escape(&f.id),
escape(&f.verdict),
f.classes
.iter()
.map(|c| format!("<code>{}</code>", escape(c)))
.collect::<Vec<_>>()
.join(", ")
);
}
body.push_str("</ul></div>");
}
body.push_str("<table><tr><th>Concept</th><th>Type</th><th>Trust</th><th>Status</th></tr>");
for c in &view.concepts {
let status_class = if c.status == "deprecated" {
" class=\"status-deprecated\""
} else {
""
};
let _ = write!(
body,
"<tr><td><a href=\"{base}/c/{}\">{}</a></td><td>{}</td><td>{}</td><td{status_class}>{}</td></tr>",
escape(&c.id),
escape(&c.title),
c.kind.as_deref().map_or_else(String::new, escape),
pill(c.trust, c.trust),
escape(&c.status),
);
}
body.push_str("</table></article>");
page("Bundle", &view.root, base, &v.nav, &body)
}
async fn concept(State(v): State<Viewer>, UrlPath(id): UrlPath<String>) -> Response {
let state = v.clone();
let wanted = id.clone();
let built = blocking(move || {
let bundle = state.bundle()?;
Ok::<_, rto_render::okf::inspect::InspectError>(view::concept_in(
&bundle,
&wanted,
&state.base,
))
})
.await;
let base = v.base.as_str();
let found = match built {
Some(Ok(found)) => found,
Some(Err(e)) => return unreadable(&e),
None => return spawn_failed(),
};
let Some(c) = found else {
return (
StatusCode::NOT_FOUND,
[(header::CONTENT_SECURITY_POLICY, CSP)],
Html(format!(
"<p>The bundle contains no concept <code>{}</code>. \
<a href=\"{}\">Back to the bundle</a>.</p>",
escape(&id),
index_href(base)
)),
)
.into_response();
};
let mut body = String::from("<article>");
if !c.screen.is_empty() {
let _ = write!(
body,
"<div class=\"screened\"><strong>The content screener flagged this document.</strong> \
It is somebody else's text and may be written to be read as instructions. \
Classes: {}.</div>",
c.screen
.iter()
.map(|s| format!("<code>{}</code>", escape(s)))
.collect::<Vec<_>>()
.join(", ")
);
}
let _ = write!(
body,
"<h1>{}</h1><p class=\"meta\">{}{}{}<code>{}</code></p>{}",
escape(&c.title),
pill(c.trust, c.trust),
c.kind
.as_deref()
.map_or_else(String::new, |k| format!("<span>{}</span>", escape(k))),
format_args!("<span>{}</span>", escape(&c.status)),
escape(&c.path),
c.body_html,
);
let _ = write!(
body,
"<p class=\"scope\"><a href=\"{base}/graph?focus={}\">See this concept \
in the graph</a></p>",
urlencode(&c.id)
);
body.push_str("<div class=\"rel\">");
if !c.links.is_empty() {
body.push_str("<h2>Links out</h2><ul>");
for l in &c.links {
if l.exists {
let _ = write!(
body,
"<li><a href=\"{base}/c/{}\">{}</a></li>",
escape(&l.target),
escape(&l.target)
);
} else {
let _ = write!(
body,
"<li class=\"absent\">{} — not in this bundle</li>",
escape(&l.target)
);
}
}
body.push_str("</ul>");
}
if !c.backlinks.is_empty() {
body.push_str("<h2>Linked from</h2><ul>");
for b in &c.backlinks {
let _ = write!(
body,
"<li><a href=\"{base}/c/{}\">{}</a></li>",
escape(b),
escape(b)
);
}
body.push_str("</ul>");
}
body.push_str("</div></article>");
page(&c.title, &v.root.display().to_string(), base, &v.nav, &body)
}
#[derive(Debug, serde::Deserialize)]
struct GraphQuery {
focus: Option<String>,
depth: Option<usize>,
limit: Option<usize>,
}
const MAX_DEPTH: usize = 3;
const MAX_NODES: usize = 500;
const DEFAULT_NODES: usize = 150;
const HUB_LIST: usize = 40;
impl GraphQuery {
fn focus(&self) -> Option<&str> {
self.focus
.as_deref()
.map(str::trim)
.filter(|f| !f.is_empty())
}
fn depth(&self) -> usize {
self.depth.unwrap_or(1).clamp(1, MAX_DEPTH)
}
fn limit(&self) -> usize {
self.limit.unwrap_or(DEFAULT_NODES).clamp(1, MAX_NODES)
}
}
async fn graph_page(State(v): State<Viewer>, Query(q): Query<GraphQuery>) -> Response {
const SCRIPT: &str = "<article><h1>Concept graph</h1>\
<p class=\"scope\" id=\"scope\">Loading…</p>\
<div id=\"graph\"></div>\
<script src=\"{BASE}/cytoscape.min.js\"></script>\
<script>\
var Q='focus={FOCUS}&depth={DEPTH}&limit={LIMIT}';\
var n=document.getElementById('scope');\
fetch('{BASE}/api/graph.json?'+Q).then(r=>r.json().then(g=>({ok:r.ok,g:g})))\
.catch(e=>({ok:false,g:{error:String(e)}})).then(res=>{\
if(!res.ok||!res.g.scope){\
n.textContent=res.g.error||'The graph could not be read.';return;}\
var g=res.g,s=g.scope;\
n.textContent='Showing '+s.shown_nodes+' of '+s.total_nodes+\
' concepts and '+s.shown_edges+' of '+s.total_edges+' links, '+\
s.depth+(s.depth==1?' hop':' hops')+' from '+s.focus+\
(s.beyond?'. '+s.beyond+' more connected concepts are not drawn.':'.');\
if(s.beyond&&{CAN_EXPAND}){var a=document.createElement('a');\
a.href='{BASE}/graph?focus='+encodeURIComponent(s.focus)+\
'&depth={NEXT_DEPTH}&limit={NEXT_LIMIT}';\
a.textContent=' Show more.';n.appendChild(a);}\
else if(s.beyond){n.textContent+=' This is the most this page draws.';}\
var cy=cytoscape({container:document.getElementById('graph'),\
elements:[...g.nodes.map(n=>({data:{id:n.id,label:n.label,trust:n.trust,\
focus:n.id===s.focus?'yes':'no'}})),\
...g.edges.map(e=>({data:{source:e.source,target:e.target}}))],\
layout:{name:'concentric',concentric:n=>n.data('focus')==='yes'?Infinity:n.degree(),\
levelWidth:()=>1,minNodeSpacing:24},style:[\
{selector:'node',style:{'label':'data(label)','font-size':'8px',\
'background-color':'#6b7684','color':'#1a2733'}},\
{selector:'node[trust=\"human-reviewed\"]',style:{'background-color':'#0e6e8c'}},\
{selector:'node[focus=\"yes\"]',style:{'background-color':'#b4531f',\
'font-size':'12px','font-weight':'bold'}},\
{selector:'edge',style:{'width':1,'line-color':'#d8d2c4',\
'target-arrow-shape':'triangle','target-arrow-color':'#d8d2c4',\
'curve-style':'bezier'}}]});\
cy.on('tap','node',e=>{location.href='{BASE}/graph?focus='+\
encodeURIComponent(e.target.id())+'&depth={DEPTH}&limit={LIMIT}';});\
});</script></article>";
let Some(focus) = q.focus().map(ToOwned::to_owned) else {
return graph_entry(&v).await;
};
let base = v.base.as_str();
let depth = q.depth();
let limit = q.limit();
let body = SCRIPT
.replace("{BASE}", base)
.replace("{FOCUS}", &urlencode(&focus))
.replace("{DEPTH}", &depth.to_string())
.replace("{LIMIT}", &limit.to_string())
.replace(
"{CAN_EXPAND}",
if limit < MAX_NODES || depth < MAX_DEPTH {
"true"
} else {
"false"
},
)
.replace("{NEXT_LIMIT}", &(limit * 2).min(MAX_NODES).to_string())
.replace(
"{NEXT_DEPTH}",
&if limit >= MAX_NODES {
(depth + 1).min(MAX_DEPTH)
} else {
depth
}
.to_string(),
);
let mut res = page(
"Concept graph",
&v.root.display().to_string(),
base,
&v.nav,
&body,
);
res.headers_mut().insert(
header::CONTENT_SECURITY_POLICY,
header::HeaderValue::from_static(
"default-src 'self'; script-src 'self' 'unsafe-inline'; \
img-src 'self'; object-src 'none'; base-uri 'none'",
),
);
res
}
async fn graph_entry(v: &Viewer) -> Response {
let built = blocking({
let v = v.clone();
move || v.graph()
})
.await;
let graph = match built {
Some(Ok(graph)) => graph,
Some(Err(e)) => return unreadable(&e),
None => return spawn_failed(),
};
let base = v.base.as_str();
let hubs = view::hubs(&graph, HUB_LIST);
let mut body = String::with_capacity(4096);
let _ = write!(
body,
"<article><h1>Concept graph</h1><p class=\"scope\">This bundle holds \
{} concepts and {} links between them — too many to draw at once, and \
too sparsely connected between its hubs for any single picture to mean \
much. Pick a concept to centre the graph on; its neighbourhood is drawn \
from there.</p><ol class=\"hubs\">",
graph.nodes.len(),
graph.edges.len()
);
for hub in &hubs {
let _ = write!(
body,
"<li><a href=\"{base}/graph?focus={}\">{}</a> \
<span class=\"deg\">{} connected</span></li>",
urlencode(&hub.id),
escape(&hub.label),
hub.degree
);
}
body.push_str("</ol></article>");
page(
"Concept graph",
&v.root.display().to_string(),
base,
&v.nav,
&body,
)
}
fn urlencode(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 8);
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(char::from(b));
}
_ => {
let _ = write!(out, "%{b:02X}");
}
}
}
out
}
async fn graph_json(State(v): State<Viewer>, Query(q): Query<GraphQuery>) -> Response {
let built = blocking({
let v = v.clone();
move || v.graph()
})
.await;
let graph = match built {
Some(Ok(graph)) => graph,
Some(Err(e)) => return unreadable(&e),
None => return spawn_failed(),
};
let payload = match q.focus() {
Some(focus) => match view::neighbourhood(&graph, focus, q.depth(), q.limit()) {
Some(scoped) => serde_json::to_string(&scoped),
None => {
return (
StatusCode::NOT_FOUND,
[
(header::CONTENT_TYPE, "application/json"),
(header::CONTENT_SECURITY_POLICY, CSP),
],
serde_json::json!({ "error": format!("no concept {focus}") }).to_string(),
)
.into_response();
}
},
None => serde_json::to_string(&serde_json::json!({
"hubs": view::hubs(&graph, HUB_LIST),
"total_nodes": graph.nodes.len(),
"total_edges": graph.edges.len(),
})),
};
let Ok(body) = payload else {
return (
StatusCode::INTERNAL_SERVER_ERROR,
[
(header::CONTENT_TYPE, "application/json"),
(header::CONTENT_SECURITY_POLICY, CSP),
],
r#"{"error":"the graph could not be serialised"}"#,
)
.into_response();
};
(
[
(header::CONTENT_TYPE, "application/json"),
(header::CONTENT_SECURITY_POLICY, CSP),
],
body,
)
.into_response()
}
async fn file(State(v): State<Viewer>, UrlPath(path): UrlPath<String>) -> Response {
let refused = || {
(
StatusCode::NOT_FOUND,
[(header::CONTENT_SECURITY_POLICY, FILE_CSP)],
)
.into_response()
};
let root = Arc::clone(&v.root);
let wanted = path.clone();
let read = blocking(move || {
let resolved = view::safe_bundle_file(&root, &wanted)?;
let meta = std::fs::metadata(&resolved).ok()?;
if meta.len() > MAX_FILE_BYTES {
return None;
}
let bytes = std::fs::read(&resolved).ok()?;
Some((resolved, bytes))
})
.await;
let Some(Some((resolved, bytes))) = read else {
return refused();
};
let mime = match resolved
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase)
.as_deref()
{
Some("png") => "image/png",
Some("jpg" | "jpeg") => "image/jpeg",
Some("gif") => "image/gif",
Some("webp") => "image/webp",
Some("svg") => "image/svg+xml",
_ => "application/octet-stream",
};
let mut response = (
[
(header::CONTENT_TYPE, mime),
(header::CONTENT_SECURITY_POLICY, FILE_CSP),
(header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
],
bytes,
)
.into_response();
if mime == "application/octet-stream" {
response.headers_mut().insert(
header::CONTENT_DISPOSITION,
header::HeaderValue::from_static("attachment"),
);
}
response
}
async fn stylesheet() -> Response {
(
[
(header::CONTENT_TYPE, "text/css; charset=utf-8"),
(header::CONTENT_SECURITY_POLICY, CSP),
(header::CACHE_CONTROL, CACHE_ASSET),
],
STYLE,
)
.into_response()
}
async fn cytoscape() -> Response {
(
[
(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
),
(header::CONTENT_SECURITY_POLICY, CSP),
(header::CACHE_CONTROL, CACHE_ASSET),
],
CYTOSCAPE,
)
.into_response()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_viewer_shares_the_sites_palette() {
let site =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../website/public/style.css");
let Ok(site_css) = std::fs::read_to_string(&site) else {
return;
};
let palette = |css: &str| {
css.lines()
.find(|l| l.trim_start().starts_with(":root") && l.contains("--ink"))
.map(|l| l.trim().to_owned())
};
let theirs = palette(&site_css).expect("the site declares a palette");
let ours = palette(STYLE).expect("the viewer declares a palette");
assert_eq!(
ours, theirs,
"the viewer's palette has drifted from the site's. Copy \
`website/public/style.css`'s `:root` line into \
`crates/roteiro/src/assets/okf-viewer.css`."
);
}
#[test]
fn interpolated_text_is_escaped() {
let out = escape(r#"<img src=x onerror="alert(1)">&'"#);
assert!(!out.contains('<'), "{out}");
assert!(!out.contains('>'), "{out}");
assert!(!out.contains('"'), "{out}");
assert_eq!(
out,
"<img src=x onerror="alert(1)">&'"
);
}
#[test]
fn a_hostile_title_cannot_escape_the_shell() {
let html = page(
"</title><script>alert(1)</script>",
"/tmp/b",
"",
&Nav::default(),
"<article/>",
);
let body = format!("{html:?}");
assert!(!body.contains("<script>alert"), "{body}");
}
use axum::body::Body;
use axum::http::Request;
use tower::ServiceExt as _;
fn fixture(tag: &str, files: &[(&str, &str)]) -> PathBuf {
static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let root = std::env::temp_dir().join(format!(
"roteiro-okf-view-{}-{seq}-{tag}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&root);
for (rel, content) in files {
let path = root.join(rel);
std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
std::fs::write(&path, content).expect("write");
}
root
}
async fn get_(root: &std::path::Path, base: &str, uri: &str) -> (StatusCode, String) {
let nav = Nav {
bundle: Some(base.to_owned()),
..Nav::default()
};
let response = router(root.to_path_buf(), base, nav)
.oneshot(
Request::builder()
.uri(uri)
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
let status = response.status();
let bytes = axum::body::to_bytes(response.into_body(), 1 << 22)
.await
.expect("body");
(status, String::from_utf8_lossy(&bytes).into_owned())
}
fn sample() -> PathBuf {
fixture(
"routes",
&[
("index.md", "---\nokf_version: \"0.2\"\n---\n\n# Bundle\n"),
(
"metrics/revenue.md",
"---\ntype: Metric\ntitle: Revenue\nverified: { by: human:alice, \
at: 2026-08-01T10:00:00Z }\n---\n\n# Revenue\n\nSee \
[cost](/metrics/cost.md).\n",
),
(
"metrics/cost.md",
"---\ntype: Metric\ntitle: Cost\n---\n\n# Cost\n",
),
],
)
}
fn crowded() -> PathBuf {
let hub = format!(
"---\ntype: Metric\ntitle: Hub\n---\n\n# Hub\n\n{}\n",
(0..8)
.map(|i| format!("[leaf {i}](/leaf/leaf-{i}.md)"))
.collect::<Vec<_>>()
.join(" ")
);
let leaves: Vec<(String, String)> = (0..8)
.map(|i| {
(
format!("leaf/leaf-{i}.md"),
format!("---\ntype: Metric\ntitle: Leaf {i}\n---\n\n# Leaf {i}\n"),
)
})
.collect();
let mut files: Vec<(&str, &str)> = vec![
("index.md", "---\nokf_version: \"0.2\"\n---\n\n# Bundle\n"),
("hub/hub.md", hub.as_str()),
];
files.extend(leaves.iter().map(|(a, b)| (a.as_str(), b.as_str())));
fixture("crowded", &files)
}
#[tokio::test]
async fn the_index_lists_the_bundle_and_counts_its_tiers() {
let root = sample();
let (status, body) = get_(&root, "", "/").await;
assert_eq!(status, StatusCode::OK);
assert!(body.contains("Revenue"), "{body}");
assert!(body.contains("href=\"/c/metrics/revenue\""), "{body}");
assert!(body.contains("human-reviewed"), "{body}");
let _ = std::fs::remove_dir_all(&root);
}
#[tokio::test]
async fn a_concept_renders_with_its_links_and_backlinks() {
let root = sample();
let (status, body) = get_(&root, "", "/c/metrics/revenue").await;
assert_eq!(status, StatusCode::OK);
assert!(body.contains("href=\"/c/metrics/cost\""), "{body}");
let (_, cost) = get_(&root, "", "/c/metrics/cost").await;
assert!(cost.contains("Linked from"), "{cost}");
assert!(cost.contains("metrics/revenue"), "{cost}");
let _ = std::fs::remove_dir_all(&root);
}
#[tokio::test]
async fn the_graph_entry_page_is_a_list_and_not_the_whole_graph() {
let root = sample();
let (status, body) = get_(&root, "", "/graph").await;
assert_eq!(status, StatusCode::OK);
assert!(
body.contains("Pick a concept to centre the graph on"),
"the entry page says how to start: {body}"
);
assert!(
body.contains("/graph?focus="),
"and offers concepts to start from"
);
assert!(
!body.contains("cytoscape.min.js"),
"and draws nothing, so it costs no layout at all"
);
let _ = std::fs::remove_dir_all(&root);
}
#[tokio::test]
async fn a_focused_view_states_what_it_is_showing_and_of_how_much() {
let root = sample();
let (status, body) = get_(&root, "", "/graph?focus=metrics/revenue").await;
assert_eq!(status, StatusCode::OK);
assert!(body.contains("id=\"scope\""), "a scope line exists: {body}");
assert!(
body.contains("focus=metrics%2Frevenue"),
"and the fetch is scoped to the focus rather than the whole graph"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn the_graph_query_clamps_what_a_caller_may_ask_for() {
let asked = GraphQuery {
focus: None,
depth: Some(99),
limit: Some(999_999),
};
assert_eq!(asked.depth(), MAX_DEPTH, "depth is clamped");
assert_eq!(asked.limit(), MAX_NODES, "and so is the node budget");
let silent = GraphQuery {
focus: None,
depth: None,
limit: None,
};
assert_eq!(silent.depth(), 1, "a silent caller gets one hop");
assert_eq!(silent.limit(), DEFAULT_NODES);
let zero = GraphQuery {
focus: None,
depth: Some(0),
limit: Some(0),
};
assert_eq!(
zero.depth(),
1,
"depth 0 is a concept page with extra steps, so it is not offered"
);
assert_eq!(zero.limit(), 1, "and the focus is always drawn");
}
#[tokio::test]
async fn the_graph_api_honours_the_budget_it_was_given() {
let root = crowded();
let (status, body) = get_(&root, "", "/api/graph.json?focus=hub/hub&limit=3&depth=1").await;
assert_eq!(status, StatusCode::OK);
let json: serde_json::Value = serde_json::from_str(&body).expect("json");
assert_eq!(
json["scope"]["shown_nodes"], 3,
"the budget binds rather than being ignored: {body}"
);
assert_eq!(
json["scope"]["total_nodes"], 9,
"against a bundle that holds more"
);
assert_eq!(
json["scope"]["beyond"], 6,
"and the six it did not draw are counted, not dropped: {body}"
);
let _ = std::fs::remove_dir_all(&root);
}
#[tokio::test]
async fn the_focus_outranks_every_neighbour_by_construction() {
let root = sample();
let (status, body) = get_(&root, "", "/graph?focus=metrics/revenue").await;
assert_eq!(status, StatusCode::OK);
assert!(
body.contains("?Infinity:n.degree()"),
"the focus must not be ranked by a constant a degree can exceed: {body}"
);
let _ = std::fs::remove_dir_all(&root);
}
#[tokio::test]
async fn the_inline_script_is_javascript_that_parses() {
let Ok(node) = std::process::Command::new("node").arg("--version").output() else {
eprintln!("SKIP: no `node` to parse the script with");
return;
};
if !node.status.success() {
eprintln!("SKIP: `node --version` failed");
return;
}
let root = sample();
let (_, body) = get_(&root, "", "/graph?focus=metrics/revenue").await;
let script = body
.rsplit_once("<script>")
.and_then(|(_, tail)| tail.split_once("</script>"))
.map(|(js, _)| js.to_owned())
.expect("the focused page carries an inline script");
let dir = fixture("script-check", &[("check.js", &script)]);
let out = std::process::Command::new("node")
.arg("--check")
.arg(dir.join("check.js"))
.output()
.expect("run node");
assert!(
out.status.success(),
"the inline script does not parse:\n{}\n--- script ---\n{script}",
String::from_utf8_lossy(&out.stderr)
);
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&root);
}
#[tokio::test]
async fn an_empty_focus_is_the_entry_list_on_both_routes() {
let root = sample();
for uri in ["/graph?focus=", "/graph?focus=%20", "/graph"] {
let (status, body) = get_(&root, "", uri).await;
assert_eq!(status, StatusCode::OK, "{uri}");
assert!(
body.contains("Pick a concept to centre the graph on"),
"{uri} must reach the entry list: {body}"
);
}
for uri in [
"/api/graph.json?focus=",
"/api/graph.json?focus=%20",
"/api/graph.json",
] {
let (status, body) = get_(&root, "", uri).await;
assert_eq!(status, StatusCode::OK, "{uri}: {body}");
let json: serde_json::Value =
serde_json::from_str(&body).unwrap_or_else(|e| panic!("{uri}: {e}: {body}"));
assert!(
json.get("hubs").is_some(),
"{uri} must answer with the entry list, not a 404: {body}"
);
}
let _ = std::fs::remove_dir_all(&root);
}
#[tokio::test]
async fn show_more_is_offered_only_when_it_can_widen_something() {
let root = sample();
let (_, growable) = get_(&root, "", "/graph?focus=metrics/revenue&limit=10&depth=1").await;
assert!(
growable.contains("&&true)"),
"with room to grow, expanding is offered: {growable}"
);
let (_, maxed) = get_(
&root,
"",
&format!("/graph?focus=metrics/revenue&limit={MAX_NODES}&depth={MAX_DEPTH}"),
)
.await;
assert!(
maxed.contains("&&false)"),
"at both maxima it is not: {maxed}"
);
assert!(
maxed.contains("This is the most this page draws"),
"and the page says so instead of going quiet: {maxed}"
);
let _ = std::fs::remove_dir_all(&root);
}
#[tokio::test]
async fn the_page_reads_an_error_response_rather_than_throwing_on_it() {
let root = sample();
let (status, body) = get_(&root, "", "/graph?focus=nonesuch").await;
assert_eq!(status, StatusCode::OK, "the page itself renders");
assert!(
body.contains("if(!res.ok||!res.g.scope){"),
"the script checks the response before reading it: {body}"
);
assert!(
body.contains("The graph could not be read."),
"and has something to say when it cannot: {body}"
);
let _ = std::fs::remove_dir_all(&root);
}
#[tokio::test]
async fn an_unknown_focus_is_a_404_carrying_json_a_client_can_read() {
let root = sample();
let (status, body) = get_(&root, "", "/api/graph.json?focus=nonesuch").await;
assert_eq!(status, StatusCode::NOT_FOUND);
let json: serde_json::Value =
serde_json::from_str(&body).unwrap_or_else(|e| panic!("{e}: {body}"));
assert_eq!(json["error"], "no concept nonesuch");
let (status, body) = get_(&root, "", "/api/graph.json?focus=a%22b").await;
assert_eq!(status, StatusCode::NOT_FOUND);
let json: serde_json::Value =
serde_json::from_str(&body).unwrap_or_else(|e| panic!("{e}: {body}"));
assert_eq!(json["error"], r#"no concept a"b"#);
let _ = std::fs::remove_dir_all(&root);
}
#[tokio::test]
async fn an_unknown_concept_is_a_404() {
let root = sample();
let (status, body) = get_(&root, "", "/c/metrics/nope").await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert!(body.contains("no concept"), "{body}");
assert!(body.contains("<code>metrics/nope</code>"), "{body}");
assert!(body.contains("<a href=\"/\">Back to the bundle"), "{body}");
let _ = std::fs::remove_dir_all(&root);
}
#[tokio::test]
async fn a_file_outside_the_image_allow_list_is_offered_as_an_attachment() {
let root = fixture(
"disposition",
&[
("index.md", "---\nokf_version: \"0.2\"\n---\n\n# B\n"),
("img/logo.svg", "<svg/>"),
("docs/policy.pdf", "%PDF-1.4 not really"),
],
);
let disposition = |uri: &'static str| {
let root = root.clone();
async move {
let response = router(root, "", Nav::default())
.oneshot(
Request::builder()
.uri(uri)
.body(Body::empty())
.expect("req"),
)
.await
.expect("response");
response
.headers()
.get(header::CONTENT_DISPOSITION)
.map(|v| v.to_str().expect("ascii").to_owned())
}
};
assert_eq!(
disposition("/f/docs/policy.pdf").await.as_deref(),
Some("attachment"),
"a type the viewer will not render is handed over, not shown"
);
assert_eq!(
disposition("/f/img/logo.svg").await,
None,
"an image the viewer embeds is not an attachment"
);
let _ = std::fs::remove_dir_all(&root);
}
#[tokio::test]
async fn a_bundle_file_is_served_under_a_stricter_policy_than_a_page() {
let root = fixture(
"file-csp",
&[
("index.md", "---\nokf_version: \"0.2\"\n---\n\n# B\n"),
("img/logo.svg", "<svg/>"),
],
);
let policy = |uri: &'static str| {
let root = root.clone();
async move {
let response = router(root, "", Nav::default())
.oneshot(
Request::builder()
.uri(uri)
.body(Body::empty())
.expect("req"),
)
.await
.expect("response");
response
.headers()
.get(header::CONTENT_SECURITY_POLICY)
.expect("every response carries a policy")
.to_str()
.expect("ascii")
.to_owned()
}
};
let file = policy("/f/img/logo.svg").await;
let page = policy("/").await;
assert_ne!(file, page, "a peer's bytes do not get the page's policy");
assert!(
file.contains("sandbox"),
"a directly-opened file is sandboxed: {file}"
);
assert!(
file.contains("default-src 'none'"),
"and fetches nothing: {file}"
);
assert!(
!file.contains("'self'"),
"`'self'` is what let an SVG reach the rest of the bundle: {file}"
);
let missing = policy("/f/img/absent.png").await;
assert_eq!(missing, file, "a refusal carries the same policy as a hit");
let _ = std::fs::remove_dir_all(&root);
}
#[cfg(unix)]
#[test]
fn a_symlinked_directory_does_not_make_the_stamp_walk_forever() {
use std::os::unix::fs::symlink;
let root = fixture(
"stamp-loop",
&[("index.md", "---\nokf_version: \"0.2\"\n---\n\n# B\n")],
);
symlink("..", root.join("loop")).expect("symlink to the parent");
symlink("/", root.join("everything")).expect("symlink to the root");
let first = stamp(&root);
assert_eq!(first.files, 3, "the links count as files, not as trees");
assert!(
first == stamp(&root),
"the stamp is stable when nothing changed"
);
std::fs::write(root.join("second.md"), "---\ntype: Metric\n---\n\n# S\n").expect("write");
let added = stamp(&root);
assert!(first != added, "a new file changes the stamp");
std::thread::sleep(std::time::Duration::from_millis(1100));
std::fs::rename(root.join("second.md"), root.join("renamed.md")).expect("rename");
assert!(
added != stamp(&root),
"a rename must change the stamp, or the viewer serves a concept that \
no longer exists under that id"
);
let alias = root.with_extension("alias");
let _ = std::fs::remove_file(&alias);
symlink(&root, &alias).expect("symlink the root");
let via_alias = stamp(&alias);
std::thread::sleep(std::time::Duration::from_millis(1100));
std::fs::rename(root.join("renamed.md"), root.join("again.md")).expect("rename");
assert!(
via_alias != stamp(&alias),
"a rename must change the stamp seen through a symlinked root too"
);
let _ = std::fs::remove_file(&alias);
let _ = std::fs::remove_dir_all(&root);
}
#[tokio::test]
async fn the_file_route_refuses_a_file_too_large_to_hold() {
let root = fixture(
"big-file",
&[
("index.md", "---\nokf_version: \"0.2\"\n---\n\n# B\n"),
("img/logo.svg", "<svg/>"),
],
);
let big = root.join("img/huge.bin");
let handle = std::fs::File::create(&big).expect("create");
handle
.set_len(MAX_FILE_BYTES + 1)
.expect("size the file past the bound");
drop(handle);
let (status, _) = get_(&root, "", "/f/img/huge.bin").await;
assert_eq!(
status,
StatusCode::NOT_FOUND,
"a file past the bound must not be served"
);
let (ok, body) = get_(&root, "", "/f/img/logo.svg").await;
assert_eq!(ok, StatusCode::OK);
assert!(body.contains("svg"), "{body}");
let _ = std::fs::remove_dir_all(&root);
}
#[tokio::test]
async fn the_file_route_serves_only_from_inside_the_bundle() {
let root = fixture(
"files",
&[
("index.md", "---\nokf_version: \"0.2\"\n---\n\n# B\n"),
("img/logo.svg", "<svg/>"),
],
);
let outside = root.parent().expect("parent").join("outside.txt");
std::fs::write(&outside, "secret").expect("write");
let (ok, body) = get_(&root, "", "/f/img/logo.svg").await;
assert_eq!(ok, StatusCode::OK);
assert!(body.contains("svg"), "{body}");
for hostile in [
"/f/../outside.txt",
"/f/img/../../outside.txt",
"/f/..%2Foutside.txt",
"/f/img/absent.png",
] {
let (status, body) = get_(&root, "", hostile).await;
assert_ne!(
status,
StatusCode::OK,
"`{hostile}` must not be served: {body}"
);
assert!(!body.contains("secret"), "`{hostile}` leaked: {body}");
}
let _ = std::fs::remove_file(&outside);
let _ = std::fs::remove_dir_all(&root);
}
#[tokio::test]
async fn a_nested_mount_prefixes_every_href() {
let root = sample();
let (status, body) = get_(&root, "/okf", "/").await;
assert_eq!(status, StatusCode::OK);
assert!(body.contains("href=\"/okf/c/metrics/revenue\""), "{body}");
assert!(body.contains("href=\"/okf/okf-viewer.css\""), "{body}");
assert!(body.contains("href=\"/okf/graph\""), "{body}");
assert!(
!body.contains("href=\"/c/"),
"an unprefixed href would 404 when nested: {body}"
);
let (status, page) = get_(&root, "/okf", "/c/metrics/revenue").await;
assert_eq!(status, StatusCode::OK);
assert!(
page.contains("href=\"/okf/c/metrics/cost\""),
"a link in the body must carry the prefix: {page}"
);
assert!(
!page.contains("href=\"/c/") && !page.contains("src=\"/f/"),
"no unprefixed href anywhere on the page: {page}"
);
let _ = std::fs::remove_dir_all(&root);
}
#[tokio::test]
async fn responses_carry_a_content_security_policy() {
let root = sample();
for uri in [
"/",
"/c/metrics/revenue",
"/c/does/not/exist",
"/graph",
"/api/graph.json",
"/okf-viewer.css",
"/cytoscape.min.js",
"/f/../escape",
] {
let response = router(root.clone(), "", Nav::default())
.oneshot(
Request::builder()
.uri(uri)
.body(Body::empty())
.expect("req"),
)
.await
.expect("response");
let csp = response
.headers()
.get(header::CONTENT_SECURITY_POLICY)
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_owned();
if uri.starts_with("/f/") {
assert!(csp.contains("default-src 'none'"), "{uri}: {csp}");
assert!(csp.contains("sandbox"), "{uri}: {csp}");
} else {
assert!(csp.contains("default-src 'self'"), "{uri}: {csp}");
assert!(csp.contains("object-src 'none'"), "{uri}: {csp}");
}
}
let _ = std::fs::remove_dir_all(&root);
}
#[tokio::test]
async fn a_path_that_is_not_a_bundle_is_refused() {
let root = std::env::temp_dir().join("roteiro-okf-view-not-a-bundle");
let _ = std::fs::remove_dir_all(&root);
let (status, body) = get_(&root, "", "/").await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert!(body.contains("Not a readable OKF bundle"), "{body}");
}
fn named_bundle(tag: &str, title: &str) -> PathBuf {
let concept = format!("---\ntype: Metric\ntitle: {title}\n---\n\n# {title}\n");
fixture(
tag,
&[
("index.md", "---\nokf_version: \"0.2\"\n---\n\n# Bundle\n"),
("metrics/only.md", &concept),
],
)
}
fn mount_at(slug: &str, root: PathBuf) -> Mount {
Mount {
slug: slug.to_owned(),
label: slug.to_owned(),
origin: "test".to_owned(),
root,
}
}
fn host() -> Router {
Router::new().route("/", get(|| async { "explorer" }))
}
async fn get_mounted(app: &Router, uri: &str) -> (StatusCode, String, Option<String>) {
let response = app
.clone()
.oneshot(
Request::builder()
.uri(uri)
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
let status = response.status();
let location = response
.headers()
.get(axum::http::header::LOCATION)
.map(|v| v.to_str().expect("location").to_owned());
let bytes = axum::body::to_bytes(response.into_body(), 1 << 22)
.await
.expect("body");
(
status,
String::from_utf8_lossy(&bytes).into_owned(),
location,
)
}
fn hrefs(body: &str) -> Vec<String> {
body.match_indices("href=\"")
.filter_map(|(i, m)| {
let rest = &body[i + m.len()..];
rest.find('"').map(|end| rest[..end].to_owned())
})
.collect()
}
#[test]
fn a_slug_is_one_readable_path_segment() {
assert_eq!(slug("Roteiro/Roteiro"), "Roteiro-Roteiro");
assert_eq!(slug("my repo"), "my-repo");
assert_eq!(slug("a.b_c-d"), "a.b_c-d");
assert_eq!(slug(" spaced out "), "spaced-out");
assert_eq!(slug("日本語"), "bundle");
assert_eq!(slug(""), "bundle");
}
#[tokio::test]
async fn two_labels_that_fold_alike_stay_separately_reachable() {
let mut mounts = vec![
Mount {
slug: slug("my repo"),
label: "my repo".to_owned(),
origin: "a".to_owned(),
root: named_bundle("fold-a", "Alpha"),
},
Mount {
slug: slug("my/repo"),
label: "my/repo".to_owned(),
origin: "b".to_owned(),
root: named_bundle("fold-b", "Beta"),
},
];
assert_eq!(
mounts[0].slug, mounts[1].slug,
"the fixture must contain the collision it is testing"
);
disambiguate(&mut mounts);
let (first, second) = (mounts[0].slug.clone(), mounts[1].slug.clone());
assert_eq!(first, "my-repo", "the first keeps the readable name");
assert_eq!(second, "my-repo-2");
let app = host().merge(mounts_router("/okf", mounts, None));
let (status, body, _) = get_mounted(&app, &format!("/okf/{first}")).await;
assert_eq!(status, StatusCode::OK);
assert!(body.contains("Alpha") && !body.contains("Beta"), "{body}");
let (status, body, _) = get_mounted(&app, &format!("/okf/{second}")).await;
assert_eq!(status, StatusCode::OK);
assert!(body.contains("Beta") && !body.contains("Alpha"), "{body}");
}
#[tokio::test]
async fn the_mount_layer_merges_into_a_host_that_owns_the_root() {
let mounts = vec![
mount_at("one", named_bundle("merge-a", "Alpha")),
mount_at("two", named_bundle("merge-b", "Beta")),
];
let app = host().merge(mounts_router("/okf", mounts, Some("/".to_owned())));
let (status, body, _) = get_mounted(&app, "/").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body, "explorer", "the host keeps its own root");
assert_eq!(get_mounted(&app, "/okf").await.0, StatusCode::OK);
}
#[tokio::test]
async fn a_lone_bundle_redirects_from_the_mount_base() {
let mounts = vec![mount_at("only", sample())];
let app = host().merge(mounts_router("/okf", mounts, None));
let (status, _, location) = get_mounted(&app, "/okf").await;
assert_eq!(status, StatusCode::TEMPORARY_REDIRECT);
assert_eq!(location.as_deref(), Some("/okf/only"));
assert_eq!(get_mounted(&app, "/okf/only").await.0, StatusCode::OK);
}
#[tokio::test]
async fn every_link_the_chooser_writes_resolves() {
let mounts = vec![
mount_at("one", named_bundle("chooser-a", "Alpha")),
mount_at("two", named_bundle("chooser-b", "Beta")),
];
let app = host().merge(mounts_router("/okf", mounts, Some("/".to_owned())));
let (status, body, _) = get_mounted(&app, "/okf").await;
assert_eq!(status, StatusCode::OK);
let links = hrefs(&body);
for want in ["/okf/one", "/okf/two", "/okf/okf-viewer.css", "/"] {
assert!(links.iter().any(|h| h == want), "{want} missing: {links:?}");
}
for link in &links {
let (status, _, _) = get_mounted(&app, link).await;
assert_eq!(status, StatusCode::OK, "{link}");
}
let (status, css, _) = get_mounted(&app, "/okf/okf-viewer.css").await;
assert_eq!(status, StatusCode::OK);
assert!(
css.contains("--ink"),
"the chooser's stylesheet is the viewer's"
);
}
#[tokio::test]
async fn every_link_a_nested_bundle_writes_resolves() {
let mounts = vec![
mount_at("one", named_bundle("nested-a", "Alpha")),
mount_at("two", named_bundle("nested-b", "Beta")),
];
let app = host().merge(mounts_router("/okf", mounts, Some("/".to_owned())));
let (status, body, _) = get_mounted(&app, "/okf/one").await;
assert_eq!(status, StatusCode::OK);
let links = hrefs(&body);
assert!(
links.iter().any(|h| h == "/okf/one"),
"no index link: {links:?}"
);
assert!(
!links.iter().any(|h| h == "/okf/one/"),
"a trailing slash under `nest` is a 404: {links:?}"
);
for link in &links {
let (status, _, _) = get_mounted(&app, link).await;
assert_eq!(status, StatusCode::OK, "{link}");
}
}
#[tokio::test]
async fn a_lone_bundle_with_no_explorer_offers_neither_link() {
let mounts = vec![mount_at("bare", sample())];
let app = Router::new().merge(mounts_router("/okf", mounts, None));
let (status, body, _) = get_mounted(&app, "/okf/bare").await;
assert_eq!(status, StatusCode::OK);
assert!(!body.contains("All bundles"), "{body}");
assert!(!body.contains(">Explorer<"), "{body}");
assert!(body.contains("Concepts"), "{body}");
}
#[tokio::test]
async fn a_nested_404_links_back_to_its_own_bundle() {
let mounts = vec![mount_at("one", sample())];
let app = host().merge(mounts_router("/okf", mounts, None));
let (status, body, _) = get_mounted(&app, "/okf/one/c/metrics/nope").await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert!(body.contains("<code>metrics/nope</code>"), "{body}");
let links = hrefs(&body);
assert!(
links.iter().any(|h| h == "/okf/one"),
"no way back: {links:?}"
);
for link in &links {
let (status, _, _) = get_mounted(&app, link).await;
assert_eq!(status, StatusCode::OK, "{link}");
}
}
#[tokio::test]
async fn a_bundle_cannot_be_named_over_the_mount_bases_stylesheet() {
assert_eq!(
slug("okf viewer.css"),
"okf-viewer.css",
"the fixture must contain the collision it is testing"
);
let mut mounts = vec![Mount {
slug: slug("okf viewer.css"),
label: "okf viewer.css".to_owned(),
origin: "test".to_owned(),
root: named_bundle("reserved", "Alpha"),
}];
disambiguate(&mut mounts);
assert_eq!(
mounts[0].slug, "okf-viewer.css-2",
"moved off the reserved segment"
);
let app = host().merge(mounts_router("/okf", mounts, None));
let (status, css, _) = get_mounted(&app, "/okf/okf-viewer.css").await;
assert_eq!(status, StatusCode::OK);
assert!(css.contains("--ink"), "not the stylesheet: {css:.80}");
let (status, body, _) = get_mounted(&app, "/okf/okf-viewer.css-2").await;
assert_eq!(status, StatusCode::OK);
assert!(body.contains("Alpha"), "{body}");
}
#[test]
fn a_mount_path_that_could_leave_an_attribute_is_refused() {
for ok in ["", "/okf", "/okf/Roteiro-Roteiro", "/okf/a.b_c-d"] {
assert_mountable(ok);
}
for bad in [
"/okf/\"><script>alert(1)</script>",
"/okf/a b",
"/okf/a/",
"okf",
"/okf//x",
] {
assert!(
std::panic::catch_unwind(|| assert_mountable(bad)).is_err(),
"accepted a mount path it cannot safely write: {bad:?}"
);
}
}
#[test]
fn a_hostile_base_cannot_reach_a_page() {
let root = sample();
let hostile = "/okf/\"><script>alert(1)</script>";
assert!(
std::panic::catch_unwind(|| router(root.clone(), hostile, Nav::default())).is_err(),
"a bundle router was built on a hostile base"
);
assert!(
std::panic::catch_unwind(|| mounts_router(hostile, Vec::new(), None)).is_err(),
"a mount layer was built on a hostile base"
);
let _ = std::fs::remove_dir_all(&root);
}
}