src/releases.rs 23.2 KiB 669 lines Raw
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 }