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:
@@ -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
@@ -15,16 +15,33 @@ impl Cache {
|
|||||||
Self::default()
|
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.
|
/// Return the cached render, or compute it with `f`, store, and return it.
|
||||||
pub fn get_or_render<F: FnOnce() -> String>(
|
pub fn get_or_render<F: FnOnce() -> String>(
|
||||||
&self, repo: &str, path: &str, sha: &str, f: F,
|
&self, repo: &str, path: &str, sha: &str, f: F,
|
||||||
) -> String {
|
) -> String {
|
||||||
let key = (repo.to_string(), path.to_string(), sha.to_string());
|
if let Some(hit) = self.get(repo, path, sha) {
|
||||||
if let Some(hit) = self.map.lock().unwrap().get(&key) {
|
return hit;
|
||||||
return hit.clone();
|
|
||||||
}
|
}
|
||||||
let html = f();
|
let html = f();
|
||||||
self.map.lock().unwrap().insert(key, html.clone());
|
self.put(repo, path, sha, html.clone());
|
||||||
html
|
html
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-17
@@ -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\">\
|
let mut s = String::from("<!doctype html><link rel=\"stylesheet\" href=\"/assets/theme.css\">\
|
||||||
<header class=\"crumb\">docsite</header><main class=\"doc\"><ul>");
|
<header class=\"crumb\">docsite</header><main class=\"doc\"><ul>");
|
||||||
for p in &st.reg.project {
|
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>");
|
s.push_str("</ul></main>");
|
||||||
Html(s)
|
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 full = proj.source_path(&pref.rel);
|
||||||
let title = url_page.rsplit('/').next().unwrap_or(url_page).to_string();
|
let title = url_page.rsplit('/').next().unwrap_or(url_page).to_string();
|
||||||
|
|
||||||
// fetch SHA + raw inside spawn_blocking (the Fetcher is sync)
|
// Resolve the revision, serve from cache on a hit, and only fetch the
|
||||||
let f = st.fetcher.clone();
|
// source on a miss (spec data-flow: hit -> serve, miss -> fetch). The
|
||||||
let (repo, branch) = (proj.repo.clone(), proj.branch.clone());
|
// Fetcher is sync, so the sha lookup + cache check + fetch + render all run
|
||||||
let full2 = full.clone();
|
// in one spawn_blocking.
|
||||||
let fetched = tokio::task::spawn_blocking(move || {
|
let st2 = st.clone();
|
||||||
let sha = f.branch_sha(&repo, &branch)?;
|
let (repo, branch, full2) = (proj.repo.clone(), proj.branch.clone(), full.clone());
|
||||||
let body = f.fetch_raw(&repo, &branch, &full2)?;
|
let (slug2, title2) = (pref.slug.clone(), title.clone());
|
||||||
Ok::<_, GiteaError>((sha, body))
|
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
|
.await
|
||||||
.expect("join");
|
.expect("join");
|
||||||
|
|
||||||
match fetched {
|
match result {
|
||||||
Ok((sha, md)) => {
|
Ok(html) => Html(html).into_response(),
|
||||||
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()
|
|
||||||
}
|
|
||||||
Err(GiteaError::NotFound { repo, path }) => {
|
Err(GiteaError::NotFound { repo, path }) => {
|
||||||
// fail-loud: a visible error block, 200 (the broken ref is content)
|
// fail-loud: a visible error block, 200 (the broken ref is content)
|
||||||
let block = format!(
|
let block = format!(
|
||||||
|
|||||||
+2
-2
@@ -15,7 +15,7 @@ fn nav_html(proj: &Project) -> String {
|
|||||||
let url = page.strip_suffix(".md").unwrap_or(page);
|
let url = page.strip_suffix(".md").unwrap_or(page);
|
||||||
s.push_str(&format!(
|
s.push_str(&format!(
|
||||||
"<a href=\"/{}/{}\">{}</a>",
|
"<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('&', "&").replace('<', "<").replace('>', ">")
|
s.replace('&', "&").replace('<', "<").replace('>', ">")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -191,3 +191,49 @@ docs="docs"
|
|||||||
"index must list exactly the registry's projects, no more: {body}"
|
"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",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user