iter 0001.tidy: registry.toml, cache-before-fetch, escape registry slugs

Resolves the actionable drift the cycle-close architect review found:
- Ship registry.toml (the spec's acceptance artifact) for aura + data-server,
  so `docsite serve`/`check` run out of the box — the headline live-render was
  not runnable as shipped without it.
- Cache before fetch: the page handler now resolves the branch SHA, serves
  from cache on a hit, and only fetches the source on a miss (the spec
  data-flow). The pre-tidy handler always fetched, paying a wasted Gitea read
  on every cache hit. A new e2e test (a counting fetcher) pins that a second
  request serves from cache without re-fetching.
- Escape the registry slug/page in nav_html's href and the index links —
  operator-trusted data, but consistent with the documented no-injection
  invariant and the rest of the shell (which already escaped).

cargo test green (41: 32 lib + 8 e2e + 1 gated live smoke); clippy clean.

refs #1
This commit is contained in:
2026-06-28 16:55:23 +02:00
parent cb82987c33
commit 55801cf819
5 changed files with 122 additions and 23 deletions
+32
View File
@@ -0,0 +1,32 @@
# DocSite registry — the publish whitelist and per-project Gitea coordinates.
# Only pages listed here are reachable; everything else (docs/specs, docs/plans,
# postmortems, internal files) is structurally unservable. DocSite reads these
# sources read-only from Gitea and never writes to them.
bind = "0.0.0.0:8080"
gitea = "http://192.168.178.103:3000"
# The read-only Gitea token is resolved from $DOCSITE_GITEA_TOKEN, never stored here.
[[project]]
slug = "aura"
repo = "Brummel/aura"
branch = "main"
docs = "docs"
[[project.section]]
title = "Concepts"
pages = ["glossary.md"]
[[project.section]]
title = "Design & Architecture"
pages = ["design/INDEX.md"]
[[project]]
slug = "data-server"
repo = "Brummel/data-server"
branch = "main"
docs = "."
[[project.section]]
title = "Reference"
pages = ["README.md"]
+21 -4
View File
@@ -15,16 +15,33 @@ impl Cache {
Self::default()
}
/// The cached render for `(repo, path, sha)`, if present. A cache hit lets
/// the caller skip the source fetch entirely (spec data-flow: hit -> serve).
pub fn get(&self, repo: &str, path: &str, sha: &str) -> Option<String> {
self.map
.lock()
.unwrap()
.get(&(repo.to_string(), path.to_string(), sha.to_string()))
.cloned()
}
/// Store a render under `(repo, path, sha)`.
pub fn put(&self, repo: &str, path: &str, sha: &str, html: String) {
self.map
.lock()
.unwrap()
.insert((repo.to_string(), path.to_string(), sha.to_string()), html);
}
/// Return the cached render, or compute it with `f`, store, and return it.
pub fn get_or_render<F: FnOnce() -> String>(
&self, repo: &str, path: &str, sha: &str, f: F,
) -> String {
let key = (repo.to_string(), path.to_string(), sha.to_string());
if let Some(hit) = self.map.lock().unwrap().get(&key) {
return hit.clone();
if let Some(hit) = self.get(repo, path, sha) {
return hit;
}
let html = f();
self.map.lock().unwrap().insert(key, html.clone());
self.put(repo, path, sha, html.clone());
html
}
}
+21 -17
View File
@@ -39,7 +39,8 @@ async fn index(State(st): State<AppState>) -> Html<String> {
let mut s = String::from("<!doctype html><link rel=\"stylesheet\" href=\"/assets/theme.css\">\
<header class=\"crumb\">docsite</header><main class=\"doc\"><ul>");
for p in &st.reg.project {
s.push_str(&format!("<li><a href=\"/{0}/\">{0}</a></li>", p.slug));
let slug = crate::theme::html_escape(&p.slug);
s.push_str(&format!("<li><a href=\"/{slug}/\">{slug}</a></li>"));
}
s.push_str("</ul></main>");
Html(s)
@@ -54,26 +55,29 @@ async fn page(State(st): State<AppState>, Path((slug, page)): Path<(String, Stri
let full = proj.source_path(&pref.rel);
let title = url_page.rsplit('/').next().unwrap_or(url_page).to_string();
// fetch SHA + raw inside spawn_blocking (the Fetcher is sync)
let f = st.fetcher.clone();
let (repo, branch) = (proj.repo.clone(), proj.branch.clone());
let full2 = full.clone();
let fetched = tokio::task::spawn_blocking(move || {
let sha = f.branch_sha(&repo, &branch)?;
let body = f.fetch_raw(&repo, &branch, &full2)?;
Ok::<_, GiteaError>((sha, body))
// Resolve the revision, serve from cache on a hit, and only fetch the
// source on a miss (spec data-flow: hit -> serve, miss -> fetch). The
// Fetcher is sync, so the sha lookup + cache check + fetch + render all run
// in one spawn_blocking.
let st2 = st.clone();
let (repo, branch, full2) = (proj.repo.clone(), proj.branch.clone(), full.clone());
let (slug2, title2) = (pref.slug.clone(), title.clone());
let result = tokio::task::spawn_blocking(move || -> Result<String, GiteaError> {
let sha = st2.fetcher.branch_sha(&repo, &branch)?;
if let Some(html) = st2.cache.get(&repo, &full2, &sha) {
return Ok(html);
}
let md = st2.fetcher.fetch_raw(&repo, &branch, &full2)?;
let body = crate::render::render_doc(&md);
let html = crate::theme::page_html(&st2.reg, &slug2, &title2, &body);
st2.cache.put(&repo, &full2, &sha, html.clone());
Ok(html)
})
.await
.expect("join");
match fetched {
Ok((sha, md)) => {
let html = st.cache.get_or_render(&proj.repo, &full, &sha, || {
let body = crate::render::render_doc(&md);
crate::theme::page_html(&st.reg, &pref.slug, &title, &body)
});
Html(html).into_response()
}
match result {
Ok(html) => Html(html).into_response(),
Err(GiteaError::NotFound { repo, path }) => {
// fail-loud: a visible error block, 200 (the broken ref is content)
let block = format!(
+2 -2
View File
@@ -15,7 +15,7 @@ fn nav_html(proj: &Project) -> String {
let url = page.strip_suffix(".md").unwrap_or(page);
s.push_str(&format!(
"<a href=\"/{}/{}\">{}</a>",
proj.slug, url, html_escape(url)
html_escape(&proj.slug), html_escape(url), html_escape(url)
));
}
}
@@ -35,7 +35,7 @@ pub fn page_html(reg: &Registry, slug: &str, title: &str, body: &str) -> String
)
}
fn html_escape(s: &str) -> String {
pub fn html_escape(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;")
}
+46
View File
@@ -191,3 +191,49 @@ docs="docs"
"index must list exactly the registry's projects, no more: {body}"
);
}
/// A Fetcher that counts `fetch_raw` calls, wrapping a MockFetcher. Lets the
/// E2E layer prove the cache-before-fetch data-flow.
struct CountingFetcher {
inner: MockFetcher,
fetches: Arc<std::sync::atomic::AtomicUsize>,
}
impl Fetcher for CountingFetcher {
fn branch_sha(&self, repo: &str, branch: &str) -> Result<String, GiteaError> {
self.inner.branch_sha(repo, branch)
}
fn fetch_raw(&self, repo: &str, branch: &str, path: &str) -> Result<String, GiteaError> {
self.fetches.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
self.inner.fetch_raw(repo, branch, path)
}
}
/// Single source of truth + the revision-keyed cache (invariant 2) at the HTTP
/// layer: two requests for the same page at an unchanged branch SHA must hit the
/// source exactly ONCE — the second serves from cache without re-fetching. The
/// pre-audit handler fetched before checking the cache, so a cache hit still
/// paid the Gitea fetch; this pins the corrected hit -> serve, miss -> fetch flow.
#[tokio::test]
async fn second_request_serves_from_cache_without_refetching() {
let fetches = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let fetcher = CountingFetcher {
inner: MockFetcher::new("a517899")
.with("Brummel/aura", "docs/design/INDEX.md", "# Ledger\n"),
fetches: fetches.clone(),
};
let app = router(AppState {
reg: Arc::new(reg()),
fetcher: Arc::new(fetcher),
cache: Arc::new(Cache::new()),
});
let (s1, _) = get(app.clone(), "/aura/design/INDEX").await;
let (s2, _) = get(app, "/aura/design/INDEX").await;
assert_eq!(s1, StatusCode::OK);
assert_eq!(s2, StatusCode::OK);
assert_eq!(
fetches.load(std::sync::atomic::Ordering::SeqCst),
1,
"the second request must serve from cache, not re-fetch the source",
);
}