1 //! Releases (m3, f0rge issue #8): `.forge/releases/<ver>.md` notes +2 //! optional `releases/out/<ver>/` artifacts.3 //!4 //! Source is the on-disk `<repo>/.forge/releases/` tree (NOT git objects):5 //! `<version>.md` files carry frontmatter (`version`, `created` required;6 //! `title`, `sha` optional) plus a markdown notes body. `build/` holds build7 //! scripts (never rendered); `out/<ver>/` holds built artifacts (gitignored,8 //! usually absent — fine).9 //!10 //! Pages: `releases/index.html` + `releases/<ver>.html`. When11 //! `releases/out/<ver>/` exists at build time the WHOLE dir is copied to12 //! `dist/releases/<ver>/` (no size cap — the maintainer shipped it, hu1313 //! serves it) and linked with sizes plus any sha256 lines found in the14 //! notes. When absent the page is notes-only with no warning.15 //!16 //! SECURITY: every byte from `.forge/` is untrusted. Notes bodies go through17 //! [`crate::build::markdown_to_html`] (raw HTML escaped); versions, titles,18 //! shas, filenames, and sha256 lines go through [`crate::layout::esc`].19 20 use std::path::{Path, PathBuf};21 22 use serde::Deserialize;23 24 use crate::build::{href_esc, markdown_to_html_in};25 use crate::layout::{esc, page_site_with_provenance};26 27 /// `.forge/` files larger than this are skipped (schema SEC-01: 256 KiB).28 pub const RELEASE_MAX_BYTES: u64 = 256 * 1024;29 30 #[derive(Debug, Clone, Deserialize)]31 struct ReleaseFm {32 version: String,33 created: String,34 #[serde(default)]35 title: Option<String>,36 #[serde(default)]37 sha: Option<String>,38 }39 40 /// One file inside `out/<ver>/` (relative path + byte size).41 #[derive(Debug, Clone, PartialEq, Eq)]42 pub struct Artifact {43 /// `/`-separated path relative to `out/<ver>/`.44 pub path: String,45 /// File size in bytes.46 pub size: u64,47 }48 49 /// One release record: notes + artifacts found at build time.50 #[derive(Debug, Clone)]51 pub struct Release {52 /// Version string (from frontmatter `version`).53 pub version: String,54 /// Optional title.55 pub title: Option<String>,56 /// RFC3339 creation timestamp.57 pub created: String,58 /// Optional commit sha recorded at create time.59 pub sha: Option<String>,60 /// Markdown notes body after the frontmatter.61 pub body: String,62 /// Artifacts present in `out/<ver>/` at build (sorted by path).63 pub artifacts: Vec<Artifact>,64 }65 66 impl Release {67 /// Display title: `title` when non-empty, else `version`.68 #[must_use]69 pub fn display_title(&self) -> &str {70 match &self.title {71 Some(t) if !t.trim().is_empty() => t,72 _ => &self.version,73 }74 }75 }76 77 fn split_frontmatter(text: &str) -> Option<(&str, &str)> {78 let rest = text.strip_prefix("---")?;79 let rest = rest80 .strip_prefix('\n')81 .or_else(|| rest.strip_prefix("\r\n"))?;82 let mut offset = 0;83 for line in rest.split_inclusive('\n') {84 let trimmed = line.trim_end_matches(['\n', '\r']);85 if trimmed == "---" {86 let yaml = &rest[..offset];87 let body = &rest[offset + line.len()..];88 return Some((yaml, body));89 }90 offset += line.len();91 }92 None93 }94 95 fn read_text_capped(path: &Path, label: &str) -> Option<String> {96 // Never follow symlinks: wiki and release artifacts already skip97 // them, and `.forge/` is repo-controlled (f0rge #68).98 match std::fs::symlink_metadata(path) {99 Ok(m) if m.file_type().is_symlink() => {100 eprintln!("hu13: releases: skipping {label}: symlink");101 return None;102 }103 _ => {}104 }105 let len = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);106 if len > RELEASE_MAX_BYTES {107 eprintln!("hu13: releases: skipping {label}: over 256 KiB ({len} bytes)");108 return None;109 }110 match std::fs::read(path) {111 Ok(bytes) => match String::from_utf8(bytes) {112 Ok(text) => Some(text),113 Err(_) => {114 eprintln!("hu13: releases: skipping {label}: not valid UTF-8");115 None116 }117 },118 Err(e) => {119 eprintln!("hu13: releases: skipping {label}: cannot read: {e}");120 None121 }122 }123 }124 125 /// Collect every file under `dir` as `(relative /-separated path, size)`,126 /// sorted by path. No size cap: the maintainer shipped it, hu13 serves it.127 fn collect_artifacts(dir: &Path) -> Vec<Artifact> {128 // The version directory itself may be a symlink; never list outside.129 match std::fs::symlink_metadata(dir) {130 Ok(m) if m.is_dir() && !m.file_type().is_symlink() => {}131 _ => return Vec::new(),132 }133 let mut out = Vec::new();134 let mut stack: Vec<PathBuf> = vec![PathBuf::new()];135 while let Some(rel) = stack.pop() {136 let abs = dir.join(&rel);137 let rd = match std::fs::read_dir(&abs) {138 Ok(rd) => rd,139 Err(e) => {140 eprintln!("hu13: releases: cannot list {}: {e}", abs.display());141 continue;142 }143 };144 for ent in rd {145 let ent = match ent {146 Ok(ent) => ent,147 Err(e) => {148 eprintln!("hu13: releases: bad dir entry: {e}");149 continue;150 }151 };152 let name = ent.file_name().to_string_lossy().into_owned();153 let rel_child = if rel.as_os_str().is_empty() {154 PathBuf::from(&name)155 } else {156 rel.join(&name)157 };158 let ft = match ent.file_type() {159 Ok(ft) => ft,160 Err(_) => continue,161 };162 if ft.is_symlink() {163 continue;164 }165 if ft.is_dir() {166 stack.push(rel_child);167 } else if ft.is_file() {168 let size = std::fs::metadata(ent.path()).map(|m| m.len()).unwrap_or(0);169 let rel_str = rel_child170 .components()171 .map(|c| c.as_os_str().to_string_lossy().into_owned())172 .collect::<Vec<_>>()173 .join("/");174 out.push(Artifact {175 path: rel_str,176 size,177 });178 }179 }180 }181 out.sort_by(|a, b| a.path.cmp(&b.path));182 out183 }184 185 /// Copy the WHOLE `src_dir` tree to `dest_dir` (no size cap).186 fn copy_tree(src_dir: &Path, dest_dir: &Path) -> Result<(), String> {187 match std::fs::symlink_metadata(src_dir) {188 Ok(m) if m.is_dir() && !m.file_type().is_symlink() => {}189 _ => return Ok(()),190 }191 let mut stack: Vec<PathBuf> = vec![PathBuf::new()];192 while let Some(rel) = stack.pop() {193 let src = src_dir.join(&rel);194 let dest = dest_dir.join(&rel);195 let rd = match std::fs::read_dir(&src) {196 Ok(rd) => rd,197 Err(e) => {198 return Err(format!("hu13: copy releases dir {}: {e}", src.display()));199 }200 };201 std::fs::create_dir_all(&dest)202 .map_err(|e| format!("hu13: create releases dir {}: {e}", dest.display()))?;203 for ent in rd {204 let ent = match ent {205 Ok(ent) => ent,206 Err(e) => return Err(format!("hu13: list releases dir: {e}")),207 };208 let name = ent.file_name();209 let rel_child = if rel.as_os_str().is_empty() {210 PathBuf::from(&name)211 } else {212 rel.join(&name)213 };214 let ft = ent215 .file_type()216 .map_err(|e| format!("hu13: stat releases file: {e}"))?;217 if ft.is_symlink() {218 continue;219 }220 if ft.is_dir() {221 stack.push(rel_child);222 } else if ft.is_file() {223 let bytes =224 std::fs::read(ent.path()).map_err(|e| format!("hu13: read artifact: {e}"))?;225 std::fs::write(dest_dir.join(&rel_child), &bytes)226 .map_err(|e| format!("hu13: write artifact: {e}"))?;227 }228 }229 }230 Ok(())231 }232 233 /// Load every release from on-disk `<repo>/.forge/releases/*.md`.234 /// Never fails: bad files are skipped with warnings; a missing dir yields235 /// an empty vec. Order is `(created, version)`.236 #[must_use]237 pub fn load(repo: &Path) -> Vec<Release> {238 let Some(root) = crate::paths::repo_subdir(repo, ".forge/releases") else {239 return Vec::new();240 };241 let rd = match std::fs::read_dir(&root) {242 Ok(rd) => rd,243 Err(_) => return Vec::new(),244 };245 let mut names: Vec<String> = Vec::new();246 for ent in rd {247 let ent = match ent {248 Ok(ent) => ent,249 Err(e) => {250 eprintln!("hu13: releases: bad dir entry: {e}");251 continue;252 }253 };254 let Ok(ft) = ent.file_type() else {255 continue;256 };257 if ft.is_symlink() || !ft.is_file() {258 continue;259 }260 let name = ent.file_name().to_string_lossy().into_owned();261 if name.ends_with(".md") {262 names.push(name);263 }264 }265 names.sort();266 267 let mut out = Vec::new();268 for name in names {269 let label = format!("releases/{name}");270 let Some(text) = read_text_capped(&root.join(&name), &label) else {271 continue;272 };273 let Some((yaml, body)) = split_frontmatter(&text) else {274 eprintln!("hu13: releases: skipping {label}: missing frontmatter");275 continue;276 };277 let fm: ReleaseFm = match yaml_serde::from_str(yaml) {278 Ok(fm) => fm,279 Err(e) => {280 eprintln!("hu13: releases: skipping {label}: {e}");281 continue;282 }283 };284 // `version` becomes a path segment (`releases/<v>.html`,285 // `releases/<v>/`): untrusted frontmatter, so reject traversal286 // loudly instead of writing outside the output dir (f0rge #60).287 if !crate::paths::is_safe_segment(&fm.version) {288 eprintln!(289 "hu13: releases: skipping {label}: unsafe version {:?}",290 fm.version291 );292 continue;293 }294 if crate::paths::is_reserved_index(&fm.version) {295 eprintln!("hu13: releases: skipping {label}: reserved version index");296 continue;297 }298 // Artifacts: present only when `out/<version>/` exists at build.299 // Absent -> notes-only, no warning.300 let out_dir = root.join("out").join(&fm.version);301 let artifacts = if out_dir.is_dir() {302 collect_artifacts(&out_dir)303 } else {304 Vec::new()305 };306 out.push(Release {307 version: fm.version,308 title: fm.title.filter(|t| !t.trim().is_empty()),309 created: fm.created,310 sha: fm.sha.filter(|s| !s.trim().is_empty()),311 body: body.to_string(),312 artifacts,313 });314 }315 out.sort_by(|a, b| {316 (a.created.clone(), a.version.clone()).cmp(&(b.created.clone(), b.version.clone()))317 });318 out319 }320 321 /// Lines in `body` that look like sha256 digest lines: contain 64 hex322 /// chars. Returned trimmed, in body order.323 #[must_use]324 pub fn sha256_lines(body: &str) -> Vec<String> {325 body.lines()326 .filter(|line| {327 let lower = line.trim().to_lowercase();328 let mut run = 0usize;329 let mut best = 0usize;330 for ch in lower.chars() {331 if ch.is_ascii_hexdigit() {332 run += 1;333 best = best.max(run);334 } else {335 run = 0;336 }337 }338 best >= 64339 })340 .map(|l| l.trim().to_string())341 .collect()342 }343 344 /// True for `releases/<version>/...` artifact paths (but not the generated345 /// `releases/<version>.html` page). Both the server and the Pages header346 /// policy use this to keep artifacts downloads, never active documents.347 #[must_use]348 pub fn is_artifact_path(path: &str) -> bool {349 let mut parts = path.split('/');350 matches!(parts.next(), Some("releases"))351 && matches!(parts.next(), Some(version) if !version.is_empty())352 && parts.next().is_some()353 }354 355 fn artifact_href(version: &str, path: &str) -> String {356 // Version and artifact path are repo-controlled: percent-encode both357 // (`#`/`?`/quotes) before HTML-escaping the attribute (f0rge #98/#104).358 href_esc(&format!("/releases/{version}/{path}"))359 }360 361 fn render_release_page(362 rel: &Release,363 site_name: &str,364 nav: &str,365 branch: &str,366 short_sha: &str,367 description: Option<&str>,368 ) -> String {369 let mut body = format!("<h1>{}</h1>\n", esc(rel.display_title()));370 body.push_str(&format!(371 "<p><span class=\"muted\">version:</span> <code>{}</code></p>\n",372 esc(&rel.version)373 ));374 body.push_str(&format!(375 "<p class=\"muted\">{}</p>\n",376 time_ago(&rel.created)377 ));378 if let Some(sha) = &rel.sha {379 body.push_str(&format!(380 "<p><span class=\"muted\">sha:</span> <code>{}</code></p>\n",381 esc(sha)382 ));383 }384 body.push_str(&markdown_to_html_in(&rel.body, "", false));385 if rel.artifacts.is_empty() {386 body.push_str("<p class=\"muted\">No artifacts attached.</p>\n");387 } else {388 body.push_str("<h2>Artifacts</h2>\n<ul class=\"artifacts\">\n");389 for a in &rel.artifacts {390 body.push_str(&format!(391 "<li><a href=\"{}\">{}</a> ({} bytes)</li>\n",392 artifact_href(&rel.version, &a.path),393 esc(&a.path),394 a.size,395 ));396 }397 body.push_str("</ul>\n");398 let hashes = sha256_lines(&rel.body);399 if !hashes.is_empty() {400 body.push_str("<h2>Checksums</h2>\n<div class=\"pre-scroll\"><pre class=\"log\">");401 for h in &hashes {402 body.push_str(&esc(h));403 body.push('\n');404 }405 body.push_str("</pre></div>\n");406 }407 }408 body.push_str("<p><a href=\"/releases/index.html\">all releases</a></p>\n");409 page_site_with_provenance(410 &format!("{} — releases — {site_name}", rel.display_title()),411 nav,412 &body,413 site_name,414 description,415 branch,416 short_sha,417 )418 }419 420 /// Relative time with the exact UTC value in `title` (f0rge #72).421 fn time_ago(s: &str) -> String {422 match crate::build::parse_ts_secs(s) {423 Some(ts) => format!(424 "<time datetime=\"{}\" title=\"{}\">{}</time>",425 esc(s),426 esc(&crate::build::ts_title(s)),427 esc(&crate::build::relative_age(crate::build::now_unix(), ts))428 ),429 None => format!("<time>{}</time>", esc(s)),430 }431 }432 433 /// Render `releases/index.html` + `releases/<ver>.html` from already-loaded434 /// data, copying each present `out/<ver>/` tree to `dist/releases/<ver>/`.435 #[allow(clippy::too_many_arguments)]436 pub fn render_loaded(437 releases: &[Release],438 repo: &Path,439 out: &Path,440 site_name: &str,441 nav: &str,442 branch: &str,443 short_sha: &str,444 description: Option<&str>,445 ) -> Result<(), String> {446 let root = crate::paths::repo_subdir(repo, ".forge/releases");447 let mut body = String::from("<div class=\"page-head\"><h1>Releases</h1></div>\n");448 if releases.is_empty() {449 body.push_str("<p class=\"muted\">No releases yet.</p>\n");450 } else {451 body.push_str("<div class=\"table-scroll\"><table>\n<thead><tr><th>title</th><th>version</th><th>created</th><th>artifacts</th></tr></thead>\n<tbody>\n");452 for rel in releases {453 let href = href_esc(&format!("/releases/{}.html", rel.version));454 body.push_str(&format!(455 "<tr><td class=\"title trunc trunc-title\" data-label=\"title\" title=\"{t}\">{t}</td><td class=\"trunc\" data-label=\"version\" title=\"{v}\"><a href=\"{href}\">{ve}</a></td><td class=\"trunc\" data-label=\"created\" title=\"{c}\">{ca}</td><td class=\"trunc\" data-label=\"artifacts\" title=\"{n}\">{n}</td></tr>\n",456 v = esc(&rel.version),457 ve = esc(&rel.version),458 t = esc(rel.display_title()),459 c = esc(&rel.created),460 ca = time_ago(&rel.created),461 n = rel.artifacts.len(),462 ));463 }464 body.push_str("</tbody>\n</table></div>\n");465 }466 let index = page_site_with_provenance(467 &format!("releases — {site_name}"),468 nav,469 &body,470 site_name,471 description,472 branch,473 short_sha,474 );475 let dest = out.join("releases").join("index.html");476 if let Some(parent) = dest.parent() {477 std::fs::create_dir_all(parent).map_err(|e| format!("hu13: create releases dir: {e}"))?;478 }479 std::fs::write(&dest, index).map_err(|e| format!("hu13: write releases index: {e}"))?;480 481 for rel in releases {482 // `load` already rejected unsafe versions; keep the guard here483 // too so a future caller cannot bypass it (defense in depth).484 if !crate::paths::is_safe_segment(&rel.version) {485 return Err(format!(486 "hu13: refusing to write release with unsafe version `{}`",487 rel.version488 ));489 }490 // Copy the whole out dir when present (no size cap). The root is491 // resolved once; copy_tree still refuses a symlinked version dir.492 if let Some(root) = root.as_ref() {493 let src_dir = root.join("out").join(&rel.version);494 if src_dir.is_dir() {495 let dest_dir = out.join("releases").join(&rel.version);496 copy_tree(&src_dir, &dest_dir)?;497 }498 }499 let html = render_release_page(rel, site_name, nav, branch, short_sha, description);500 let page_dest = out.join("releases").join(format!("{}.html", rel.version));501 if let Some(parent) = page_dest.parent() {502 std::fs::create_dir_all(parent)503 .map_err(|e| format!("hu13: create releases parent: {e}"))?;504 }505 std::fs::write(&page_dest, html)506 .map_err(|e| format!("hu13: write release page {}: {e}", rel.version))?;507 }508 Ok(())509 }510 511 #[cfg(test)]512 mod tests {513 use super::*;514 515 #[test]516 fn sha256_line_detection() {517 let digest = "a".repeat(64);518 let body = format!("artifact:\n{digest} app.tar.gz\nplain line\n");519 let lines = sha256_lines(&body);520 assert_eq!(lines.len(), 1);521 assert!(lines[0].contains("app.tar.gz"));522 assert!(sha256_lines("no hashes here\n").is_empty());523 }524 525 #[test]526 fn artifact_paths_and_links_are_classified_and_encoded() {527 assert!(is_artifact_path("releases/v1.0/app.bin"));528 assert!(is_artifact_path("releases/v1.0/nested/app.bin"));529 assert!(!is_artifact_path("releases/v1.0.html"));530 assert!(!is_artifact_path("releases/index.html"));531 assert!(!is_artifact_path("raw/releases/v1/app.bin"));532 533 let href = artifact_href("v1#x", "a\" b#c?.txt");534 assert!(href.starts_with("/releases/v1%23x/a%22%20b%23c%3F.txt"));535 assert!(!href.contains('"'), "quotes are encoded");536 }537 538 #[test]539 fn display_title_falls_back_to_version() {540 let r = Release {541 version: "v1.0".to_string(),542 title: None,543 created: "2026-01-01T00:00:00Z".to_string(),544 sha: None,545 body: String::new(),546 artifacts: Vec::new(),547 };548 assert_eq!(r.display_title(), "v1.0");549 }550 551 /// m6/table-scroll (f0rge #30): 200-char release title renders in a552 /// .table-scroll wrapper with a td.title cell.553 #[test]554 fn long_release_title_scrolls_with_title_cell() {555 let long_title = "r".repeat(200);556 let rel = Release {557 version: "v9.9.9".to_string(),558 title: Some(long_title.clone()),559 created: "2026-09-10T03:00:00Z".to_string(),560 sha: Some("a".repeat(64)),561 body: "notes\n".to_string(),562 artifacts: Vec::new(),563 };564 let repo = std::env::temp_dir().join(format!(565 "hu13-rel-long-{}-{}",566 std::process::id(),567 std::time::SystemTime::now()568 .duration_since(std::time::UNIX_EPOCH)569 .map(|d| d.as_nanos())570 .unwrap_or(0)571 ));572 std::fs::create_dir_all(&repo).unwrap();573 let out = repo.join("out");574 std::fs::create_dir_all(&out).unwrap();575 render_loaded(576 &[rel],577 &repo,578 &out,579 "test",580 "<nav>",581 "main",582 "abc1234",583 None,584 )585 .expect("render");586 let index =587 std::fs::read_to_string(out.join("releases").join("index.html")).expect("index");588 assert!(589 index.contains("<div class=\"table-scroll\"><table>"),590 "wrapper"591 );592 assert!(593 index.contains("<td class=\"title trunc trunc-title\""),594 "title cell trunc"595 );596 assert!(index.contains("title=\""), "title attrs kept");597 assert!(index.contains(&long_title), "long title kept");598 }599 600 #[test]601 fn missing_dir_is_empty() {602 let dir = std::env::temp_dir().join(format!(603 "hu13-rel-empty-{}-{}",604 std::process::id(),605 std::time::SystemTime::now()606 .duration_since(std::time::UNIX_EPOCH)607 .map(|d| d.as_nanos())608 .unwrap_or(0)609 ));610 std::fs::create_dir_all(&dir).unwrap();611 assert!(load(&dir).is_empty());612 }613 614 /// m6/no-scroll (f0rge #39): release rows carry data-labels for cards.615 #[test]616 fn release_cells_have_data_labels() {617 let css = include_str!("../static/style.css");618 assert!(css.contains("content:attr(data-label)"), "label rule");619 let rel = Release {620 version: "v1.2.3".to_string(),621 title: Some("Labeled".to_string()),622 created: "2026-09-10T03:00:00Z".to_string(),623 sha: None,624 body: "notes\n".to_string(),625 artifacts: Vec::new(),626 };627 let repo = std::env::temp_dir().join(format!(628 "hu13-rel-labels-{}-{}",629 std::process::id(),630 std::time::SystemTime::now()631 .duration_since(std::time::UNIX_EPOCH)632 .map(|d| d.as_nanos())633 .unwrap_or(0)634 ));635 std::fs::create_dir_all(&repo).unwrap();636 let out = repo.join("out");637 std::fs::create_dir_all(&out).unwrap();638 render_loaded(639 &[rel],640 &repo,641 &out,642 "test",643 "<nav>",644 "main",645 "abc1234",646 None,647 )648 .expect("render");649 let index =650 std::fs::read_to_string(out.join("releases").join("index.html")).expect("index");651 let tds = index.matches("<td").count();652 assert!(tds > 0, "has cells");653 assert_eq!(654 index.matches("data-label=").count(),655 tds,656 "every td labeled"657 );658 for label in ["version", "title", "created", "artifacts"] {659 assert!(660 index.contains(&format!("data-label=\"{label}\"")),661 "label {label}"662 );663 }664 // m6/cards2 (f0rge #47): card DOM order is title-first.665 let title = index.find("data-label=\"title\"").expect("title cell");666 let ver = index.find("data-label=\"version\"").expect("version cell");667 assert!(title < ver, "card DOM: title precedes version");668 }669 }