1 //! M8 hardening: perf gates (release-only), atomicity, schema N-1, hashes.2 //! No new deps; temp dirs under $TMPDIR. Perf tests skip in debug.3 4 #![forbid(unsafe_code)]5 6 use std::path::{Path, PathBuf};7 use std::process::Command;8 use std::sync::atomic::{AtomicU64, Ordering};9 10 static CTR: AtomicU64 = AtomicU64::new(0);11 12 fn bin() -> &'static str {13 env!("CARGO_BIN_EXE_f0rge")14 }15 16 fn tmp_repo(tag: &str) -> PathBuf {17 let d = std::env::temp_dir().join(format!(18 "f0rge-m8-{tag}-{}-{}",19 std::process::id(),20 CTR.fetch_add(1, Ordering::Relaxed)21 ));22 let _ = std::fs::remove_dir_all(&d);23 std::fs::create_dir_all(&d).unwrap();24 let st = Command::new(bin()).arg("init").current_dir(&d).output().unwrap();25 assert!(st.status.success(), "init failed: {st:?}");26 d27 }28 29 fn uuid(i: u32) -> String {30 format!("{:08x}-0000-4000-8000-{:012x}", i, i)31 }32 33 fn run(dir: &Path, args: &[&str]) -> (std::process::Output, std::time::Duration) {34 let t = std::time::Instant::now();35 let o = Command::new(bin()).args(args).current_dir(dir).output().unwrap();36 (o, t.elapsed())37 }38 39 fn lines(s: &[u8]) -> Vec<String> {40 String::from_utf8(s.to_vec())41 .unwrap()42 .lines()43 .filter(|l| !l.is_empty())44 .map(|l| l.to_string())45 .collect()46 }47 48 /// Release-only gate: debug runs skip (fast), release runs enforce.49 fn release_only(test: &str) -> bool {50 if cfg!(debug_assertions) {51 eprintln!("skip {test}: release-only bench");52 return false;53 }54 true55 }56 57 #[test]58 fn issue_list_10k_json_under_300ms() {59 if !release_only("issue_list_10k") {60 return;61 }62 let dir = tmp_repo("list10k");63 let base = dir.join(".forge/issues");64 for i in 0..10_000u32 {65 let id = uuid(i);66 let d = base.join(&id);67 std::fs::create_dir_all(&d).unwrap();68 std::fs::write(69 d.join("issue.md"),70 format!(71 "---\nid: {id}\ntitle: t{i}\nstate: open\ncreated: 2025-09-{:02}T00:00:00Z\n---\nb{i}\n",72 1 + (i % 28)73 ),74 )75 .unwrap();76 }77 let (o, dt) = run(&dir, &["issue", "list", "--json"]);78 assert!(o.status.success(), "stderr: {}", String::from_utf8_lossy(&o.stderr));79 assert!(o.stderr.is_empty(), "leak: {}", String::from_utf8_lossy(&o.stderr));80 let ls = lines(&o.stdout);81 assert_eq!(ls.len(), 10_000);82 assert!(ls[0].starts_with("{\"number\":1,"));83 eprintln!("10k issue list --json: {dt:?}");84 assert!(dt.as_millis() < 300, "too slow: {dt:?}");85 let _ = std::fs::remove_dir_all(&dir);86 }87 88 #[test]89 fn wiki_list_500_json_under_100ms() {90 if !release_only("wiki_list_500") {91 return;92 }93 let dir = tmp_repo("wiki500");94 let base = dir.join(".forge/wiki/docs");95 std::fs::create_dir_all(&base).unwrap();96 for i in 0..500u32 {97 std::fs::write(base.join(format!("p{i:03}.md")), format!("# Title {i}\nbody {i}\n")).unwrap();98 }99 let (o, dt) = run(&dir, &["wiki", "list", "--json"]);100 assert!(o.status.success(), "stderr: {}", String::from_utf8_lossy(&o.stderr));101 let body = String::from_utf8(o.stdout).unwrap();102 let rows: Vec<serde_json::Value> = serde_json::from_str(body.trim()).unwrap();103 assert_eq!(rows.len(), 500);104 assert!(body.contains("Title 7"));105 eprintln!("500-file wiki list --json: {dt:?}");106 assert!(dt.as_millis() < 100, "too slow: {dt:?}");107 let _ = std::fs::remove_dir_all(&dir);108 }109 110 #[test]111 fn stale_tmp_ignored_by_cli() {112 let dir = tmp_repo("staletmp");113 let (o, _) = run(&dir, &["issue", "create", "--title", "t", "--json"]);114 assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));115 let mut ids = Vec::new();116 for e in std::fs::read_dir(dir.join(".forge/issues")).unwrap().flatten() {117 ids.push(e.file_name().to_string_lossy().into_owned());118 }119 assert_eq!(ids.len(), 1);120 // crash debris beside the entity: strict-tmp name + bare .tmp121 let target = dir.join(".forge/issues").join(&ids[0]).join("issue.md");122 std::fs::write(target.with_extension("tmp.999-0"), b"---\nid: garbage\n---\npartial").unwrap();123 std::fs::write(target.with_extension("tmp"), b"partial").unwrap();124 let (o, _) = run(&dir, &["issue", "list", "--json"]);125 assert!(o.status.success());126 assert_eq!(lines(&o.stdout).len(), 1);127 let (o, _) = run(&dir, &["issue", "view", &ids[0], "--json"]);128 assert!(o.status.success());129 assert!(String::from_utf8_lossy(&o.stdout).contains("\"title\":\"t\""));130 let _ = std::fs::remove_dir_all(&dir);131 }132 133 #[test]134 fn old_schema_warns_names_file_and_keeps_going() {135 let dir = tmp_repo("schema");136 let id = uuid(7);137 let d = dir.join(".forge/issues").join(&id);138 std::fs::create_dir_all(&d).unwrap();139 std::fs::write(140 d.join("issue.md"),141 format!(142 "---\nid: {id}\ntitle: t\nstate: open\ncreated: 2025-09-08T02:00:00Z\nschema: v0\n---\nbody\n"143 ),144 )145 .unwrap();146 let (o, _) = run(&dir, &["issue", "view", &id, "--json"]);147 assert!(o.status.success());148 let err = String::from_utf8_lossy(&o.stderr).into_owned();149 assert!(err.contains("issue.md") && err.contains("v0"), "{err:?}");150 assert!(!err.trim_end().contains('\n'), "one line: {err:?}");151 assert!(String::from_utf8_lossy(&o.stdout).contains("\"title\":\"t\""));152 let _ = std::fs::remove_dir_all(&dir);153 }154 155 #[test]156 fn release_create_appends_sha256_and_empty_safe() {157 let dir = tmp_repo("relhash");158 // artifacts pre-exist in out/ (as a build would leave them)159 let out = dir.join(".forge/releases/out/v9");160 std::fs::create_dir_all(&out).unwrap();161 std::fs::write(out.join("a.bin"), b"aaa").unwrap();162 std::fs::write(out.join("b.bin"), b"bbb").unwrap();163 let notes = dir.join("n.md");164 std::fs::write(¬es, "notes\n").unwrap();165 let ns = notes.to_string_lossy().into_owned();166 let (o, _) = run(&dir, &["release", "create", "v9", "--notes", ns.as_str()]);167 assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));168 let md = std::fs::read_to_string(dir.join(".forge/releases/v9.md")).unwrap();169 assert!(md.starts_with("---\n"), "{md:?}");170 assert!(md.contains("## sha256"), "{md:?}");171 for name in ["a.bin", "b.bin"] {172 let sum = Command::new("sha256sum").arg(out.join(name)).output().unwrap();173 let hash = String::from_utf8(sum.stdout).unwrap();174 let hash = hash.split_whitespace().next().unwrap().to_string();175 assert_eq!(hash.len(), 64);176 assert!(md.contains(&format!("{hash} {name}")), "{md:?}");177 }178 // empty-safe: no artifacts -> no block, notes intact179 std::fs::write(¬es, "empty notes\n").unwrap();180 let (o, _) = run(&dir, &["release", "create", "v10", "--notes", ns.as_str()]);181 assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));182 let md10 = std::fs::read_to_string(dir.join(".forge/releases/v10.md")).unwrap();183 assert!(!md10.contains("## sha256"), "{md10:?}");184 assert!(md10.contains("empty notes"));185 let _ = std::fs::remove_dir_all(&dir);186 }