From 55801cf819a87dc6e9df0aaa4ade0e4e27cb9448 Mon Sep 17 00:00:00 2001 From: Brummel Date: Sun, 28 Jun 2026 16:55:23 +0200 Subject: [PATCH] iter 0001.tidy: registry.toml, cache-before-fetch, escape registry slugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- registry.toml | 32 ++++++++++++++++++++++++++++++++ src/cache.rs | 25 +++++++++++++++++++++---- src/server/mod.rs | 38 +++++++++++++++++++++----------------- src/theme/mod.rs | 4 ++-- tests/e2e.rs | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 122 insertions(+), 23 deletions(-) create mode 100644 registry.toml diff --git a/registry.toml b/registry.toml new file mode 100644 index 0000000..554aa11 --- /dev/null +++ b/registry.toml @@ -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"] diff --git a/src/cache.rs b/src/cache.rs index 3f9a4a5..9480b8c 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -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 { + 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 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 } } diff --git a/src/server/mod.rs b/src/server/mod.rs index c3930de..b092543 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -39,7 +39,8 @@ async fn index(State(st): State) -> Html { let mut s = String::from("\
docsite
    "); for p in &st.reg.project { - s.push_str(&format!("
  • {0}
  • ", p.slug)); + let slug = crate::theme::html_escape(&p.slug); + s.push_str(&format!("
  • {slug}
  • ")); } s.push_str("
"); Html(s) @@ -54,26 +55,29 @@ async fn page(State(st): State, 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 { + 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!( diff --git a/src/theme/mod.rs b/src/theme/mod.rs index 35fd8e3..548ece4 100644 --- a/src/theme/mod.rs +++ b/src/theme/mod.rs @@ -15,7 +15,7 @@ fn nav_html(proj: &Project) -> String { let url = page.strip_suffix(".md").unwrap_or(page); s.push_str(&format!( "{}", - 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('&', "&").replace('<', "<").replace('>', ">") } diff --git a/tests/e2e.rs b/tests/e2e.rs index 4d71617..fc184f4 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -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, +} + +impl Fetcher for CountingFetcher { + fn branch_sha(&self, repo: &str, branch: &str) -> Result { + self.inner.branch_sha(repo, branch) + } + fn fetch_raw(&self, repo: &str, branch: &str, path: &str) -> Result { + 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", + ); +}