1 From e04d65ca2f1b82bdd5ec037e6340df05833b3613 Mon Sep 17 00:00:00 20012 From: gl0w <gl0w@world>3 Date: Thu, 10 Sep 2026 07:09:44 +01004 Subject: [PATCH] m6/diff-colors: tokyo night diff classes dark+light (1535 tests)6 7 ---8 src/commits.rs | 78 +++++++++++++++++++++++++++++++--9 src/forge.rs | 49 ++++++++++++++++++++-10 src/layout.rs | 117 ++++++++++++++++++++++++++++++++++++++++++++++++-11 3 files changed, 237 insertions(+), 7 deletions(-)12 13 diff --git a/src/commits.rs b/src/commits.rs14 index 3467d9f..9c3b288 10064415 --- a/src/commits.rs16 +++ b/src/commits.rs17 @@ -1,14 +1,15 @@18 //! `commits.html` + `commit/<sha>.html` (m2, f0rge issue #5).19 //!20 -//! Commit subjects, messages, and diffs are untrusted: everything goes21 -//! through [`crate::layout::esc`] into an escaped `<pre>`. Diffs come from22 +//! Commit subjects and messages go through [`crate::layout::esc`]; diffs go23 +//! through [`crate::layout::diff_html`] (escaped line-level color spans in an24 +//! escaped `<pre>`, m6 diff-colors f0rge #22). Diffs come from25 //! [`crate::gitx::diff`] (binary content noted, output truncated past26 //! 2000 lines with a marker). Nothing here goes through markdown.27 28 use std::path::Path;29 30 use crate::gitx;31 -use crate::layout::{esc, page_site};32 +use crate::layout::{diff_html, esc, page_site};33 34 /// Short display form of a full sha (first 7 hex chars, git-style).35 #[must_use]36 @@ -72,7 +73,7 @@ pub fn render_loaded(37 esc(&c.author),38 esc(&c.date),39 esc(&message),40 - esc(&diff),41 + diff_html(&diff),42 );43 let html = page_site(44 &format!("commit {} — {site_name}", short_sha(&c.sha)),45 @@ -237,6 +238,75 @@ mod tests {46 );47 }48 49 + #[test]50 + fn diff_lines_carry_color_spans() {51 + // m6 diff-colors (f0rge #22): hunk cyan, +++/--- bold, + green, - red.52 + let repo = fixture_repo();53 + let out = mktemp("out");54 + render(&repo, &out, "main", 100, "test", "<nav>", None).expect("render");55 + let sha = shas(&repo)[1].clone(); // text hunk + binary commit56 + let detail = std::fs::read_to_string(out.join("commit").join(format!("{sha}.html")))57 + .expect("detail");58 + assert!(59 + detail.contains("<span class=\"diff-hunk\">@@"),60 + "hunk header cyan: {detail}"61 + );62 + assert!(63 + detail.contains("<span class=\"diff-file\">---"),64 + "--- file line bold: {detail}"65 + );66 + assert!(67 + detail.contains("<span class=\"diff-file\">+++"),68 + "+++ file line bold: {detail}"69 + );70 + assert!(71 + detail.contains("<span class=\"diff-add\">+line two added</span>"),72 + "added line green: {detail}"73 + );74 + // Binary note + truncation marker behavior intact (no span, still shown).75 + assert!(76 + detail.contains("Binary files") || detail.contains(gitx::BINARY_NOTE),77 + "binary note intact"78 + );79 + let huge_sha = shas(&repo)[0].clone();80 + let huge = std::fs::read_to_string(out.join("commit").join(format!("{huge_sha}.html")))81 + .expect("huge detail");82 + assert!(83 + huge.contains(gitx::DIFF_TRUNCATED_MARKER),84 + "truncation marker intact with spans"85 + );86 + }87 +88 + #[test]89 + fn diff_escaping_intact_inside_spans() {90 + // Evil `<script>` added line: escaped, still inside the green span.91 + let dir = mktemp("evil-diff");92 + git(&dir, &["init", "-b", "main"]);93 + git(&dir, &["config", "user.name", "hu13-test"]);94 + git(&dir, &["config", "user.email", "[email protected]"]);95 + git(&dir, &["config", "commit.gpgsign", "false"]);96 + std::fs::write(dir.join("x.txt"), "ok\n").unwrap();97 + git(&dir, &["add", "-A"]);98 + git(&dir, &["commit", "-m", "base"]);99 + std::fs::write(dir.join("x.txt"), "ok\nevil <script>alert(1)</script>\n").unwrap();100 + git(&dir, &["add", "-A"]);101 + git(&dir, &["commit", "-m", "evil diff"]);102 + let out = mktemp("out");103 + render(&dir, &out, "main", 100, "test", "<nav>", None).expect("render");104 + let sha = shas(&dir)[0].clone();105 + let detail = std::fs::read_to_string(out.join("commit").join(format!("{sha}.html")))106 + .expect("detail");107 + assert!(!detail.contains("<script>alert(1)</script>"), "no raw HTML");108 + assert!(109 + detail.contains("<script>"),110 + "escaped form present: {detail}"111 + );112 + assert!(113 + detail.contains("<span class=\"diff-add\">+evil <script>"),114 + "escaped inside green span: {detail}"115 + );116 + }117 +118 #[test]119 fn max_log_limits_list_and_details() {120 let repo = fixture_repo();121 diff --git a/src/forge.rs b/src/forge.rs122 index 951136b..1f8af3e 100644123 --- a/src/forge.rs124 +++ b/src/forge.rs125 @@ -23,7 +23,7 @@ use std::path::Path;126 use serde::Deserialize;127 128 use crate::build::markdown_to_html;129 -use crate::layout::{esc, page_site};130 +use crate::layout::{diff_html, esc, page_site};131 132 /// `.forge/` files larger than this are skipped (schema SEC-01: 256 KiB).133 pub const FORGE_MAX_BYTES: u64 = 256 * 1024;134 @@ -898,7 +898,7 @@ fn render_pr_page(pr: &Pr, site_name: &str, nav: &str, description: Option<&str>135 Some(patch) if !patch.is_empty() => {136 body.push_str(&format!(137 "<div class=\"pre-scroll\"><pre class=\"diff\">{}</pre></div>\n",138 - esc(patch)139 + diff_html(patch)140 ));141 }142 _ => body.push_str("<p class=\"muted\">No patch attached.</p>\n"),143 @@ -1326,6 +1326,51 @@ mod tests {144 assert!(html.contains("lgtm"), "review body shown");145 }146 147 + #[test]148 + fn pr_patch_lines_carry_diff_spans() {149 + // m6 diff-colors (f0rge #22): PR patch.diff shares the commit renderer.150 + let repo = mktemp("pr-diff");151 + write_forge(152 + &repo,153 + "prs/cccccccc-cccc-cccc-cccc-cccccccccccc/pr.md",154 + "---\nid: cccccccc-cccc-cccc-cccc-cccccccccccc\ntitle: Fix it\nstate: open\nbase: main\nhead: fix\ncreated: 2026-09-10T03:00:00Z\n---\ndesc\n",155 + );156 + let dest = repo157 + .join(".forge")158 + .join("prs/cccccccc-cccc-cccc-cccc-cccccccccccc/patch.diff");159 + std::fs::write(160 + &dest,161 + "diff --git a/x b/x\n--- a/x\n+++ b/x\n@@ -1 +1 @@\n-old\n+evil <script>\n",162 + )163 + .unwrap();164 + let out = mktemp("pr-diff-out");165 + render_loaded(&load(&repo), &out, "test", "<nav>", None).expect("render");166 + let html =167 + std::fs::read_to_string(out.join("pulls/cccccccc-cccc-cccc-cccc-cccccccccccc.html"))168 + .expect("pr page");169 + assert!(170 + html.contains("<span class=\"diff-hunk\">@@ -1 +1 @@</span>"),171 + "hunk cyan: {html}"172 + );173 + assert!(174 + html.contains("<span class=\"diff-file\">--- a/x</span>"),175 + "--- file bold"176 + );177 + assert!(178 + html.contains("<span class=\"diff-file\">+++ b/x</span>"),179 + "+++ file bold"180 + );181 + assert!(182 + html.contains("<span class=\"diff-del\">-old</span>"),183 + "removed red"184 + );185 + assert!(!html.contains("<script>"), "patch escaped");186 + assert!(187 + html.contains("<span class=\"diff-add\">+evil <script></span>"),188 + "added green, escaped in span"189 + );190 + }191 +192 #[test]193 fn tasks_group_and_ci_fail_badge() {194 let repo = mktemp("taskci");195 diff --git a/src/layout.rs b/src/layout.rs196 index fbecd44..af31aa2 100644197 --- a/src/layout.rs198 +++ b/src/layout.rs199 @@ -24,6 +24,60 @@ pub fn esc(input: &str) -> String {200 out201 }202 203 +/// Render unified diff / patch text as escaped HTML with line-level color204 +/// spans (m6 diff-colors, f0rge issue #22; Tokyo Night vars in205 +/// `static/style.css`, dark + light).206 +///207 +/// Line kinds (line-level only, no word-diff): `@@` hunk headers go in208 +/// `<span class="diff-hunk">`, `+++`/`---` file lines in209 +/// `<span class="diff-file">` (bold fg), `+` additions in210 +/// `<span class="diff-add">`, `-` removals in `<span class="diff-del">`.211 +/// Everything else (context, `diff --git` / `index` headers, commit212 +/// metadata, `Binary files ... differ` notes, `[hu13: ...]` truncation /213 +/// binary markers) is escaped as-is with no span so truncation and binary214 +/// behavior stay intact. Every line is escaped with [`esc`] before wrapping,215 +/// so hostile `<script>` inside an added line renders as text. Output joins216 +/// lines with `\n` (preserving a trailing newline) for use inside an217 +/// already-escaped `<pre class="diff">`.218 +#[must_use]219 +pub fn diff_html(raw: &str) -> String {220 + if raw.is_empty() {221 + return String::new();222 + }223 + let mut out = String::with_capacity(raw.len() + raw.len() / 4);224 + let mut first = true;225 + for line in raw.lines() {226 + if !first {227 + out.push('\n');228 + }229 + first = false;230 + let e = esc(line);231 + if line.starts_with("@@") {232 + out.push_str("<span class=\"diff-hunk\">");233 + out.push_str(&e);234 + out.push_str("</span>");235 + } else if line.starts_with("+++") || line.starts_with("---") {236 + out.push_str("<span class=\"diff-file\">");237 + out.push_str(&e);238 + out.push_str("</span>");239 + } else if line.starts_with('+') {240 + out.push_str("<span class=\"diff-add\">");241 + out.push_str(&e);242 + out.push_str("</span>");243 + } else if line.starts_with('-') {244 + out.push_str("<span class=\"diff-del\">");245 + out.push_str(&e);246 + out.push_str("</span>");247 + } else {248 + out.push_str(&e);249 + }250 + }251 + if raw.ends_with('\n') {252 + out.push('\n');253 + }254 + out255 +}256 +257 /// Render a full HTML page. `title` is escaped; `nav` and `body` are258 /// inlined as-is (caller must escape untrusted text with [`esc`]).259 #[allow(dead_code)]260 @@ -189,7 +243,7 @@ pub fn healthcheck_page() -> String {261 262 #[cfg(test)]263 mod tests {264 - use super::{esc, healthcheck_page, page};265 + use super::{diff_html, esc, healthcheck_page, page};266 267 #[test]268 fn esc_escapes_angle_amp_quote() {269 @@ -349,4 +403,65 @@ mod tests {270 let pre = "<a href=\"/\" aria-current=\"page\">code</a>";271 assert_eq!(apply_active(pre, "/"), pre);272 }273 +274 + #[test]275 + fn diff_html_colors_each_line_kind() {276 + // m6 diff-colors (f0rge #22): line-level only, spans + classes.277 + let raw = "diff --git a/notes.txt b/notes.txt\n\278 +--- a/notes.txt\n+++ b/notes.txt\n\279 +@@ -1 +1,2 @@\n line one\n-line gone\n+line two added\n";280 + let html = diff_html(raw);281 + assert!(282 + html.contains("<span class=\"diff-hunk\">@@ -1 +1,2 @@</span>"),283 + "hunk cyan: {html}"284 + );285 + assert!(286 + html.contains("<span class=\"diff-file\">--- a/notes.txt</span>"),287 + "--- file bold: {html}"288 + );289 + assert!(290 + html.contains("<span class=\"diff-file\">+++ b/notes.txt</span>"),291 + "+++ file bold: {html}"292 + );293 + assert!(294 + html.contains("<span class=\"diff-add\">+line two added</span>"),295 + "added green: {html}"296 + );297 + assert!(298 + html.contains("<span class=\"diff-del\">-line gone</span>"),299 + "removed red: {html}"300 + );301 + // Context + `diff --git` header stay plain (no span).302 + assert!(html.contains(" line one"), "context kept");303 + assert!(html.contains("diff --git"), "diff header kept");304 + assert!(!html.contains("<span> line one"), "context has no span");305 + }306 +307 + #[test]308 + fn diff_html_escapes_before_wrapping() {309 + let html = diff_html("+evil <script>alert(1)</script>\n-old & \"quoted\"\n");310 + assert!(!html.contains("<script>"), "no raw HTML: {html}");311 + assert!(312 + html.contains(313 + "<span class=\"diff-add\">+evil <script>alert(1)</script></span>"314 + ),315 + "added line escaped in span: {html}"316 + );317 + assert!(318 + html.contains("<span class=\"diff-del\">-old & "quoted"</span>"),319 + "removed line escaped in span: {html}"320 + );321 + }322 +323 + #[test]324 + fn diff_html_keeps_markers_as_plain_text() {325 + // Truncation + binary notes pass through escaped, span-free.326 + let marker = "[hu13: diff truncated at 2000 lines]";327 + let binary = "Binary files a/bin.dat and b/bin.dat differ";328 + let html = diff_html(&format!("+x\n{marker}\n{binary}\n"));329 + assert!(html.contains("<span class=\"diff-add\">+x</span>"));330 + assert!(html.contains(marker), "truncation marker intact");331 + assert!(html.contains("Binary files"), "binary note intact");332 + assert!(!html.contains("<span>\n[hu13"), "markers have no span");333 + }334 }335 -- 336 2.55.0337