lihaaf 0.1.2

Fast compile-fail and compile-pass test harness for Rust proc macros; a faster trybuild-style workflow
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
//! Per-dispatch freshness validation (the policy).
//!
//! the policy: "Before each fixture worker dispatches, the harness
//! re-checks four invariants against the in-memory manifest captured at
//! startup":
//!
//! 1. The lihaaf-managed dylib file at `managed_dylib_path` exists.
//! 2. Its mtime has not moved backward (a backward jump implies clock
//!    skew or external file replacement of the managed copy itself).
//! 3. Its SHA-256 still matches `dylib_sha256`.
//! 4. `rustc --version --verbose` still produces a toolchain that matches
//!    the captured key on every dimension — release line, host triple,
//!    commit hash, and sysroot. Same comparator as the session-startup
//!    drift check (`toolchain::matches`); freshness wraps it into the
//!    per-dispatch loop so a same-release-line drift on host /
//!    commit_hash / sysroot still trips here.
//!
//! the policy: "ANY divergence → blow the cache, re-run from stage 3
//! (dylib build), re-copy, re-validate, then proceed. No 'try anyway'
//! fallback."
//!
//! In practice — and per the dispatch-orchestrator brief — v0.1 hard-
//! fails with a diagnostic similar in shape to the policy `TOOLCHAIN_DRIFT`
//! rather than attempting a mid-session rebuild. The mid-session
//! rebuild is anchored deferral: it requires re-issuing the dylib
//! build under whatever rustc is currently active, re-copying, and
//! re-validating every in-flight worker; safer to refuse and let the
//! adopter re-run the session against the now-current toolchain.
//!
//! ## Why per-dispatch and not per-session
//!
//! A long-running session can outlive a `rustup update`, a sibling
//! cargo build that touches `target/lihaaf/`, or a clock skew event.
//! The freshness check is the only line of defense against silent ABI
//! mismatch (per the policy: "load-time crash (loud, survivable) or silent
//! miscompilation (quiet, catastrophic)"). The per-dispatch cost is
//! dominated by the SHA-256 over a page-cache-warm artifact (~30 ms
//! for a 10–50 MB dylib on a laptop) plus a short `rustc --version
//! --verbose` subprocess — small enough that paying it on every
//! dispatch is the right call given the blast radius of a stale dylib.

use std::path::PathBuf;

use crate::toolchain;
use crate::util;

/// Snapshot of the four invariants captured at session startup.
///
/// Re-checked per fixture dispatch via [`check`]. The snapshot is
/// constructed once per session from the data already on hand after
/// stages 2–5 of [`crate::session::run`] (`Toolchain` + dylib copy
/// outcome); only the data needed by the four invariants is copied out
/// so the snapshot is `Send + Sync + Clone` for the worker pool.
#[derive(Debug, Clone)]
pub struct FreshnessSnapshot {
    /// Absolute path of the lihaaf-managed dylib copy. This is
    /// invariant 1 (existence) plus the input to invariants 2 + 3.
    pub managed_dylib_path: PathBuf,
    /// mtime of the managed dylib at copy time, in Unix seconds.
    /// Invariant 2 — a backward jump triggers the failure path.
    pub original_mtime_unix_secs: i64,
    /// SHA-256 of the managed dylib at copy time. Invariant 3 —
    /// 3 — a hash mismatch triggers the failure path even if mtime is
    /// stable (defensive against in-place edits that preserve the
    /// timestamp).
    pub original_sha256: String,
    /// Full parsed toolchain captured at session startup. Invariant 4 —
    /// re-runs `rustc --version --verbose` per dispatch and compares the
    /// captured key (release_line, host, commit_hash, sysroot) via
    /// `crate::toolchain::matches`. Same comparator as the session-startup
    /// boundary check; freshness wraps it into the per-dispatch loop so
    /// a same-release-line drift on host / commit_hash / sysroot still
    /// trips here.
    pub original_toolchain: toolchain::Toolchain,
}

/// One of the four policy invariants and its drift detail.
#[derive(Debug, Clone)]
pub enum FreshnessFailure {
    /// Invariant 1: the managed dylib no longer exists at the captured
    /// path. The `path` is the absolute path lihaaf was checking; the
    /// adopter typically discovers this when an unrelated `cargo
    /// clean` ran mid-session.
    DylibMissing {
        /// Path that was expected to exist.
        path: PathBuf,
    },
    /// Invariant 2: the managed dylib's mtime moved backward relative
    /// to the captured value. Implies clock skew, an external file
    /// replacement of the managed copy, or NTP correction.
    DylibMtimeBackward {
        /// Path of the managed copy.
        path: PathBuf,
        /// mtime captured at copy time (Unix seconds).
        original_mtime: i64,
        /// mtime observed at this dispatch (Unix seconds).
        observed_mtime: i64,
    },
    /// Invariant 3: the managed dylib's SHA-256 no longer matches the
    /// captured digest. Implies in-place edit of the managed copy.
    DylibShaMismatch {
        /// Path of the managed copy.
        path: PathBuf,
        /// SHA-256 captured at copy time.
        original_sha256: String,
        /// SHA-256 observed at this dispatch.
        observed_sha256: String,
    },
    /// Invariant 4: captured toolchain key drifted between session
    /// startup and this dispatch. Same shape as the policy
    /// `TOOLCHAIN_DRIFT`, but fired from the per-dispatch path rather
    /// than the one-shot pre-dispatch check. Any of `release_line`,
    /// `host`, `commit_hash`, or `sysroot` may differ — the rendered
    /// detail names which dimension(s) drifted.
    ///
    /// `original` and `observed` are boxed to keep the `FreshnessFailure`
    /// enum (and the `Result<(), FreshnessFailure>` consumed across the
    /// per-dispatch hot path) small per clippy's `result_large_err` lint.
    /// Unboxing here re-trips the lint.
    RustcDrift {
        /// Full toolchain captured at session startup.
        original: Box<toolchain::Toolchain>,
        /// Toolchain observed at this dispatch. When the re-capture
        /// itself failed (e.g. rustc no longer on PATH), this is a
        /// placeholder with empty strings + empty PathBuf.
        observed: Box<toolchain::Toolchain>,
    },
}

impl FreshnessFailure {
    /// Stable identifier for the invariant that drifted. Consumed by
    /// the session-outcome diagnostic so adopters and CI can grep on a
    /// fixed token rather than a free-form message body.
    pub fn invariant_label(&self) -> &'static str {
        match self {
            Self::DylibMissing { .. } => "managed_dylib_path",
            Self::DylibMtimeBackward { .. } => "dylib_mtime",
            Self::DylibShaMismatch { .. } => "dylib_sha256",
            Self::RustcDrift { .. } => "rustc_release",
        }
    }

    /// Pre-rendered diagnostic body. Composed once at construction
    /// time so the session reporter prints byte-deterministic output.
    pub fn detail(&self) -> String {
        match self {
            Self::DylibMissing { path } => {
                format!("managed dylib no longer exists at {}", path.display())
            }
            Self::DylibMtimeBackward {
                path,
                original_mtime,
                observed_mtime,
            } => format!(
                "managed dylib mtime moved backward at {} (original: {original_mtime}, observed: {observed_mtime})",
                path.display()
            ),
            Self::DylibShaMismatch {
                path,
                original_sha256,
                observed_sha256,
            } => format!(
                "managed dylib SHA-256 changed at {} (original: {original_sha256}, observed: {observed_sha256})",
                path.display()
            ),
            Self::RustcDrift { original, observed } => {
                // Identify which of the four key fields actually drifted
                // so the diagnostic body points the adopter at the
                // dimension that changed. Order is stable (release_line,
                // host, commit_hash, sysroot) for byte-deterministic
                // output regardless of how many fields drifted.
                let mut changed: Vec<&'static str> = Vec::new();
                if original.release_line != observed.release_line {
                    changed.push("release_line");
                }
                if original.host != observed.host {
                    changed.push("host");
                }
                if original.commit_hash != observed.commit_hash {
                    changed.push("commit_hash");
                }
                if original.sysroot != observed.sysroot {
                    changed.push("sysroot");
                }
                let changed_list = if changed.is_empty() {
                    // Should not happen — check() only constructs this
                    // variant on a real inequality — but render a stable
                    // placeholder rather than an empty list so the body
                    // is never confusingly blank.
                    "(none detected)".to_string()
                } else {
                    changed.join(", ")
                };
                format!(
                    "rustc toolchain drifted (changed fields: {changed_list}; original: {orig_rl}, host: {orig_host}, commit-hash: {orig_ch}, sysroot: {orig_sr}; observed: {obs_rl}, host: {obs_host}, commit-hash: {obs_ch}, sysroot: {obs_sr})",
                    orig_rl = original.release_line,
                    orig_host = original.host,
                    orig_ch = original.commit_hash,
                    orig_sr = original.sysroot.display(),
                    obs_rl = observed.release_line,
                    obs_host = observed.host,
                    obs_ch = observed.commit_hash,
                    obs_sr = observed.sysroot.display(),
                )
            }
        }
    }
}

/// Re-check the four policy invariants against `snapshot`. Returns
/// `Ok(())` when all four still hold; otherwise returns the first
/// invariant that drifted (checked in a fixed order: existence → mtime →
/// SHA-256 → rustc).
///
/// The check is intended for the per-dispatch path. Re-running a
/// short `rustc --version --verbose` per fixture is acceptable — the
/// cost is dwarfed by the per-fixture rustc compile — providing
/// the only line of defense against an in-session toolchain swap.
///
/// Invariant 4 uses the same `(release_line, host, commit_hash,
/// sysroot)` comparator as the session-startup `toolchain::matches`
/// check, so a same-release-line drift on host / commit_hash / sysroot
/// trips here too — no shadow comparator with a narrower key.
pub fn check(snapshot: &FreshnessSnapshot) -> Result<(), FreshnessFailure> {
    // Invariant 1: existence.
    let path = &snapshot.managed_dylib_path;
    let meta = match std::fs::metadata(path) {
        Ok(m) => m,
        Err(_) => {
            return Err(FreshnessFailure::DylibMissing { path: path.clone() });
        }
    };

    // Invariant 2: mtime not moved backward.
    let observed_mtime = match meta.modified() {
        Ok(t) => t
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_secs() as i64)
            .unwrap_or(0),
        Err(_) => 0,
    };
    if observed_mtime < snapshot.original_mtime_unix_secs {
        return Err(FreshnessFailure::DylibMtimeBackward {
            path: path.clone(),
            original_mtime: snapshot.original_mtime_unix_secs,
            observed_mtime,
        });
    }

    // Invariant 3: SHA-256 unchanged.
    let observed_sha = match util::sha256_file(path) {
        Ok(s) => s,
        Err(_) => {
            return Err(FreshnessFailure::DylibMissing { path: path.clone() });
        }
    };
    if observed_sha != snapshot.original_sha256 {
        return Err(FreshnessFailure::DylibShaMismatch {
            path: path.clone(),
            original_sha256: snapshot.original_sha256.clone(),
            observed_sha256: observed_sha,
        });
    }

    // Invariant 4: captured toolchain key unchanged. Compared with the
    // session-startup `toolchain::matches` comparator across all four
    // key fields (release_line, host, commit_hash, sysroot).
    match toolchain::capture() {
        Ok(observed) => {
            if !toolchain::matches(&snapshot.original_toolchain, &observed) {
                return Err(FreshnessFailure::RustcDrift {
                    original: Box::new(snapshot.original_toolchain.clone()),
                    observed: Box::new(observed),
                });
            }
        }
        Err(_) => {
            // A captured toolchain that can no longer be re-captured is
            // itself a drift. Surface it as RustcDrift with a
            // placeholder `observed` so the detail renderer still has
            // valid fields to compare and the user sees a clear
            // "rustc disappeared" delta rather than a silent pass.
            return Err(FreshnessFailure::RustcDrift {
                original: Box::new(snapshot.original_toolchain.clone()),
                observed: Box::new(toolchain::Toolchain {
                    release_line: String::new(),
                    release: String::new(),
                    host: String::new(),
                    commit_hash: String::new(),
                    sysroot: PathBuf::new(),
                }),
            });
        }
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write;
    use tempfile::tempdir;

    fn write_dylib_stub(dir: &std::path::Path, contents: &[u8]) -> PathBuf {
        let p = dir.join("libstub.so");
        let mut f = std::fs::File::create(&p).unwrap();
        f.write_all(contents).unwrap();
        f.sync_all().unwrap();
        p
    }

    /// Canonical placeholder toolchain for tests that do not exercise the
    /// rustc-drift path. The values do not have to match the real rustc
    /// running the test because every test using this helper bails out
    /// before invariant 4 (the rustc re-capture).
    fn placeholder_toolchain() -> toolchain::Toolchain {
        toolchain::Toolchain {
            release_line: "rustc 1.95.0 (abc 2026-01-01)".into(),
            release: "1.95.0".into(),
            host: "x86_64-unknown-linux-gnu".into(),
            commit_hash: "59807616e2031c7c44a76b1b0c1bbd0fed9a07cf".into(),
            sysroot: PathBuf::from("/usr/local/rustup/toolchains/stable-x86_64"),
        }
    }

    /// Build a passing-invariants-1-3 snapshot pointing at a stub dylib
    /// in `dir`, so the rustc-drift tests below cleanly bite only
    /// invariant 4. The `original_toolchain` is a synthetic value chosen
    /// to differ from whatever the test machine's real `rustc::capture()`
    /// returns — guaranteeing the drift path fires.
    fn snapshot_with_synthetic_toolchain(
        dir: &std::path::Path,
        original_toolchain: toolchain::Toolchain,
    ) -> FreshnessSnapshot {
        let p = write_dylib_stub(dir, b"hello world");
        let meta = std::fs::metadata(&p).unwrap();
        let mtime = meta
            .modified()
            .unwrap()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_secs() as i64;
        let sha = crate::util::sha256_file(&p).unwrap();
        FreshnessSnapshot {
            managed_dylib_path: p,
            original_mtime_unix_secs: mtime,
            original_sha256: sha,
            original_toolchain,
        }
    }

    #[test]
    fn missing_dylib_fails_invariant_1() {
        let snap = FreshnessSnapshot {
            managed_dylib_path: PathBuf::from("/path/that/does/not/exist.so"),
            original_mtime_unix_secs: 0,
            original_sha256: "deadbeef".into(),
            original_toolchain: placeholder_toolchain(),
        };
        let r = check(&snap).unwrap_err();
        assert_eq!(r.invariant_label(), "managed_dylib_path");
    }

    #[test]
    fn sha_mismatch_fails_invariant_3() {
        let tmp = tempdir().unwrap();
        let p = write_dylib_stub(tmp.path(), b"hello world");
        let mtime = std::fs::metadata(&p)
            .unwrap()
            .modified()
            .unwrap()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_secs() as i64;
        let snap = FreshnessSnapshot {
            managed_dylib_path: p.clone(),
            original_mtime_unix_secs: mtime,
            // Wrong digest on purpose.
            original_sha256: "0000000000000000000000000000000000000000000000000000000000000000"
                .into(),
            original_toolchain: placeholder_toolchain(),
        };
        let err = check(&snap).unwrap_err();
        match &err {
            FreshnessFailure::DylibShaMismatch { .. } => {}
            other => panic!("expected DylibShaMismatch, got {other:?}"),
        }
        assert_eq!(err.invariant_label(), "dylib_sha256");
    }

    #[test]
    fn detail_messages_are_byte_deterministic() {
        let f = FreshnessFailure::DylibShaMismatch {
            path: PathBuf::from("/p/lib.so"),
            original_sha256: "abc".into(),
            observed_sha256: "def".into(),
        };
        let a = f.detail();
        let b = f.detail();
        assert_eq!(a, b);
        assert!(a.contains("/p/lib.so"));
        assert!(a.contains("abc"));
        assert!(a.contains("def"));
    }

    /// Shared body for the four freshness-drift tests. Anchors to the live
    /// `rustc` so `release_line`, `host`, `commit_hash`, and `sysroot` all
    /// genuinely match between the snapshot and the check-time capture;
    /// the caller's `mutate` closure changes only the named field. The
    /// assertion that the changed-fields prefix contains `field_name` AND
    /// NOT the other three field names bites two regressions: a comparator
    /// regressed to release-line-only (would return `Ok(())` and panic at
    /// `unwrap_err()`) and a detail renderer that drops the "names only
    /// the drifted dimensions" property.
    fn assert_only_field_drifts(
        field_name: &'static str,
        mutate: impl FnOnce(&mut toolchain::Toolchain),
    ) {
        let tmp = tempdir().unwrap();
        let live = toolchain::capture().expect("rustc must be on PATH for this test");
        let mut original = live.clone();
        mutate(&mut original);
        let snap = snapshot_with_synthetic_toolchain(tmp.path(), original);

        let err = check(&snap).unwrap_err();
        assert!(
            matches!(err, FreshnessFailure::RustcDrift { .. }),
            "expected RustcDrift, got {err:?}"
        );
        assert_eq!(err.invariant_label(), "rustc_release");

        let detail = err.detail();
        let changed_prefix = detail
            .split(';')
            .next()
            .filter(|s| s.contains("changed fields:"))
            .expect("changed-fields prefix must be present");
        assert!(
            changed_prefix.contains(field_name),
            "changed-fields list must name {field_name}: {changed_prefix}"
        );
        for other in ["release_line", "host", "commit_hash", "sysroot"] {
            if other != field_name {
                assert!(
                    !changed_prefix.contains(other),
                    "{other} must NOT appear in changed-fields: {changed_prefix}"
                );
            }
        }
    }

    #[test]
    fn freshness_check_detects_release_line_drift() {
        assert_only_field_drifts("release_line", |tc| {
            tc.release_line = "rustc 0.0.0 (fake 1970-01-01)".into();
        });
    }

    #[test]
    fn freshness_check_detects_host_drift() {
        assert_only_field_drifts("host", |tc| {
            tc.host = "fake-host-target".into();
        });
    }

    #[test]
    fn freshness_check_detects_commit_hash_drift() {
        assert_only_field_drifts("commit_hash", |tc| {
            tc.commit_hash = "00000000000000000000000000000000fakehash".into();
        });
    }

    #[test]
    fn freshness_check_detects_sysroot_drift() {
        assert_only_field_drifts("sysroot", |tc| {
            tc.sysroot = PathBuf::from("/nonexistent/fake/toolchains/stable");
        });
    }

    /// `detail()` rendering is byte-deterministic across the new
    /// `RustcDrift` shape. Two calls produce identical strings and the
    /// changed-fields list lands in canonical (release_line, host,
    /// commit_hash, sysroot) order regardless of how many fields drift.
    #[test]
    fn rustc_drift_detail_is_byte_deterministic_and_lists_changed_fields() {
        let original = toolchain::Toolchain {
            release_line: "rustc 1.95.0 (abc 2026-01-01)".into(),
            release: "1.95.0".into(),
            host: "x86_64-unknown-linux-gnu".into(),
            commit_hash: "59807616e2031c7c44a76b1b0c1bbd0fed9a07cf".into(),
            sysroot: PathBuf::from("/usr/local/rustup/toolchains/stable-x86_64"),
        };
        let observed = toolchain::Toolchain {
            release_line: "rustc 1.96.0 (def 2026-07-01)".into(),
            release: "1.96.0".into(),
            host: "aarch64-apple-darwin".into(),
            commit_hash: "59807616e2031c7c44a76b1b0c1bbd0fed9a07cf".into(),
            sysroot: PathBuf::from("/usr/local/rustup/toolchains/stable-x86_64"),
        };
        let f = FreshnessFailure::RustcDrift {
            original: Box::new(original),
            observed: Box::new(observed),
        };
        let a = f.detail();
        let b = f.detail();
        assert_eq!(a, b);
        // Two fields drifted; both must appear, in canonical order.
        let ri = a.find("release_line").expect("release_line in detail");
        let hi = a.find("host").expect("host in detail");
        assert!(
            ri < hi,
            "changed-fields list must list release_line before host: {a}"
        );
        // Untouched fields are not listed in the changed-fields prefix.
        // (commit_hash and sysroot DO appear later as part of the full
        // original/observed dump — we only check the changed-fields
        // section comes first by checking the changed-fields header.)
        let header = "changed fields: release_line, host;";
        assert!(
            a.contains(header),
            "expected changed-fields header `{header}`, got: {a}"
        );
    }
}