.forge/prs/6bc2c630-84d9-4ff8-92be-d3601659c2ab/patch.diff 14.1 KiB 387 lines Raw
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