1 //! M1 acceptance: goldens, SEC-01/02, permutation property, 10k bench.2 3 use super::*;4 use std::os::unix::fs::symlink;5 6 fn fx(name: &str) -> String {7 let p = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../spec/fixtures").join(name);8 fs::read_to_string(&p).unwrap_or_else(|_| panic!("missing fixture {name}"))9 }10 11 fn tmp_forge(tag: &str) -> (PathBuf, Forge) {12 let dir = std::env::temp_dir()13 .join(format!("f0rge-{tag}-{}-{}", std::process::id(), TMP_CTR.fetch_add(1, Ordering::Relaxed)));14 let _ = fs::remove_dir_all(&dir);15 fs::create_dir_all(dir.join(".forge/issues")).unwrap();16 (dir.clone(), Forge::open(&dir))17 }18 19 fn one_line_err(e: &ForgeError) -> String {20 let s = e.to_string();21 assert!(!s.contains('\n'), "error must be one line: {s:?}");22 s23 }24 25 #[test]26 fn golden_issue_roundtrip() {27 let p: Parsed<Issue> = parse_entity(&fx("issue.md"), "issue.md").unwrap();28 assert!(p.warnings.is_empty());29 assert_eq!(p.value.state, "open");30 let doc = render_entity(&p.value, &p.body).unwrap();31 let q: Parsed<Issue> = parse_entity(&doc, "issue.md").unwrap();32 assert_eq!(p.value, q.value);33 assert_eq!(p.body, q.body);34 }35 36 #[test]37 fn golden_pr_roundtrip() {38 let p: Parsed<Pr> = parse_entity(&fx("pr.md"), "pr.md").unwrap();39 assert!(p.warnings.is_empty());40 let doc = render_entity(&p.value, &p.body).unwrap();41 let q: Parsed<Pr> = parse_entity(&doc, "pr.md").unwrap();42 assert_eq!(p.value, q.value);43 }44 45 #[test]46 fn golden_comment_review_task_release_merge_cirun() {47 let c: Parsed<IssueComment> = parse_entity(&fx("issue-comment.md"), "c.md").unwrap();48 assert_eq!(c.value.author.as_deref(), Some("gl0w"));49 let r: Parsed<PrReview> = parse_entity(&fx("pr-review.md"), "r.md").unwrap();50 assert_eq!(r.value.state.as_deref(), Some("approved"));51 let t: Parsed<Task> = parse_entity(&fx("task.md"), "t.md").unwrap();52 assert_eq!(t.value.status, "doing");53 let rel: Parsed<Release> = parse_entity(&fx("release.md"), "rel.md").unwrap();54 assert_eq!(rel.value.version, "v0.1.0");55 let m: Parsed<MergeRecord> = parse_entity(&fx("merge-record.md"), "m.md").unwrap();56 assert!(m.value.applied);57 let run: Parsed<CiRun> = parse_entity(&fx("ci-run.md"), "run.md").unwrap();58 assert_eq!(run.value.steps.len(), 1);59 assert_eq!(run.value.steps[0].exit, 0);60 }61 62 #[test]63 fn golden_labels_milestones_cienv() {64 let (labels, w): (Vec<Label>, _) = parse_list(&fx("labels.md"), "labels.md").unwrap();65 assert!(w.is_empty());66 assert_eq!(labels.len(), 2);67 let (ms, _): (Vec<Milestone>, _) = parse_list(&fx("milestones.md"), "m.md").unwrap();68 assert_eq!(ms[0].state, "open");69 let env = parse_ci_env(&fx("ci-env"), "ci.env").unwrap();70 assert_eq!(env.runner, "local");71 assert_eq!(env.knobs.get("jobs").map(String::as_str), Some("4"));72 }73 74 #[test]75 fn unknown_field_is_warning() {76 let text = fx("issue.md").replacen("state: open", "state: open\nzzz_new: 1", 1);77 let p: Parsed<Issue> = parse_entity(&text, "issue.md").unwrap();78 assert_eq!(p.warnings, vec!["unknown field `zzz_new`".to_string()]);79 }80 81 #[test]82 fn invalid_fixtures_name_file_and_field() {83 let e = parse_entity::<Issue>(&fx("invalid/missing-title.md"), "missing-title.md").unwrap_err();84 let s = one_line_err(&e);85 assert!(s.contains("missing-title.md") && s.contains("title"), "{s}");86 let e = parse_entity::<Issue>(&fx("invalid/bad-uuid.md"), "bad-uuid.md").unwrap_err();87 let s = one_line_err(&e);88 assert!(s.contains("bad-uuid.md") && s.contains("id"), "{s}");89 let e = parse_entity::<Issue>(&fx("invalid/bad-ts.md"), "bad-ts.md").unwrap_err();90 let s = one_line_err(&e);91 assert!(s.contains("bad-ts.md") && s.contains("created"), "{s}");92 }93 94 #[test]95 fn sec_alias_bomb_rejected() {96 let e = parse_entity::<Issue>(&fx("sec/alias-bomb.md"), "alias-bomb.md").unwrap_err();97 one_line_err(&e);98 assert!(matches!(e, ForgeError::AliasBomb { .. }), "{e}");99 }100 101 #[test]102 fn sec_oversize_rejected() {103 let big = format!("---\nid: x\n{}\n---\n", "a".repeat(MAX_FILE_BYTES));104 let e = parse_entity::<Issue>(&big, "big.md").unwrap_err();105 one_line_err(&e);106 assert!(matches!(e, ForgeError::TooLarge { .. }));107 }108 109 #[test]110 fn sec_size_bomb_between_read_and_parse() {111 let (_dir, fg) = tmp_forge("sizebomb");112 let rel = PathBuf::from("issues/x.md");113 let big = vec![b'a'; MAX_FILE_BYTES + 1];114 let e = fg.atomic_write(&rel, &big).unwrap_err();115 one_line_err(&e);116 assert!(matches!(e, ForgeError::TooLarge { .. }));117 }118 119 #[test]120 fn sec_symlink_escape_rejected() {121 let (dir, fg) = tmp_forge("symlink");122 let outside = dir.join("outside.md");123 fs::write(&outside, fx("issue.md")).unwrap();124 let link = dir.join(".forge/issues/evil.md");125 symlink(&outside, &link).unwrap();126 // resolve follows the link out of a *different* root? link target is inside127 // root here; now test true escape: link to /etc/hostname style target.128 let link2 = dir.join(".forge/issues/evil2.md");129 symlink("/etc/hostname", &link2).unwrap();130 let e = fg.load_entity::<Issue>(Path::new("issues/evil2.md")).unwrap_err();131 one_line_err(&e);132 assert!(matches!(e, ForgeError::Escape { .. }), "{e}");133 // write through symlink refused134 let e = fg.atomic_write(Path::new("issues/evil.md"), b"x").unwrap_err();135 assert!(matches!(e, ForgeError::Escape { .. }), "{e}");136 }137 138 #[test]139 fn hostile_bytes_never_panic() {140 for (i, raw) in [141 String::from_utf8_lossy(&[0xff, 0xfe, 0x00]).into_owned(),142 String::new(),143 "---\n".to_string(),144 "no fences at all".to_string(),145 "---\n[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[\n---\n".to_string(),146 format!("---\n{}\n---\n", "*a ".repeat(200)),147 ]148 .iter()149 .enumerate()150 {151 let r = parse_entity::<Issue>(raw, "hostile.md");152 assert!(r.is_err(), "case {i} should err, not panic");153 one_line_err(&r.unwrap_err());154 }155 }156 157 #[test]158 fn forge_atomic_save_load_no_tmp_leftovers() {159 let (dir, fg) = tmp_forge("atomic");160 let p: Parsed<Issue> = parse_entity(&fx("issue.md"), "issue.md").unwrap();161 let rel = fg.issue_file(&p.value.id);162 fg.save_entity(&rel, &p.value, &p.body).unwrap();163 let q: Parsed<Issue> = fg.load_entity(&rel).unwrap();164 assert_eq!(p.value, q.value);165 assert_eq!(p.body, q.body);166 let leftovers: Vec<_> = fs::read_dir(dir.join(".forge/issues").join(&p.value.id))167 .unwrap()168 .filter_map(|e| e.ok())169 .filter(|e| e.path().extension().map(|x| x == "tmp").unwrap_or(false))170 .collect();171 assert!(leftovers.is_empty());172 }173 174 #[test]175 fn numbers_stable_under_permutation() {176 let mut entries: Vec<(String, String)> = (0..200u32)177 .map(|i| {178 (179 format!("{:08x}-0000-4000-8000-{:012x}", i, i),180 format!("2025-09-{:02}T{:02}:00:00Z", 1 + (i % 28), i % 24),181 )182 })183 .collect();184 let base = issue_numbers(&entries);185 // reverse, rotate, deterministic LCG shuffles186 let mut perms: Vec<Vec<(String, String)>> = vec![187 entries.iter().rev().cloned().collect(),188 entries.iter().cloned().cycle().skip(37).take(200).collect(),189 ];190 for seed in [1u64, 7, 12345] {191 let mut v = entries.clone();192 let mut s = seed;193 for i in (1..v.len()).rev() {194 s = s.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);195 v.swap(i, (s >> 33) as usize % (i + 1));196 }197 perms.push(v);198 }199 for p in &perms {200 assert_eq!(issue_numbers(p), base);201 }202 // spot check: earliest created == number 1203 let first = entries.iter().min_by(|a, b| (a.1.clone(), a.0.clone()).cmp(&(b.1.clone(), b.0.clone()))).unwrap();204 assert_eq!(base[&first.0], 1);205 let _ = &mut entries;206 }207 208 #[test]209 fn kanban_and_wiki_derived() {210 let tasks = vec![211 ("a".to_string(), "todo".to_string()),212 ("b".to_string(), "doing".to_string()),213 ("c".to_string(), "blocked".to_string()),214 ("d".to_string(), "done".to_string()),215 ("e".to_string(), "bogus".to_string()),216 ];217 let k = kanban(&tasks);218 assert_eq!(k.len(), 4);219 assert_eq!(k["todo"], vec!["a".to_string()]);220 assert!(k.values().all(|v| !v.contains(&"e".to_string())));221 let w = wiki_index(&[222 ("research/a.md".to_string(), "# Real title\nbody\n".to_string()),223 ("docs/b.md".to_string(), "no heading\n".to_string()),224 ]);225 assert_eq!(w[0].path, "docs/b.md");226 assert_eq!(w[0].title, "b");227 assert_eq!(w[1].title, "Real title");228 }229 230 #[test]231 fn json_models_compact_and_ordered() {232 let p: Parsed<Issue> = parse_entity(&fx("issue.md"), "issue.md").unwrap();233 let j = IssueJson::new(3, &p.value, &p.body, 1);234 let s = to_json_line(&j);235 assert!(s.starts_with("{\"number\":3,"));236 assert!(!s.contains("\", \""));237 assert!(!s.contains("\": "));238 assert!(!s.contains('\n'));239 let order = ["number", "title", "state", "labels", "assignees", "milestone", "body", "createdAt", "comments"];240 let mut last = 0;241 for k in order {242 let i = s.find(&format!("\"{k}\":")).unwrap_or_else(|| panic!("missing {k} in {s}"));243 assert!(i >= last, "field order broken at {k}");244 last = i;245 }246 let pr: Parsed<Pr> = parse_entity(&fx("pr.md"), "pr.md").unwrap();247 let pj = PrJson::new(1, &pr.value);248 let ps = to_json_line(&pj);249 assert!(ps.contains("\"databaseId\":\"33333333-3333-4333-8333-333333333333\""));250 assert!(ps.contains("\"status\":\"completed\"") && ps.contains("\"conclusion\":\"success\""));251 }252 253 #[test]254 fn rfc3339_formatter_known_values() {255 assert_eq!(unix_to_rfc3339(0), "1970-01-01T00:00:00Z");256 assert_eq!(unix_to_rfc3339(946684800), "2000-01-01T00:00:00Z");257 assert_eq!(unix_to_rfc3339(1735689600), "2025-01-01T00:00:00Z");258 assert!(is_rfc3339_utc(&now_rfc3339()));259 }260 261 #[test]262 fn bench_10k_issue_scan_under_100ms_debug() {263 let dir = std::env::temp_dir().join(format!(264 "f0rge-bench-{}-{}",265 std::process::id(),266 TMP_CTR.fetch_add(1, Ordering::Relaxed)267 ));268 let _ = fs::remove_dir_all(&dir);269 let issues = dir.join(".forge/issues");270 for i in 0..10_000u32 {271 let id = format!("{:08x}-0000-4000-8000-{:012x}", i, i);272 let d = issues.join(&id);273 fs::create_dir_all(&d).unwrap();274 fs::write(275 d.join("issue.md"),276 format!(277 "---\nid: {id}\ntitle: t{i}\nstate: open\ncreated: 2025-09-{:02}T00:00:00Z\n---\nb{i}\n",278 1 + (i % 28)279 ),280 )281 .unwrap();282 }283 let fg = Forge::open(&dir);284 let t0 = std::time::Instant::now();285 let nums = fg.scan_numbers().unwrap();286 let dt = t0.elapsed();287 assert_eq!(nums.len(), 10_000);288 // fused path agrees with the pure path289 let idx = fg.scan_issue_index().unwrap();290 assert_eq!(issue_numbers(&idx), nums);291 eprintln!("10k scan+numbers: {:?}", dt);292 assert!(dt.as_millis() < 100, "too slow: {:?}", dt);293 let _ = fs::remove_dir_all(&dir);294 }295 296 #[test]297 fn fast_card_matches_strict_on_plain_and_fancy() {298 // plain + flow seq + quoted + comment + block seq + unknown field299 let id = "11111111-1111-4111-8111-111111111111";300 let cases = vec![301 format!("---\nid: {id}\ntitle: plain\nstate: open\ncreated: 2025-09-08T02:00:00Z\n---\nbody\n"),302 format!("---\nid: {id}\ntitle: \"quoted # kept\"\nstate: open # live\nlabels: [a, b]\nassignees: []\nmilestone: v1\ncreated: 2025-09-08T02:00:00Z\nupdated: 2025-09-08T03:00:00Z\n---\nbody\n"),303 format!("---\nid: {id}\ntitle: 'it''s'\nstate: in-progress\nlabels:\n- x\n- y\ncreated: 2025-09-08T02:00:00Z\nzzz_new: 1\n---\nbody\n"),304 // fancy: escape + nested flow -> strict fallback, same values305 format!("---\nid: {id}\ntitle: \"a\\nb\"\nstate: open\nlabels: [\"q,q\", z]\ncreated: 2025-09-08T02:00:00Z\n---\nbody\n"),306 ];307 for (i, text) in cases.iter().enumerate() {308 let a: Parsed<Issue> = parse_entity(text, "c.md").unwrap();309 let b = parse_issue_card(text, "c.md").unwrap();310 assert_eq!(a.value, b.value, "case {i} values");311 assert_eq!(a.warnings, b.warnings, "case {i} warnings");312 assert_eq!(a.schema_note, b.schema_note, "case {i} schema");313 assert_eq!(a.body, b.body, "case {i} body");314 }315 // errors stay identical through the fallback316 for text in [317 "---\nid: nope\ntitle: t\nstate: open\ncreated: 2025-09-08T02:00:00Z\n---\n",318 "---\ntitle: t\nstate: open\ncreated: 2025-09-08T02:00:00Z\n---\n",319 ] {320 assert_eq!(321 parse_entity::<Issue>(text, "e.md").unwrap_err().to_string(),322 parse_issue_card(text, "e.md").unwrap_err().to_string()323 );324 }325 }326 327 #[test]328 fn schema_n1_warns_and_keeps_going() {329 let id = "11111111-1111-4111-8111-111111111111";330 let old = format!("---\nid: {id}\ntitle: t\nstate: open\ncreated: 2025-09-08T02:00:00Z\nschema: v0\n---\nbody\n");331 let p: Parsed<Issue> = parse_entity(&old, "issue.md").unwrap();332 assert_eq!(p.value.title, "t");333 let n = p.schema_note.unwrap();334 assert!(n.contains("issue.md") && n.contains("v0") && !n.contains('\n'), "{n}");335 // current marker + absent marker -> silent; never an unknown-field warning336 for text in [337 old.replace("schema: v0", "schema: v1"),338 old.lines().filter(|l| !l.starts_with("schema:")).collect::<Vec<_>>().join("\n"),339 ] {340 let q: Parsed<Issue> = parse_entity(&text, "issue.md").unwrap();341 assert!(q.schema_note.is_none());342 assert!(q.warnings.is_empty(), "{:?}", q.warnings);343 }344 // card path agrees, incl. fallback shapes345 let c = parse_issue_card(&old, "issue.md").unwrap();346 assert!(c.schema_note.is_some());347 }348 349 #[test]350 fn stale_tmp_never_read_nor_listed() {351 // crash mid-write leaves `*.tmp.*`; readers ignore tmp paths entirely.352 let (dir, fg) = tmp_forge("staletmp");353 let p: Parsed<Issue> = parse_entity(&fx("issue.md"), "issue.md").unwrap();354 let rel = fg.issue_file(&p.value.id);355 fg.save_entity(&rel, &p.value, &p.body).unwrap();356 let target = dir.join(".forge").join(&rel);357 fs::write(target.with_extension("tmp.999-0"), b"---\nid: garbage\n---\npartial").unwrap();358 fs::write(target.with_extension("tmp"), b"partial").unwrap();359 let q: Parsed<Issue> = fg.load_entity(&rel).unwrap();360 assert_eq!(p.value, q.value);361 let c = parse_issue_card(&fs::read_to_string(&target).unwrap(), "issue.md").unwrap();362 assert_eq!(c.value, p.value);363 let idx = fg.scan_issue_index().unwrap();364 assert_eq!(idx.len(), 1);365 assert!(fg.scan_numbers().unwrap().contains_key(&p.value.id));366 // no locks anywhere: two writers are last-wins, both leave valid docs367 fg.save_entity(&rel, &p.value, "first\n").unwrap();368 fg.save_entity(&rel, &p.value, "second\n").unwrap();369 let r: Parsed<Issue> = fg.load_entity(&rel).unwrap();370 assert_eq!(r.body, "second\n");371 }372 373 #[test]374 fn created_fractional_orders_and_parses_both() {375 // #28: new stamps carry fractional seconds; old second-precision files parse.376 let a = now_rfc3339();377 assert!(a.contains('.'), "fraction missing: {a}");378 assert!(is_rfc3339_utc(&a), "{a}");379 assert!(is_rfc3339_utc("2026-09-08T07:44:29Z"));380 assert!(is_rfc3339_utc("2026-09-08T07:44:29.123Z"));381 assert!(is_rfc3339_utc("2026-09-08T07:44:29.123456Z"));382 assert!(!is_rfc3339_utc("2026-09-08T07:44:29.Z"));383 assert!(!is_rfc3339_utc("2026-09-08T07:44:29.1234567890Z"));384 // rapid calls never tie: lexicographic == chronological.385 let mut prev = now_rfc3339();386 for _ in 0..50 {387 let cur = now_rfc3339();388 assert!(prev < cur, "not monotonic: {prev} vs {cur}");389 prev = cur;390 }391 // old file (no fraction) still validates through the entity path.392 let id = "11111111-1111-4111-8111-111111111111";393 let old = format!("---\nid: {id}\ntitle: t\nstate: open\ncreated: 2025-09-08T02:00:00Z\n---\nb\n");394 let p: Parsed<Issue> = parse_entity(&old, "old.md").unwrap();395 assert_eq!(p.value.created, "2025-09-08T02:00:00Z");396 let new = format!("---\nid: {id}\ntitle: t\nstate: open\ncreated: {a}\n---\nb\n");397 let q: Parsed<Issue> = parse_entity(&new, "new.md").unwrap();398 assert_eq!(q.value.created, a);399 }