polish batch: dot repo name, gitlinks, inbox id escaping, forge symlinks, wiki index (#68)
Message
polish batch: dot repo name, gitlinks, inbox id escaping, forge symlinks, wiki index (#68)
- resolve_repo_name canonicalizes first, so 'hu13 build .' titles the
site after the directory instead of the literal 'repo'.
- TreeEntry carries is_gitlink (mode 160000). Gitlinks get no raw
copy, no file page and no `git log -1` lookup; the file table marks
them '@ submodule' instead of linking a page that cannot exist.
- 'inbox list' now runs the id through escape_terminal like every
other field (a hostile filename can contain ESC).
- forge/release read_text_capped refuses symlinked files, matching
the wiki and release-artifact rules.
- wiki index only shows the path hint for nested pages ('hu13 README
README' is gone).
- content-type HEAD anchor was already tightened in #59.
tests/polish_batch.rs covers all five.
Diff
src/build.rs+15 −2
--- a/src/build.rs+++ b/src/build.rs@@ -68,7 +68,9 @@ pub fn resolve_repo_name(repo: &Path, flag: Option<&str>) -> String {6868 return t.to_string();6969 }7070 }71- repo.file_name()71+ // `hu13 build .` must name the repo, not the path "." (f0rge #68).72+ let abs = std::fs::canonicalize(repo).unwrap_or_else(|_| repo.to_path_buf());73+ abs.file_name()7274 .map(|n| n.to_string_lossy().into_owned())7375 .map(|n| n.trim().to_string())7476 .filter(|n| !n.is_empty())@@ -855,6 +857,13 @@ pub fn file_table_html(855857 esc(&age),856858 esc(&age)857859 ));860+ } else if e.is_gitlink {861+ // Submodule: no blob in this repo, so no file page/raw copy.862+ out.push_str(&format!(863+ "<tr class=\"file gitlink\"><td class=\"trunc\" title=\"submodule\">{} <span class=\"muted\">@ submodule</span></td>\864+ <td class=\"title trunc trunc-message\" title=\"\"></td><td class=\"trunc\" title=\"\"></td></tr>\n",865+ esc(&e.name),866+ ));858867 } else {859868 out.push_str(&format!(860869 "<tr class=\"file\"><td class=\"trunc\" title=\"{}\"><a href=\"/files/{}.html\">{}</a></td>\@@ -1192,7 +1201,8 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu11921201 };11931202 if e.is_dir {11941203 queue.push(full);1195- } else {1204+ } else if !e.is_gitlink {1205+ // Gitlinks have no blob in this repo (f0rge #68).11961206 files.push(full);11971207 }11981208 }@@ -1259,6 +1269,9 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu12591269 let mut all_paths: Vec<String> = Vec::new();12601270 for (d, ents) in &dir_entries {12611271 for e in ents {1272+ if e.is_gitlink {1273+ continue;1274+ }12621275 let full = if d.is_empty() {12631276 e.name.clone()12641277 } else {
src/forge.rs+9 −0
--- a/src/forge.rs+++ b/src/forge.rs@@ -60,6 +60,15 @@ fn split_frontmatter(text: &str) -> Option<(&str, &str)> {6060 /// Read a UTF-8 file, skipping (with a warning) files that are missing,6161 /// oversized, or non-UTF-8. `label` names the file for warnings.6262 fn read_text_capped(path: &Path, label: &str) -> Option<String> {63+ // Never follow symlinks: wiki and release artifacts already skip64+ // them, and `.forge/` is repo-controlled (f0rge #68).65+ match std::fs::symlink_metadata(path) {66+ Ok(m) if m.file_type().is_symlink() => {67+ eprintln!("hu13: forge: skipping {label}: symlink");68+ return None;69+ }70+ _ => {}71+ }6372 let len = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);6473 if len > FORGE_MAX_BYTES {6574 eprintln!("hu13: forge: skipping {label}: over 256 KiB ({len} bytes)");
src/gitx.rs+5 −1
--- a/src/gitx.rs+++ b/src/gitx.rs@@ -106,6 +106,9 @@ pub struct TreeEntry {106106 pub name_bytes: Vec<u8>,107107 /// `true` for directories (type `tree`), `false` otherwise.108108 pub is_dir: bool,109+ /// `true` for submodule entries (mode `160000`): no blob exists, so110+ /// they must never get a raw copy or a file page (f0rge #68).111+ pub is_gitlink: bool,109112 }110113 111114 /// Exact bytes of `branch:path` from [`show`].@@ -580,12 +583,13 @@ pub fn ls_tree(repo: &Path, branch: &str, path: &str) -> Result<Vec<TreeEntry>,580583 // meta: "<mode> <type> <sha>"581584 let meta_str = String::from_utf8_lossy(meta);582585 let mut it = meta_str.split_whitespace();583- let _mode = it.next().unwrap_or("");586+ let mode = it.next().unwrap_or("");584587 let typ = it.next().unwrap_or("");585588 entries.push(TreeEntry {586589 name: String::from_utf8_lossy(&name_bytes).into_owned(),587590 name_bytes,588591 is_dir: typ == "tree",592+ is_gitlink: mode == "160000",589593 });590594 }591595 entries.sort_by(|a, b| {
src/inbox.rs+1 −1
--- a/src/inbox.rs+++ b/src/inbox.rs@@ -826,7 +826,7 @@ pub fn cmd_list(inbox_dir: &Path) -> i32 {826826 for e in list_entries(inbox_dir) {827827 println!(828828 "{} [{}] {} {}",829- e.id,829+ escape_terminal(&e.id),830830 escape_terminal(&e.kind),831831 escape_terminal(&e.received_at),832832 escape_terminal(&e.title)
src/releases.rs+9 −0
--- a/src/releases.rs+++ b/src/releases.rs@@ -93,6 +93,15 @@ fn split_frontmatter(text: &str) -> Option<(&str, &str)> {9393 }9494 9595 fn read_text_capped(path: &Path, label: &str) -> Option<String> {96+ // Never follow symlinks: wiki and release artifacts already skip97+ // them, and `.forge/` is repo-controlled (f0rge #68).98+ match std::fs::symlink_metadata(path) {99+ Ok(m) if m.file_type().is_symlink() => {100+ eprintln!("hu13: releases: skipping {label}: symlink");101+ return None;102+ }103+ _ => {}104+ }96105 let len = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);97106 if len > RELEASE_MAX_BYTES {98107 eprintln!("hu13: releases: skipping {label}: over 256 KiB ({len} bytes)");
src/wiki.rs+8 −2
--- a/src/wiki.rs+++ b/src/wiki.rs@@ -281,11 +281,17 @@ pub fn render(281281 } else {282282 body.push_str("<ul class=\"wiki-index\">\n");283283 for p in &pages {284+ // Root pages already read as "hu13 README"; only nested pages285+ // need the path hint (was "hu13 README README", f0rge #68).286+ let hint = if p.path.contains('/') {287+ format!(" <span class=\"muted\">{}</span>", esc(&p.path))288+ } else {289+ String::new()290+ };284291 body.push_str(&format!(285- "<li><a href=\"/wiki/{}.html\">{}</a> <span class=\"muted\">{}</span></li>\n",292+ "<li><a href=\"/wiki/{}.html\">{}</a>{hint}</li>\n",286293 esc(&p.path),287294 esc(&p.title),288- esc(&p.path),289295 ));290296 }291297 body.push_str("</ul>\n");
tests/polish_batch.rs+192 −0
--- /dev/null+++ b/tests/polish_batch.rs@@ -0,0 +1,192 @@1+//! f0rge #68: small robustness/consistency batch.2+//! - `hu13 build .` names the repo after its directory, not "repo"3+//! - gitlinks (mode 160000) get no raw copy / file page4+//! - `inbox list` escapes a hostile id5+//! - `.forge` symlinks are skipped like wiki/artifact symlinks6+//! - wiki root pages do not repeat their filename after the title7+8+use std::path::{Path, PathBuf};9+use std::process::Command;10+11+fn fresh(prefix: &str) -> PathBuf {12+ let dir = std::env::temp_dir().join(format!(13+ "hu13-polish-{prefix}-{}-{}",14+ std::process::id(),15+ std::time::SystemTime::now()16+ .duration_since(std::time::UNIX_EPOCH)17+ .map(|d| d.as_nanos())18+ .unwrap_or(0)19+ ));20+ std::fs::create_dir_all(&dir).unwrap();21+ dir22+}23+24+fn git(repo: &Path, args: &[&str]) -> String {25+ let out = Command::new("git")26+ .arg("-C")27+ .arg(repo)28+ .args(args)29+ .output()30+ .expect("spawn git");31+ assert!(32+ out.status.success(),33+ "git {args:?}: {}",34+ String::from_utf8_lossy(&out.stderr)35+ );36+ String::from_utf8_lossy(&out.stdout).trim().to_string()37+}38+39+fn init(repo: &Path) {40+ git(repo, &["init", "-b", "main"]);41+ git(repo, &["config", "user.name", "hu13-test"]);42+ git(repo, &["config", "user.email", "[email protected]"]);43+ git(repo, &["config", "commit.gpgsign", "false"]);44+}45+46+fn build(repo: &Path, out: &Path, cwd: Option<&Path>) -> std::process::Output {47+ let mut cmd = Command::new(env!("CARGO_BIN_EXE_hu13"));48+ cmd.arg("build").arg(repo).arg("-o").arg(out);49+ if let Some(dir) = cwd {50+ cmd.current_dir(dir);51+ }52+ cmd.output().expect("spawn hu13 build")53+}54+55+#[test]56+fn dot_build_uses_directory_name() {57+ let parent = fresh("dot");58+ let repo = parent.join("my-project");59+ std::fs::create_dir_all(&repo).unwrap();60+ init(&repo);61+ std::fs::write(repo.join("README.md"), "# dot\n").unwrap();62+ git(&repo, &["add", "-A"]);63+ git(&repo, &["commit", "-m", "one"]);64+ let out = fresh("out");65+ let res = build(Path::new("."), &out, Some(&repo));66+ assert!(67+ res.status.success(),68+ "{}",69+ String::from_utf8_lossy(&res.stderr)70+ );71+ let index = std::fs::read_to_string(out.join("index.html")).unwrap();72+ assert!(73+ index.contains("<h1>my-project</h1>"),74+ "repo named after the directory: {}",75+ &index[..600.min(index.len())]76+ );77+ assert!(!index.contains("<h1>repo</h1>"), "not the literal 'repo'");78+}79+80+#[test]81+fn gitlink_gets_no_raw_or_page() {82+ let repo = fresh("gitlink");83+ init(&repo);84+ std::fs::write(repo.join("README.md"), "# gitlink\n").unwrap();85+ git(&repo, &["add", "-A"]);86+ git(&repo, &["commit", "-m", "one"]);87+ let head = git(&repo, &["rev-parse", "HEAD"]);88+ git(89+ &repo,90+ &[91+ "update-index",92+ "--add",93+ "--cacheinfo",94+ &format!("160000,{head},sub"),95+ ],96+ );97+ // Entry name for --cacheinfo is the last argument.98+ git(&repo, &["commit", "-m", "add gitlink"]);99+ let out = fresh("out");100+ let res = build(&repo, &out, None);101+ assert!(102+ res.status.success(),103+ "{}",104+ String::from_utf8_lossy(&res.stderr)105+ );106+ assert!(!out.join("raw/sub").exists(), "no raw copy for a gitlink");107+ assert!(108+ !out.join("files/sub.html").exists(),109+ "no file page for a gitlink"110+ );111+ let index = std::fs::read_to_string(out.join("index.html")).unwrap();112+ assert!(index.contains("@ submodule"), "gitlink marked in the table");113+}114+115+#[test]116+fn inbox_list_escapes_hostile_id() {117+ let inbox = fresh("inbox");118+ // A filename can contain ESC; list must never print it raw.119+ let name = "bad\u{1b}id.json";120+ std::fs::write(121+ inbox.join(name),122+ r#"{"type":"issue","title":"t","received_at":"2026-09-10T00:00:00Z"}"#,123+ )124+ .unwrap();125+ let out = Command::new(env!("CARGO_BIN_EXE_hu13"))126+ .arg("inbox")127+ .arg("list")128+ .arg("--inbox-dir")129+ .arg(&inbox)130+ .output()131+ .unwrap();132+ assert!(out.status.success());133+ let stdout = String::from_utf8_lossy(&out.stdout);134+ assert!(stdout.contains("\\x1b"), "escaped id: {stdout}");135+ assert!(!stdout.contains('\u{1b}'), "no raw ESC: {stdout}");136+}137+138+#[test]139+fn forge_symlink_is_skipped() {140+ let repo = fresh("symlink");141+ init(&repo);142+ std::fs::write(repo.join("README.md"), "# symlink\n").unwrap();143+ let dir = repo.join(".forge/issues/aaaaaaaa-1111-4111-8111-111111111111");144+ std::fs::create_dir_all(&dir).unwrap();145+ let outside = fresh("outside").join("issue.md");146+ std::fs::write(147+ &outside,148+ "---\nid: aaaaaaaa-1111-4111-8111-111111111111\ntitle: linked\nstate: open\ncreated: 2026-09-10T00:00:00Z\n---\nbody\n",149+ )150+ .unwrap();151+ std::os::unix::fs::symlink(&outside, dir.join("issue.md")).unwrap();152+ git(&repo, &["add", "-A"]);153+ git(&repo, &["commit", "-m", "one"]);154+ let out = fresh("out");155+ let res = build(&repo, &out, None);156+ assert!(157+ res.status.success(),158+ "{}",159+ String::from_utf8_lossy(&res.stderr)160+ );161+ let err = String::from_utf8_lossy(&res.stderr);162+ assert!(err.contains("symlink"), "warned about the symlink: {err}");163+ assert!(164+ !out.join("issues/aaaaaaaa-1111-4111-8111-111111111111.html")165+ .exists(),166+ "no page for a symlinked issue"167+ );168+}169+170+#[test]171+fn wiki_root_page_does_not_repeat_its_filename() {172+ let repo = fresh("wiki");173+ init(&repo);174+ std::fs::write(repo.join("README.md"), "# wiki\n").unwrap();175+ std::fs::create_dir_all(repo.join(".forge/wiki/nested")).unwrap();176+ std::fs::write(repo.join(".forge/wiki/README.md"), "# hu13 README\n").unwrap();177+ std::fs::write(repo.join(".forge/wiki/nested/guide.md"), "# Guide\n").unwrap();178+ git(&repo, &["add", "-A"]);179+ git(&repo, &["commit", "-m", "one"]);180+ let out = fresh("out");181+ let res = build(&repo, &out, None);182+ assert!(res.status.success());183+ let index = std::fs::read_to_string(out.join("wiki/index.html")).unwrap();184+ assert!(185+ !index.contains("hu13 README</a> README"),186+ "root page path hint dropped: {index}"187+ );188+ assert!(189+ index.contains("Guide</a> <span class=\"muted\">nested/guide.md</span>"),190+ "nested pages keep the path hint"191+ );192+}