1 //! Header config (m6, f0rge issues #21 + #24 + #27 + #28):2 //! `--repo-name`/`HU13_REPO_NAME` (flag > env > dirname, never blank) +3 //! `--description`/`HU13_DESCRIPTION` (flag > env > hidden, no empty `<p>`),4 //! threaded into `<title>`, og tags, feed title/subtitle, and the code-page5 //! repo-head — never into the minimal header (logo • nav only).6 //! Hostile `<script>` input renders as escaped text everywhere.7 8 use std::sync::atomic::{AtomicU64, Ordering};9 10 static SEQ: AtomicU64 = AtomicU64::new(0);11 12 use std::path::{Path, PathBuf};13 use std::process::Command;14 use std::sync::Mutex;15 16 static ENV_LOCK: Mutex<()> = Mutex::new(());17 18 fn git(repo: &Path, args: &[&str]) {19 let st = Command::new("git")20 .arg("-C")21 .arg(repo)22 .args(args)23 .status()24 .expect("spawn git");25 assert!(st.success(), "git {args:?} failed");26 }27 28 fn fresh(prefix: &str) -> PathBuf {29 let dir = std::env::temp_dir().join(format!(30 "hu13-header-{prefix}-{}-{}",31 std::process::id(),32 std::time::SystemTime::now()33 .duration_since(std::time::UNIX_EPOCH)34 .map(|d| d.as_nanos())35 .unwrap_or(0)36 .wrapping_add(u128::from(SEQ.fetch_add(1, Ordering::Relaxed)))37 ));38 std::fs::create_dir_all(&dir).unwrap();39 dir40 }41 42 /// Minimal repo: README (proves the README line is NOT used as the43 /// description fallback) + one file, one commit, no `.forge/`.44 fn fixture_repo() -> PathBuf {45 let dir = fresh("repo");46 git(&dir, &["init", "-b", "main"]);47 git(&dir, &["config", "user.name", "hu13-test"]);48 git(&dir, &["config", "user.email", "[email protected]"]);49 git(&dir, &["config", "commit.gpgsign", "false"]);50 std::fs::write(dir.join("README.md"), "# Readme headline\n\nbody\n").unwrap();51 std::fs::write(dir.join("hello.txt"), "hello\n").unwrap();52 git(&dir, &["add", "-A"]);53 git(&dir, &["commit", "-m", "init"]);54 dir55 }56 57 fn build_with(repo: &Path, name_flag: Option<String>, desc_flag: Option<String>) -> PathBuf {58 let out = fresh("out");59 let bin = env!("CARGO_BIN_EXE_hu13");60 let mut cmd = Command::new(bin);61 cmd.arg("build").arg(repo).arg("-o").arg(&out);62 if let Some(n) = name_flag {63 cmd.arg("--repo-name").arg(n);64 }65 if let Some(d) = desc_flag {66 cmd.arg("--description").arg(d);67 }68 let st = cmd.status().expect("spawn hu13 build");69 assert!(st.success(), "build exits 0");70 out71 }72 73 fn read(rel: &Path, join: &str) -> String {74 std::fs::read_to_string(rel.join(join)).expect(join)75 }76 77 fn header_of(html: &str) -> &str {78 let start = html.find("<header").expect("header open");79 let end = html.find("</header>").expect("header close");80 &html[start..end]81 }82 83 fn set_env(k: &str, v: Option<&str>) {84 unsafe {85 match v {86 Some(val) => std::env::set_var(k, val),87 None => std::env::remove_var(k),88 }89 }90 }91 92 #[test]93 fn repo_name_chain_flag_beats_env_beats_dirname() {94 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());95 let repo = fixture_repo();96 let dirname = repo.file_name().unwrap().to_string_lossy().into_owned();97 98 // Env only -> env wins over dirname.99 set_env("HU13_REPO_NAME", Some("Env Name"));100 set_env("HU13_DESCRIPTION", None);101 let out = build_with(&repo, None, None);102 let index = read(&out, "index.html");103 assert!(104 index.contains("<title>Env Name</title>"),105 "env name in title"106 );107 assert!(108 index.contains("og:title\" content=\"Env Name\""),109 "env name in og:title"110 );111 assert!(112 index.contains("<div class=\"repo-head\"><h1>Env Name</h1>"),113 "env name in code-page repo-head"114 );115 assert!(116 read(&out, "feed.atom").contains("<title>Env Name</title>"),117 "env name in feed title"118 );119 let header = header_of(&index).to_string();120 assert!(121 !header.contains("<span class=\"brand\">"),122 "no brand node in header"123 );124 assert!(!header.contains("hu13-word"), "no word node in header");125 assert_eq!(header.matches("•").count(), 1, "logo • nav only");126 assert!(!header.contains("Env Name"), "header carries zero names");127 128 // Flag beats env.129 set_env("HU13_REPO_NAME", Some("Env Name"));130 let out = build_with(&repo, Some("Flag Name".into()), None);131 let index = read(&out, "index.html");132 assert!(index.contains("<title>Flag Name</title>"), "flag wins");133 assert!(!index.contains("Env Name"), "env suppressed by flag");134 assert!(135 index.contains("og:title\" content=\"Flag Name\""),136 "og:title follows flag"137 );138 139 // Neither -> dirname fallback, never blank.140 set_env("HU13_REPO_NAME", None);141 let out = build_with(&repo, None, None);142 let index = read(&out, "index.html");143 assert!(144 index.contains(&format!("<title>{dirname}</title>")),145 "dirname fallback"146 );147 assert!(148 index.contains(&format!("<div class=\"repo-head\"><h1>{dirname}</h1>")),149 "repo-head fallback"150 );151 assert!(152 !header_of(&index).contains(&dirname),153 "header stays nameless"154 );155 assert!(!index.contains("<h1></h1>"), "header never blank");156 set_env("HU13_REPO_NAME", None);157 }158 159 #[test]160 fn description_chain_flag_beats_env_beats_hidden() {161 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());162 let repo = fixture_repo();163 set_env("HU13_REPO_NAME", None);164 165 // Env only -> shown.166 set_env("HU13_DESCRIPTION", Some("Env desc"));167 let out = build_with(&repo, None, None);168 let index = read(&out, "index.html");169 assert!(170 index.contains("<p class=\"repo-desc\">Env desc</p>"),171 "env desc shown"172 );173 174 // Flag beats env.175 set_env("HU13_DESCRIPTION", Some("Env desc"));176 let out = build_with(&repo, None, Some("Flag desc".into()));177 let index = read(&out, "index.html");178 assert!(179 index.contains("<p class=\"repo-desc\">Flag desc</p>"),180 "flag wins"181 );182 assert!(!index.contains("Env desc"), "env suppressed by flag");183 184 // Neither -> hidden: no description line at all (and the README185 // headline is NOT used as a fallback).186 set_env("HU13_DESCRIPTION", None);187 let out = build_with(&repo, None, None);188 let index = read(&out, "index.html");189 assert!(!index.contains("repo-desc"), "no description line");190 assert!(!index.contains("No description"), "no placeholder text");191 // The README headline still renders in the README section, but must192 // not leak into the header/description surfaces.193 assert!(!index.contains("og:description"), "no og:description");194 let feed = read(&out, "feed.atom");195 assert!(!feed.contains("<subtitle>"), "no feed subtitle");196 set_env("HU13_DESCRIPTION", None);197 }198 199 #[test]200 fn hostile_name_and_description_escape_everywhere() {201 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());202 let repo = fixture_repo();203 set_env("HU13_REPO_NAME", None);204 set_env("HU13_DESCRIPTION", None);205 let evil_name = "Evil <script>alert(1)</script> Hub";206 let evil_desc = "Desc <script>alert(2)</script> & \"quoted\"";207 let out = build_with(&repo, Some(evil_name.into()), Some(evil_desc.into()));208 let index = read(&out, "index.html");209 let feed = read(&out, "feed.atom");210 let commits = read(&out, "commits.html");211 212 for page in [&index, &commits] {213 assert!(!page.contains("<script>alert"), "no raw script passthrough");214 }215 assert!(!feed.contains("<script>alert"), "feed escapes too");216 217 // Escaped forms present in every naming surface; the header stays nameless.218 let name_e = "Evil <script>alert(1)</script> Hub";219 let desc_e = "Desc <script>alert(2)</script> & "quoted"";220 assert!(221 !header_of(&index).contains(name_e),222 "header carries zero names"223 );224 assert!(225 !header_of(&index).contains("<span class=\"brand\">"),226 "no brand node to escape into"227 );228 assert!(index.contains(desc_e), "body escapes description");229 assert!(230 index.contains(&format!("<title>{name_e}</title>")),231 "title escapes name"232 );233 assert!(234 index.contains(&format!("og:title\" content=\"{name_e}\"")),235 "og:title matches"236 );237 assert!(238 index.contains(&format!("og:description\" content=\"{desc_e}\"")),239 "og:description matches"240 );241 assert!(commits.contains("<title>commit"), "subpage title kept");242 assert!(243 !header_of(&commits).contains(name_e),244 "subpage header stays nameless"245 );246 assert!(247 index.contains(&format!("<div class=\"repo-head\"><h1>{name_e}</h1>")),248 "code-page repo-head escapes name"249 );250 assert!(251 feed.contains(&format!("<title>{name_e}</title>")),252 "feed title escapes name"253 );254 assert!(255 feed.contains(&format!("<subtitle>{desc_e}</subtitle>")),256 "feed subtitle escapes description"257 );258 set_env("HU13_REPO_NAME", None);259 set_env("HU13_DESCRIPTION", None);260 }261 262 #[test]263 fn empty_values_fall_back_never_blank() {264 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());265 let repo = fixture_repo();266 let dirname = repo.file_name().unwrap().to_string_lossy().into_owned();267 268 // Empty flag values fall back.269 set_env("HU13_REPO_NAME", None);270 set_env("HU13_DESCRIPTION", None);271 let out = build_with(&repo, Some("".into()), Some(" ".into()));272 let index = read(&out, "index.html");273 assert!(274 index.contains(&format!("<title>{dirname}</title>")),275 "empty name flag falls back to dirname"276 );277 assert!(!index.contains("repo-desc"), "empty desc flag hides line");278 279 // Empty env values fall back too.280 set_env("HU13_REPO_NAME", Some(""));281 set_env("HU13_DESCRIPTION", Some(" "));282 let out = build_with(&repo, None, None);283 let index = read(&out, "index.html");284 assert!(285 index.contains(&format!("<title>{dirname}</title>")),286 "empty name env falls back to dirname"287 );288 assert!(!index.contains("repo-desc"), "empty desc env hides line");289 assert!(!index.contains("<h1></h1>"), "header never blank");290 set_env("HU13_REPO_NAME", None);291 set_env("HU13_DESCRIPTION", None);292 }293 294 #[test]295 fn feed_title_matches_header_and_subtitle_matches_og() {296 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());297 let repo = fixture_repo();298 set_env("HU13_REPO_NAME", None);299 set_env("HU13_DESCRIPTION", None);300 let out = build_with(&repo, Some("Feed Hub".into()), Some("Feed desc".into()));301 let index = read(&out, "index.html");302 let feed = read(&out, "feed.atom");303 assert!(feed.contains("<title>Feed Hub</title>"), "feed title");304 assert!(305 feed.contains("<subtitle>Feed desc</subtitle>"),306 "feed subtitle"307 );308 assert!(309 index.contains("og:title\" content=\"Feed Hub\""),310 "og:title agrees with feed title"311 );312 assert!(313 index.contains("og:description\" content=\"Feed desc\""),314 "og:description agrees with feed subtitle"315 );316 }317 318 #[test]319 fn entrypoint_and_dockerfile_pass_both_envs() {320 let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));321 let ep = std::fs::read_to_string(manifest.join("entrypoint.sh")).expect("entrypoint.sh");322 for needle in [323 "HU13_REPO_NAME",324 "HU13_DESCRIPTION",325 "--repo-name",326 "--description",327 "\"$HU13_REPO_NAME\"",328 "\"$HU13_DESCRIPTION\"",329 ] {330 assert!(ep.contains(needle), "entrypoint passes {needle}");331 }332 let docker = std::fs::read_to_string(manifest.join("Dockerfile")).expect("Dockerfile");333 for needle in ["HU13_REPO_NAME", "HU13_DESCRIPTION"] {334 assert!(docker.contains(needle), "Dockerfile defaults {needle}");335 }336 }