From 788320a56f99f92843c52ecb6e878c0f30460656 Mon Sep 17 00:00:00 2001 From: hu13-test Date: Tue, 27 Oct 2026 16:36:25 +0000 Subject: [PATCH] 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. --- src/build.rs | 17 +++- src/forge.rs | 9 ++ src/gitx.rs | 6 +- src/inbox.rs | 2 +- src/releases.rs | 9 ++ src/wiki.rs | 10 ++- tests/polish_batch.rs | 192 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 239 insertions(+), 6 deletions(-) create mode 100644 tests/polish_batch.rs diff --git a/src/build.rs b/src/build.rs index 352fc43..d6e3420 100644 --- a/src/build.rs +++ b/src/build.rs @@ -68,7 +68,9 @@ pub fn resolve_repo_name(repo: &Path, flag: Option<&str>) -> String { return t.to_string(); } } - repo.file_name() + // `hu13 build .` must name the repo, not the path "." (f0rge #68). + let abs = std::fs::canonicalize(repo).unwrap_or_else(|_| repo.to_path_buf()); + abs.file_name() .map(|n| n.to_string_lossy().into_owned()) .map(|n| n.trim().to_string()) .filter(|n| !n.is_empty()) @@ -855,6 +857,13 @@ pub fn file_table_html( esc(&age), esc(&age) )); + } else if e.is_gitlink { + // Submodule: no blob in this repo, so no file page/raw copy. + out.push_str(&format!( + "{} @ submodule\ + \n", + esc(&e.name), + )); } else { out.push_str(&format!( "{}\ @@ -1192,7 +1201,8 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result Result = Vec::new(); for (d, ents) in &dir_entries { for e in ents { + if e.is_gitlink { + continue; + } let full = if d.is_empty() { e.name.clone() } else { diff --git a/src/forge.rs b/src/forge.rs index ba988e7..a9ada13 100644 --- a/src/forge.rs +++ b/src/forge.rs @@ -60,6 +60,15 @@ fn split_frontmatter(text: &str) -> Option<(&str, &str)> { /// Read a UTF-8 file, skipping (with a warning) files that are missing, /// oversized, or non-UTF-8. `label` names the file for warnings. fn read_text_capped(path: &Path, label: &str) -> Option { + // Never follow symlinks: wiki and release artifacts already skip + // them, and `.forge/` is repo-controlled (f0rge #68). + match std::fs::symlink_metadata(path) { + Ok(m) if m.file_type().is_symlink() => { + eprintln!("hu13: forge: skipping {label}: symlink"); + return None; + } + _ => {} + } let len = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0); if len > FORGE_MAX_BYTES { eprintln!("hu13: forge: skipping {label}: over 256 KiB ({len} bytes)"); diff --git a/src/gitx.rs b/src/gitx.rs index 56acaf5..47910e6 100644 --- a/src/gitx.rs +++ b/src/gitx.rs @@ -106,6 +106,9 @@ pub struct TreeEntry { pub name_bytes: Vec, /// `true` for directories (type `tree`), `false` otherwise. pub is_dir: bool, + /// `true` for submodule entries (mode `160000`): no blob exists, so + /// they must never get a raw copy or a file page (f0rge #68). + pub is_gitlink: bool, } /// Exact bytes of `branch:path` from [`show`]. @@ -580,12 +583,13 @@ pub fn ls_tree(repo: &Path, branch: &str, path: &str) -> Result, // meta: " " let meta_str = String::from_utf8_lossy(meta); let mut it = meta_str.split_whitespace(); - let _mode = it.next().unwrap_or(""); + let mode = it.next().unwrap_or(""); let typ = it.next().unwrap_or(""); entries.push(TreeEntry { name: String::from_utf8_lossy(&name_bytes).into_owned(), name_bytes, is_dir: typ == "tree", + is_gitlink: mode == "160000", }); } entries.sort_by(|a, b| { diff --git a/src/inbox.rs b/src/inbox.rs index f366558..bc24f3a 100644 --- a/src/inbox.rs +++ b/src/inbox.rs @@ -826,7 +826,7 @@ pub fn cmd_list(inbox_dir: &Path) -> i32 { for e in list_entries(inbox_dir) { println!( "{} [{}] {} {}", - e.id, + escape_terminal(&e.id), escape_terminal(&e.kind), escape_terminal(&e.received_at), escape_terminal(&e.title) diff --git a/src/releases.rs b/src/releases.rs index 129d61a..4ef2d1b 100644 --- a/src/releases.rs +++ b/src/releases.rs @@ -93,6 +93,15 @@ fn split_frontmatter(text: &str) -> Option<(&str, &str)> { } fn read_text_capped(path: &Path, label: &str) -> Option { + // Never follow symlinks: wiki and release artifacts already skip + // them, and `.forge/` is repo-controlled (f0rge #68). + match std::fs::symlink_metadata(path) { + Ok(m) if m.file_type().is_symlink() => { + eprintln!("hu13: releases: skipping {label}: symlink"); + return None; + } + _ => {} + } let len = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0); if len > RELEASE_MAX_BYTES { eprintln!("hu13: releases: skipping {label}: over 256 KiB ({len} bytes)"); diff --git a/src/wiki.rs b/src/wiki.rs index 8e332d5..a0e8d7e 100644 --- a/src/wiki.rs +++ b/src/wiki.rs @@ -281,11 +281,17 @@ pub fn render( } else { body.push_str("
    \n"); for p in &pages { + // Root pages already read as "hu13 README"; only nested pages + // need the path hint (was "hu13 README README", f0rge #68). + let hint = if p.path.contains('/') { + format!(" {}", esc(&p.path)) + } else { + String::new() + }; body.push_str(&format!( - "
  • {} {}
  • \n", + "
  • {}{hint}
  • \n", esc(&p.path), esc(&p.title), - esc(&p.path), )); } body.push_str("
\n"); diff --git a/tests/polish_batch.rs b/tests/polish_batch.rs new file mode 100644 index 0000000..5012ddd --- /dev/null +++ b/tests/polish_batch.rs @@ -0,0 +1,192 @@ +//! f0rge #68: small robustness/consistency batch. +//! - `hu13 build .` names the repo after its directory, not "repo" +//! - gitlinks (mode 160000) get no raw copy / file page +//! - `inbox list` escapes a hostile id +//! - `.forge` symlinks are skipped like wiki/artifact symlinks +//! - wiki root pages do not repeat their filename after the title + +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn fresh(prefix: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "hu13-polish-{prefix}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn git(repo: &Path, args: &[&str]) -> String { + let out = Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .output() + .expect("spawn git"); + assert!( + out.status.success(), + "git {args:?}: {}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8_lossy(&out.stdout).trim().to_string() +} + +fn init(repo: &Path) { + git(repo, &["init", "-b", "main"]); + git(repo, &["config", "user.name", "hu13-test"]); + git(repo, &["config", "user.email", "hu13@test.local"]); + git(repo, &["config", "commit.gpgsign", "false"]); +} + +fn build(repo: &Path, out: &Path, cwd: Option<&Path>) -> std::process::Output { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_hu13")); + cmd.arg("build").arg(repo).arg("-o").arg(out); + if let Some(dir) = cwd { + cmd.current_dir(dir); + } + cmd.output().expect("spawn hu13 build") +} + +#[test] +fn dot_build_uses_directory_name() { + let parent = fresh("dot"); + let repo = parent.join("my-project"); + std::fs::create_dir_all(&repo).unwrap(); + init(&repo); + std::fs::write(repo.join("README.md"), "# dot\n").unwrap(); + git(&repo, &["add", "-A"]); + git(&repo, &["commit", "-m", "one"]); + let out = fresh("out"); + let res = build(Path::new("."), &out, Some(&repo)); + assert!( + res.status.success(), + "{}", + String::from_utf8_lossy(&res.stderr) + ); + let index = std::fs::read_to_string(out.join("index.html")).unwrap(); + assert!( + index.contains("

my-project

"), + "repo named after the directory: {}", + &index[..600.min(index.len())] + ); + assert!(!index.contains("

repo

"), "not the literal 'repo'"); +} + +#[test] +fn gitlink_gets_no_raw_or_page() { + let repo = fresh("gitlink"); + init(&repo); + std::fs::write(repo.join("README.md"), "# gitlink\n").unwrap(); + git(&repo, &["add", "-A"]); + git(&repo, &["commit", "-m", "one"]); + let head = git(&repo, &["rev-parse", "HEAD"]); + git( + &repo, + &[ + "update-index", + "--add", + "--cacheinfo", + &format!("160000,{head},sub"), + ], + ); + // Entry name for --cacheinfo is the last argument. + git(&repo, &["commit", "-m", "add gitlink"]); + let out = fresh("out"); + let res = build(&repo, &out, None); + assert!( + res.status.success(), + "{}", + String::from_utf8_lossy(&res.stderr) + ); + assert!(!out.join("raw/sub").exists(), "no raw copy for a gitlink"); + assert!( + !out.join("files/sub.html").exists(), + "no file page for a gitlink" + ); + let index = std::fs::read_to_string(out.join("index.html")).unwrap(); + assert!(index.contains("@ submodule"), "gitlink marked in the table"); +} + +#[test] +fn inbox_list_escapes_hostile_id() { + let inbox = fresh("inbox"); + // A filename can contain ESC; list must never print it raw. + let name = "bad\u{1b}id.json"; + std::fs::write( + inbox.join(name), + r#"{"type":"issue","title":"t","received_at":"2026-09-10T00:00:00Z"}"#, + ) + .unwrap(); + let out = Command::new(env!("CARGO_BIN_EXE_hu13")) + .arg("inbox") + .arg("list") + .arg("--inbox-dir") + .arg(&inbox) + .output() + .unwrap(); + assert!(out.status.success()); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("\\x1b"), "escaped id: {stdout}"); + assert!(!stdout.contains('\u{1b}'), "no raw ESC: {stdout}"); +} + +#[test] +fn forge_symlink_is_skipped() { + let repo = fresh("symlink"); + init(&repo); + std::fs::write(repo.join("README.md"), "# symlink\n").unwrap(); + let dir = repo.join(".forge/issues/aaaaaaaa-1111-4111-8111-111111111111"); + std::fs::create_dir_all(&dir).unwrap(); + let outside = fresh("outside").join("issue.md"); + std::fs::write( + &outside, + "---\nid: aaaaaaaa-1111-4111-8111-111111111111\ntitle: linked\nstate: open\ncreated: 2026-09-10T00:00:00Z\n---\nbody\n", + ) + .unwrap(); + std::os::unix::fs::symlink(&outside, dir.join("issue.md")).unwrap(); + git(&repo, &["add", "-A"]); + git(&repo, &["commit", "-m", "one"]); + let out = fresh("out"); + let res = build(&repo, &out, None); + assert!( + res.status.success(), + "{}", + String::from_utf8_lossy(&res.stderr) + ); + let err = String::from_utf8_lossy(&res.stderr); + assert!(err.contains("symlink"), "warned about the symlink: {err}"); + assert!( + !out.join("issues/aaaaaaaa-1111-4111-8111-111111111111.html") + .exists(), + "no page for a symlinked issue" + ); +} + +#[test] +fn wiki_root_page_does_not_repeat_its_filename() { + let repo = fresh("wiki"); + init(&repo); + std::fs::write(repo.join("README.md"), "# wiki\n").unwrap(); + std::fs::create_dir_all(repo.join(".forge/wiki/nested")).unwrap(); + std::fs::write(repo.join(".forge/wiki/README.md"), "# hu13 README\n").unwrap(); + std::fs::write(repo.join(".forge/wiki/nested/guide.md"), "# Guide\n").unwrap(); + git(&repo, &["add", "-A"]); + git(&repo, &["commit", "-m", "one"]); + let out = fresh("out"); + let res = build(&repo, &out, None); + assert!(res.status.success()); + let index = std::fs::read_to_string(out.join("wiki/index.html")).unwrap(); + assert!( + !index.contains("hu13 README README"), + "root page path hint dropped: {index}" + ); + assert!( + index.contains("Guide nested/guide.md"), + "nested pages keep the path hint" + ); +} -- 2.56.0