1 From 788320a56f99f92843c52ecb6e878c0f30460656 Mon Sep 17 00:00:00 20012 From: hu13-test <[email protected]>3 Date: Tue, 27 Oct 2026 16:36:25 +00004 Subject: [PATCH] polish batch: dot repo name, gitlinks, inbox id escaping,5 forge symlinks, wiki index (#68)6 7 - resolve_repo_name canonicalizes first, so 'hu13 build .' titles the8 site after the directory instead of the literal 'repo'.9 - TreeEntry carries is_gitlink (mode 160000). Gitlinks get no raw10 copy, no file page and no `git log -1` lookup; the file table marks11 them '@ submodule' instead of linking a page that cannot exist.12 - 'inbox list' now runs the id through escape_terminal like every13 other field (a hostile filename can contain ESC).14 - forge/release read_text_capped refuses symlinked files, matching15 the wiki and release-artifact rules.16 - wiki index only shows the path hint for nested pages ('hu13 README17 README' is gone).18 - content-type HEAD anchor was already tightened in #59.19 20 tests/polish_batch.rs covers all five.21 ---22 src/build.rs | 17 +++-23 src/forge.rs | 9 ++24 src/gitx.rs | 6 +-25 src/inbox.rs | 2 +-26 src/releases.rs | 9 ++27 src/wiki.rs | 10 ++-28 tests/polish_batch.rs | 192 ++++++++++++++++++++++++++++++++++++++++++29 7 files changed, 239 insertions(+), 6 deletions(-)30 create mode 100644 tests/polish_batch.rs31 32 diff --git a/src/build.rs b/src/build.rs33 index 352fc43..d6e3420 10064434 --- a/src/build.rs35 +++ b/src/build.rs36 @@ -68,7 +68,9 @@ pub fn resolve_repo_name(repo: &Path, flag: Option<&str>) -> String {37 return t.to_string();38 }39 }40 - repo.file_name()41 + // `hu13 build .` must name the repo, not the path "." (f0rge #68).42 + let abs = std::fs::canonicalize(repo).unwrap_or_else(|_| repo.to_path_buf());43 + abs.file_name()44 .map(|n| n.to_string_lossy().into_owned())45 .map(|n| n.trim().to_string())46 .filter(|n| !n.is_empty())47 @@ -855,6 +857,13 @@ pub fn file_table_html(48 esc(&age),49 esc(&age)50 ));51 + } else if e.is_gitlink {52 + // Submodule: no blob in this repo, so no file page/raw copy.53 + out.push_str(&format!(54 + "<tr class=\"file gitlink\"><td class=\"trunc\" title=\"submodule\">{} <span class=\"muted\">@ submodule</span></td>\55 + <td class=\"title trunc trunc-message\" title=\"\"></td><td class=\"trunc\" title=\"\"></td></tr>\n",56 + esc(&e.name),57 + ));58 } else {59 out.push_str(&format!(60 "<tr class=\"file\"><td class=\"trunc\" title=\"{}\"><a href=\"/files/{}.html\">{}</a></td>\61 @@ -1192,7 +1201,8 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu62 };63 if e.is_dir {64 queue.push(full);65 - } else {66 + } else if !e.is_gitlink {67 + // Gitlinks have no blob in this repo (f0rge #68).68 files.push(full);69 }70 }71 @@ -1259,6 +1269,9 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu72 let mut all_paths: Vec<String> = Vec::new();73 for (d, ents) in &dir_entries {74 for e in ents {75 + if e.is_gitlink {76 + continue;77 + }78 let full = if d.is_empty() {79 e.name.clone()80 } else {81 diff --git a/src/forge.rs b/src/forge.rs82 index ba988e7..a9ada13 10064483 --- a/src/forge.rs84 +++ b/src/forge.rs85 @@ -60,6 +60,15 @@ fn split_frontmatter(text: &str) -> Option<(&str, &str)> {86 /// Read a UTF-8 file, skipping (with a warning) files that are missing,87 /// oversized, or non-UTF-8. `label` names the file for warnings.88 fn read_text_capped(path: &Path, label: &str) -> Option<String> {89 + // Never follow symlinks: wiki and release artifacts already skip90 + // them, and `.forge/` is repo-controlled (f0rge #68).91 + match std::fs::symlink_metadata(path) {92 + Ok(m) if m.file_type().is_symlink() => {93 + eprintln!("hu13: forge: skipping {label}: symlink");94 + return None;95 + }96 + _ => {}97 + }98 let len = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);99 if len > FORGE_MAX_BYTES {100 eprintln!("hu13: forge: skipping {label}: over 256 KiB ({len} bytes)");101 diff --git a/src/gitx.rs b/src/gitx.rs102 index 56acaf5..47910e6 100644103 --- a/src/gitx.rs104 +++ b/src/gitx.rs105 @@ -106,6 +106,9 @@ pub struct TreeEntry {106 pub name_bytes: Vec<u8>,107 /// `true` for directories (type `tree`), `false` otherwise.108 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,112 }113 114 /// Exact bytes of `branch:path` from [`show`].115 @@ -580,12 +583,13 @@ pub fn ls_tree(repo: &Path, branch: &str, path: &str) -> Result<Vec<TreeEntry>,116 // meta: "<mode> <type> <sha>"117 let meta_str = String::from_utf8_lossy(meta);118 let mut it = meta_str.split_whitespace();119 - let _mode = it.next().unwrap_or("");120 + let mode = it.next().unwrap_or("");121 let typ = it.next().unwrap_or("");122 entries.push(TreeEntry {123 name: String::from_utf8_lossy(&name_bytes).into_owned(),124 name_bytes,125 is_dir: typ == "tree",126 + is_gitlink: mode == "160000",127 });128 }129 entries.sort_by(|a, b| {130 diff --git a/src/inbox.rs b/src/inbox.rs131 index f366558..bc24f3a 100644132 --- a/src/inbox.rs133 +++ b/src/inbox.rs134 @@ -826,7 +826,7 @@ pub fn cmd_list(inbox_dir: &Path) -> i32 {135 for e in list_entries(inbox_dir) {136 println!(137 "{} [{}] {} {}",138 - e.id,139 + escape_terminal(&e.id),140 escape_terminal(&e.kind),141 escape_terminal(&e.received_at),142 escape_terminal(&e.title)143 diff --git a/src/releases.rs b/src/releases.rs144 index 129d61a..4ef2d1b 100644145 --- a/src/releases.rs146 +++ b/src/releases.rs147 @@ -93,6 +93,15 @@ fn split_frontmatter(text: &str) -> Option<(&str, &str)> {148 }149 150 fn read_text_capped(path: &Path, label: &str) -> Option<String> {151 + // Never follow symlinks: wiki and release artifacts already skip152 + // them, and `.forge/` is repo-controlled (f0rge #68).153 + match std::fs::symlink_metadata(path) {154 + Ok(m) if m.file_type().is_symlink() => {155 + eprintln!("hu13: releases: skipping {label}: symlink");156 + return None;157 + }158 + _ => {}159 + }160 let len = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);161 if len > RELEASE_MAX_BYTES {162 eprintln!("hu13: releases: skipping {label}: over 256 KiB ({len} bytes)");163 diff --git a/src/wiki.rs b/src/wiki.rs164 index 8e332d5..a0e8d7e 100644165 --- a/src/wiki.rs166 +++ b/src/wiki.rs167 @@ -281,11 +281,17 @@ pub fn render(168 } else {169 body.push_str("<ul class=\"wiki-index\">\n");170 for p in &pages {171 + // Root pages already read as "hu13 README"; only nested pages172 + // need the path hint (was "hu13 README README", f0rge #68).173 + let hint = if p.path.contains('/') {174 + format!(" <span class=\"muted\">{}</span>", esc(&p.path))175 + } else {176 + String::new()177 + };178 body.push_str(&format!(179 - "<li><a href=\"/wiki/{}.html\">{}</a> <span class=\"muted\">{}</span></li>\n",180 + "<li><a href=\"/wiki/{}.html\">{}</a>{hint}</li>\n",181 esc(&p.path),182 esc(&p.title),183 - esc(&p.path),184 ));185 }186 body.push_str("</ul>\n");187 diff --git a/tests/polish_batch.rs b/tests/polish_batch.rs188 new file mode 100644189 index 0000000..5012ddd190 --- /dev/null191 +++ b/tests/polish_batch.rs192 @@ -0,0 +1,192 @@193 +//! f0rge #68: small robustness/consistency batch.194 +//! - `hu13 build .` names the repo after its directory, not "repo"195 +//! - gitlinks (mode 160000) get no raw copy / file page196 +//! - `inbox list` escapes a hostile id197 +//! - `.forge` symlinks are skipped like wiki/artifact symlinks198 +//! - wiki root pages do not repeat their filename after the title199 +200 +use std::path::{Path, PathBuf};201 +use std::process::Command;202 +203 +fn fresh(prefix: &str) -> PathBuf {204 + let dir = std::env::temp_dir().join(format!(205 + "hu13-polish-{prefix}-{}-{}",206 + std::process::id(),207 + std::time::SystemTime::now()208 + .duration_since(std::time::UNIX_EPOCH)209 + .map(|d| d.as_nanos())210 + .unwrap_or(0)211 + ));212 + std::fs::create_dir_all(&dir).unwrap();213 + dir214 +}215 +216 +fn git(repo: &Path, args: &[&str]) -> String {217 + let out = Command::new("git")218 + .arg("-C")219 + .arg(repo)220 + .args(args)221 + .output()222 + .expect("spawn git");223 + assert!(224 + out.status.success(),225 + "git {args:?}: {}",226 + String::from_utf8_lossy(&out.stderr)227 + );228 + String::from_utf8_lossy(&out.stdout).trim().to_string()229 +}230 +231 +fn init(repo: &Path) {232 + git(repo, &["init", "-b", "main"]);233 + git(repo, &["config", "user.name", "hu13-test"]);234 + git(repo, &["config", "user.email", "[email protected]"]);235 + git(repo, &["config", "commit.gpgsign", "false"]);236 +}237 +238 +fn build(repo: &Path, out: &Path, cwd: Option<&Path>) -> std::process::Output {239 + let mut cmd = Command::new(env!("CARGO_BIN_EXE_hu13"));240 + cmd.arg("build").arg(repo).arg("-o").arg(out);241 + if let Some(dir) = cwd {242 + cmd.current_dir(dir);243 + }244 + cmd.output().expect("spawn hu13 build")245 +}246 +247 +#[test]248 +fn dot_build_uses_directory_name() {249 + let parent = fresh("dot");250 + let repo = parent.join("my-project");251 + std::fs::create_dir_all(&repo).unwrap();252 + init(&repo);253 + std::fs::write(repo.join("README.md"), "# dot\n").unwrap();254 + git(&repo, &["add", "-A"]);255 + git(&repo, &["commit", "-m", "one"]);256 + let out = fresh("out");257 + let res = build(Path::new("."), &out, Some(&repo));258 + assert!(259 + res.status.success(),260 + "{}",261 + String::from_utf8_lossy(&res.stderr)262 + );263 + let index = std::fs::read_to_string(out.join("index.html")).unwrap();264 + assert!(265 + index.contains("<h1>my-project</h1>"),266 + "repo named after the directory: {}",267 + &index[..600.min(index.len())]268 + );269 + assert!(!index.contains("<h1>repo</h1>"), "not the literal 'repo'");270 +}271 +272 +#[test]273 +fn gitlink_gets_no_raw_or_page() {274 + let repo = fresh("gitlink");275 + init(&repo);276 + std::fs::write(repo.join("README.md"), "# gitlink\n").unwrap();277 + git(&repo, &["add", "-A"]);278 + git(&repo, &["commit", "-m", "one"]);279 + let head = git(&repo, &["rev-parse", "HEAD"]);280 + git(281 + &repo,282 + &[283 + "update-index",284 + "--add",285 + "--cacheinfo",286 + &format!("160000,{head},sub"),287 + ],288 + );289 + // Entry name for --cacheinfo is the last argument.290 + git(&repo, &["commit", "-m", "add gitlink"]);291 + let out = fresh("out");292 + let res = build(&repo, &out, None);293 + assert!(294 + res.status.success(),295 + "{}",296 + String::from_utf8_lossy(&res.stderr)297 + );298 + assert!(!out.join("raw/sub").exists(), "no raw copy for a gitlink");299 + assert!(300 + !out.join("files/sub.html").exists(),301 + "no file page for a gitlink"302 + );303 + let index = std::fs::read_to_string(out.join("index.html")).unwrap();304 + assert!(index.contains("@ submodule"), "gitlink marked in the table");305 +}306 +307 +#[test]308 +fn inbox_list_escapes_hostile_id() {309 + let inbox = fresh("inbox");310 + // A filename can contain ESC; list must never print it raw.311 + let name = "bad\u{1b}id.json";312 + std::fs::write(313 + inbox.join(name),314 + r#"{"type":"issue","title":"t","received_at":"2026-09-10T00:00:00Z"}"#,315 + )316 + .unwrap();317 + let out = Command::new(env!("CARGO_BIN_EXE_hu13"))318 + .arg("inbox")319 + .arg("list")320 + .arg("--inbox-dir")321 + .arg(&inbox)322 + .output()323 + .unwrap();324 + assert!(out.status.success());325 + let stdout = String::from_utf8_lossy(&out.stdout);326 + assert!(stdout.contains("\\x1b"), "escaped id: {stdout}");327 + assert!(!stdout.contains('\u{1b}'), "no raw ESC: {stdout}");328 +}329 +330 +#[test]331 +fn forge_symlink_is_skipped() {332 + let repo = fresh("symlink");333 + init(&repo);334 + std::fs::write(repo.join("README.md"), "# symlink\n").unwrap();335 + let dir = repo.join(".forge/issues/aaaaaaaa-1111-4111-8111-111111111111");336 + std::fs::create_dir_all(&dir).unwrap();337 + let outside = fresh("outside").join("issue.md");338 + std::fs::write(339 + &outside,340 + "---\nid: aaaaaaaa-1111-4111-8111-111111111111\ntitle: linked\nstate: open\ncreated: 2026-09-10T00:00:00Z\n---\nbody\n",341 + )342 + .unwrap();343 + std::os::unix::fs::symlink(&outside, dir.join("issue.md")).unwrap();344 + git(&repo, &["add", "-A"]);345 + git(&repo, &["commit", "-m", "one"]);346 + let out = fresh("out");347 + let res = build(&repo, &out, None);348 + assert!(349 + res.status.success(),350 + "{}",351 + String::from_utf8_lossy(&res.stderr)352 + );353 + let err = String::from_utf8_lossy(&res.stderr);354 + assert!(err.contains("symlink"), "warned about the symlink: {err}");355 + assert!(356 + !out.join("issues/aaaaaaaa-1111-4111-8111-111111111111.html")357 + .exists(),358 + "no page for a symlinked issue"359 + );360 +}361 +362 +#[test]363 +fn wiki_root_page_does_not_repeat_its_filename() {364 + let repo = fresh("wiki");365 + init(&repo);366 + std::fs::write(repo.join("README.md"), "# wiki\n").unwrap();367 + std::fs::create_dir_all(repo.join(".forge/wiki/nested")).unwrap();368 + std::fs::write(repo.join(".forge/wiki/README.md"), "# hu13 README\n").unwrap();369 + std::fs::write(repo.join(".forge/wiki/nested/guide.md"), "# Guide\n").unwrap();370 + git(&repo, &["add", "-A"]);371 + git(&repo, &["commit", "-m", "one"]);372 + let out = fresh("out");373 + let res = build(&repo, &out, None);374 + assert!(res.status.success());375 + let index = std::fs::read_to_string(out.join("wiki/index.html")).unwrap();376 + assert!(377 + !index.contains("hu13 README</a> README"),378 + "root page path hint dropped: {index}"379 + );380 + assert!(381 + index.contains("Guide</a> <span class=\"muted\">nested/guide.md</span>"),382 + "nested pages keep the path hint"383 + );384 +}385 -- 386 2.56.0387