1 //! Truncate all table fields (m6/truncate, f0rge #42):2 //! every table cell carries `.trunc` + `title=`, desktop fits 1100px.3 4 use std::sync::atomic::{AtomicU64, Ordering};5 6 static SEQ: AtomicU64 = AtomicU64::new(0);7 8 use std::path::{Path, PathBuf};9 use std::process::Command;10 11 fn bin() -> &'static str {12 env!("CARGO_BIN_EXE_hu13")13 }14 15 fn manifest() -> PathBuf {16 PathBuf::from(env!("CARGO_MANIFEST_DIR"))17 }18 19 fn fresh(prefix: &str) -> PathBuf {20 let dir = std::env::temp_dir().join(format!(21 "hu13-truncate-{prefix}-{}-{}",22 std::process::id(),23 std::time::SystemTime::now()24 .duration_since(std::time::UNIX_EPOCH)25 .map(|d| d.as_nanos())26 .unwrap_or(0)27 .wrapping_add(u128::from(SEQ.fetch_add(1, Ordering::Relaxed)))28 ));29 std::fs::create_dir_all(&dir).unwrap();30 dir31 }32 33 fn git(repo: &Path, args: &[&str]) {34 let st = Command::new("git")35 .arg("-C")36 .arg(repo)37 .args(args)38 .status()39 .expect("spawn git");40 assert!(st.success(), "git {args:?} failed");41 }42 43 fn forge_write(repo: &Path, rel: &str, content: &str) {44 let dest = repo.join(".forge").join(rel);45 std::fs::create_dir_all(dest.parent().expect("parent")).unwrap();46 std::fs::write(&dest, content).unwrap();47 }48 49 fn css() -> String {50 std::fs::read_to_string(manifest().join("static").join("style.css")).expect("read style.css")51 }52 53 /// Evil long fixture: unbroken 300-char strings + HTML metachars in every54 /// table (commits subject/author, issues/pulls/tasks/releases titles,55 /// file-table message, CI step name). Returns (dist, evil_title, evil_subject).56 fn build_evil_fixture() -> (PathBuf, String, String) {57 let repo = fresh("repo");58 git(&repo, &["init", "-b", "main"]);59 git(&repo, &["config", "user.name", "hu13-test"]);60 git(&repo, &["config", "user.email", "[email protected]"]);61 git(&repo, &["config", "commit.gpgsign", "false"]);62 // Evil long subject: unbroken + <script> + quotes (tests esc in cell + title=).63 let evil_subject = format!(64 "S{} <script>alert(1)</script> \"quoted\" & more",65 "x".repeat(260)66 );67 // Long evil author via --author on one commit.68 let evil_author = format!("{} <[email protected]>", "A".repeat(120));69 std::fs::write(repo.join("README.md"), "# evil fixture\n").unwrap();70 std::fs::write(repo.join("notes.txt"), "hello\n").unwrap();71 git(&repo, &["add", "-A"]);72 git(73 &repo,74 &[75 "commit",76 "-m",77 &evil_subject,78 &format!("--author={evil_author}"),79 ],80 );81 std::fs::write(repo.join("notes.txt"), "hello again\nsecond\n").unwrap();82 git(&repo, &["add", "-A"]);83 git(&repo, &["commit", "-m", "second commit"]);84 85 let evil_title = format!("T{} <b>bold</b> \"q\" & tail", "y".repeat(260));86 forge_write(87 &repo,88 "issues/aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa/issue.md",89 &format!(90 "---\nid: aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa\ntitle: {evil_title}\nstate: open\nlabels: []\nassignees: []\ncreated: 2026-09-10T03:00:00Z\n---\nBody.\n"91 ),92 );93 forge_write(94 &repo,95 "prs/bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbbbb/pr.md",96 &format!(97 "---\nid: bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbbbb\ntitle: {evil_title}\nstate: open\nbase: main\nhead: evil-branch-name-that-is-way-too-long-for-a-table-cell-to-fit\ncreated: 2026-09-10T03:30:00Z\n---\nPR body.\n"98 ),99 );100 forge_write(101 &repo,102 "tasks/cccccccc-cccc-4ccc-cccc-cccccccccccc.md",103 &format!(104 "---\nid: cccccccc-cccc-4ccc-cccc-cccccccccccc\ntitle: {evil_title}\nstatus: todo\ncreated: 2026-09-10T03:00:00Z\n---\nTask body.\n"105 ),106 );107 forge_write(&repo, "ci/ci.env", "runner: local\n");108 let long_step = format!("{}_step.sh", "s".repeat(100));109 forge_write(110 &repo,111 &format!("ci/steps/{long_step}"),112 "#!/bin/sh\nexit 0\n",113 );114 forge_write(115 &repo,116 "ci/runs/2026-09-10T00-00-00Z/summary.md",117 &format!(118 "---\nrun: 2026-09-10T00-00-00Z\nverdict: pass\ncreated: 2026-09-10T00:00:00Z\nsteps:\n - name: {long_step}\n exit: 0\n duration_ms: 3\n---\nlog\n"119 ),120 );121 forge_write(122 &repo,123 "releases/v9.9.md",124 &format!(125 "---\nversion: v9.9\ntitle: {evil_title}\ncreated: 2026-09-10T02:00:00Z\n---\nNotes.\n"126 ),127 );128 git(&repo, &["add", "-A"]);129 git(&repo, &["commit", "-m", "forge evil fixture"]);130 let out = fresh("out");131 let st = Command::new(bin())132 .arg("build")133 .arg(&repo)134 .arg("-o")135 .arg(&out)136 .status()137 .expect("spawn hu13 build");138 assert!(st.success(), "evil fixture build exits 0");139 (out, evil_title, evil_subject)140 }141 142 fn assert_every_td_truncated(html: &str, page: &str) {143 // m6/cards2 (f0rge #46): the file-table page (index.html) stays a real144 // table — trunc + title= kept, no data-label card treatment.145 assert_every_td_truncated_opts(html, page, page != "index.html");146 }147 148 fn assert_every_td_truncated_opts(html: &str, page: &str, expect_labels: bool) {149 let parts: Vec<&str> = html.split("<td").skip(1).collect();150 assert!(!parts.is_empty(), "{page} has cells");151 for (i, p) in parts.iter().enumerate() {152 let head = p.split('>').next().unwrap_or("");153 assert!(154 head.contains("trunc"),155 "{page} td #{i} carries .trunc (got {head:?})"156 );157 assert!(158 head.contains("title="),159 "{page} td #{i} carries title= (got {head:?})"160 );161 if expect_labels {162 assert!(163 head.contains("data-label="),164 "{page} td #{i} keeps data-label (got {head:?})"165 );166 }167 }168 if !expect_labels {169 assert!(170 !html.contains("data-label="),171 "{page} has no card data-labels"172 );173 }174 }175 176 #[test]177 fn trunc_css_utilities_and_widths() {178 let css = css();179 for needle in [180 ".trunc",181 "text-overflow:ellipsis",182 "40ch",183 "16ch",184 "10ch",185 "72ch",186 "60ch",187 ] {188 assert!(css.contains(needle), "CSS mentions {needle}");189 }190 // Desktop tables capped at the container (fit 1100px, no page h-scroll).191 assert!(css.contains("max-width:100%"), "tables capped at container");192 assert!(193 css.contains("overflow-x:clip") || css.contains("overflow-x:auto"),194 "no page h-scroll"195 );196 // Narrow keeps card reflow (trunc disabled, scroll off).197 assert!(198 css.contains("@media (max-width:767px)"),199 "narrow query kept"200 );201 }202 203 #[test]204 fn every_table_cell_truncates_with_title() {205 let (out, evil_title, evil_subject) = build_evil_fixture();206 let read = |rel: &str| std::fs::read_to_string(out.join(rel)).expect(rel);207 // Full text is never lost: escaped long strings stay in the DOM and in title=.208 for page in [209 "commits.html",210 "issues/index.html",211 "pulls/index.html",212 "tasks/index.html",213 "releases/index.html",214 "ci/index.html",215 "index.html",216 ] {217 let html = read(page);218 assert!(219 !html.contains("<script>alert(1)</script>"),220 "{page} escapes evil HTML"221 );222 assert!(!html.contains("<b>bold</b>"), "{page} escapes evil tags");223 }224 // Evil subject + title survive escaped (cell text + title attr).225 let commits = read("commits.html");226 assert!(commits.contains("<script>"), "commits escape kept");227 assert!(commits.contains("trunc-sha"), "commits sha width");228 assert!(commits.contains("trunc-subject"), "commits subject width");229 assert!(commits.contains("trunc-author"), "commits author width");230 // Per-table trunc asserts + title attrs.231 for page in [232 "commits.html",233 "issues/index.html",234 "pulls/index.html",235 "releases/index.html",236 "ci/index.html",237 "index.html",238 ] {239 assert_every_td_truncated(&read(page), page);240 }241 // Kind-specific widths per table.242 assert!(243 read("issues/index.html").contains("trunc-title"),244 "issues title 60ch"245 );246 assert!(247 read("pulls/index.html").contains("trunc-title"),248 "pulls title 60ch"249 );250 assert!(251 read("releases/index.html").contains("trunc-title"),252 "releases title 60ch"253 );254 assert!(255 read("index.html").contains("trunc-message"),256 "file-table message 72ch"257 );258 // Tasks list uses trunc + title on titles (no table by design).259 let tasks = read("tasks/index.html");260 assert!(!tasks.contains("<table"), "tasks stays list-only");261 assert!(tasks.contains("trunc-title"), "tasks titles truncate");262 assert!(tasks.contains("title="), "tasks titles keep full text");263 // Long content kept (CSS truncates visually, nothing cut in HTML).264 assert!(265 read("issues/index.html").contains(&"y".repeat(100)),266 "long issue title kept"267 );268 assert!(269 commits.contains(&"x".repeat(100)),270 "long commit subject kept"271 );272 let _ = (evil_title, evil_subject);273 }