use std::fmt::Write as _;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use axum::Router;
use axum::extract::{Path as UrlPath, State};
use axum::http::{StatusCode, header};
use axum::response::{Html, IntoResponse, Response};
use axum::routing::get;
use rto_render::okf::view;
#[derive(Clone)]
struct Viewer {
root: Arc<PathBuf>,
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()
}
pub fn router(root: PathBuf, base: &str) -> Router {
let state = Viewer {
root: Arc::new(root),
base: Arc::new(base.to_owned()),
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, body: &str) -> Response {
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><a href=\"{base}/\">Concepts</a><a href=\"{base}/graph\">Graph</a></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, &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=\"{base}/\">Back to the bundle</a>.</p>",
escape(&id)
)),
)
.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,
);
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, &body)
}
async fn graph_page(State(v): State<Viewer>) -> Response {
const SCRIPT: &str = "<article><h1>Concept graph</h1><div id=\"graph\"></div>\
<script src=\"{BASE}/cytoscape.min.js\"></script>\
<script>fetch('{BASE}/api/graph.json').then(r=>r.json()).then(g=>{\
cytoscape({container:document.getElementById('graph'),\
elements:[...g.nodes.map(n=>({data:{id:n.id,label:n.label,trust:n.trust}})),\
...g.edges.map(e=>({data:{source:e.source,target:e.target}}))],\
layout:{name:'cose'},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:'edge',style:{'width':1,'line-color':'#d8d2c4',\
'target-arrow-shape':'triangle','target-arrow-color':'#d8d2c4',\
'curve-style':'bezier'}}]});});</script></article>";
let base = v.base.as_str();
let body = &SCRIPT.replace("{BASE}", base);
let mut res = page("Concept graph", &v.root.display().to_string(), base, 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_json(State(v): State<Viewer>) -> Response {
let built = blocking(move || v.graph()).await;
let graph = match built {
Some(Ok(graph)) => graph,
Some(Err(e)) => return unreadable(&e),
None => return spawn_failed(),
};
let Ok(body) = serde_json::to_string(graph.as_ref()) 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",
"",
"<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 response = router(root.to_path_buf(), base)
.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",
),
],
)
}
#[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 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}");
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, "")
.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, "")
.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(), "")
.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}");
}
}