on master help sign in
Runtime freshness: content-hash instead of mtime

find_runtime's staleness check compared the archive's mtime against the newest runtime-source mtime — fragile under clock skew, touch, and VCS checkouts that reset mtimes, and it needed the workspace tree at compile time (unavailable for cargo-installed binaries). Replace it with a content comparison. build.rs hashes (blake3) the runtime archive it embeds and stamps it via cargo:rustc-env; at link time the compiler hashes any candidate archive (same-dir or KNOT_RUNTIME_LIB) and compares. A match means byte-identical bits — exactly what this compiler would link — regardless of how the bits got there or what any mtime says. Freshness is now a property of the artifact actually linked, not a proxy for it, and it works with no workspace dependency. Verified: fresh same-dir archive links silently; a tampered archive warns and falls back to the embedded runtime, and the fallback binary still runs.

commit0c0d7105eea411270a626fe8f09b995a91e6261e authoriko <mail@iko.soy> dateMon 3 Aug 2026, 14:20 (2 months ago) parent7e3390f
4 files changed+50−77
Cargo.lock+1 crates/knot-compiler/Cargo.toml+6 crates/knot-compiler/build.rs+7 crates/knot-compiler/src/main.rs+36−77
Cargo.lock+1
@@ -1452,6 +1452,7 @@ dependencies = [
14521452name = "knot-compiler"
14531453version = "0.1.0"
14541454dependencies = [
1455+ "blake3",
14551456 "cranelift-codegen",
14561457 "cranelift-frontend",
14571458 "cranelift-module",
crates/knot-compiler/Cargo.toml+6
@@ -28,3 +28,9 @@ object = { version = "0.39", default-features = false, features = ["write"] }
2828# refined types). `gh-release` statically links a prebuilt libz3 (downloaded at
2929# build time), so the compiler binary is self-contained — no runtime Z3 dep.
3030z3 = { version = "0.20", default-features = false, features = ["gh-release"] }
31+# Content-hash of the embedded runtime archive, used at link time to reject a
32+# stale on-disk libknot_runtime.a (replaces the old mtime heuristic).
33+blake3 = { version = "1", default-features = false }
34+
35+[build-dependencies]
36+blake3 = { version = "1", default-features = false }
crates/knot-compiler/build.rs+7
@@ -100,6 +100,13 @@ fn main() {
100100 if let Some(src) = runtime_path {
101101 std::fs::copy(&src, &dest).expect("failed to copy libknot_runtime.a to OUT_DIR");
102102 println!("cargo:rustc-cfg=has_embedded_runtime");
103+ // Stamp the content hash of the archive we're embedding. At link time
104+ // the compiler hashes any on-disk candidate and compares — a match
105+ // means byte-identical bits (fresh), regardless of mtimes or which
106+ // archive produced them.
107+ let bytes = std::fs::read(&dest).expect("failed to read embedded runtime for hashing");
108+ let hash = blake3::hash(&bytes).to_hex();
109+ println!("cargo:rustc-env=KNOT_EMBEDDED_RUNTIME_HASH={hash}");
103110 }
104111
105112 println!("cargo:rustc-check-cfg=cfg(has_embedded_runtime)");
crates/knot-compiler/src/main.rs+36−77
@@ -542,67 +542,29 @@ fn is_extracted_temp_runtime(p: &std::path::Path) -> bool {
542542 })
543543}
544544
545−/// Returns true if the runtime archive `lib` is stale — i.e. the newest
546−/// source file under `crates/knot-runtime/src/` (recursively) or the
547−/// runtime crate's `Cargo.toml` has an mtime newer than the archive itself.
548−/// Falls back to `false` (assume fresh) when the workspace root can't be
549−/// located or mtimes can't be compared, so we never block builds spuriously.
550−fn is_runtime_stale(lib: &std::path::Path, exe_dir: &std::path::Path) -> bool {
551− // Walk up from the compiler's directory to find the workspace root
552− // (the directory containing `crates/knot-runtime/`). In a cargo
553− // workspace the compiler binary lives in `target/<profile>/`, so the
554− // workspace root is two levels above `exe_dir`.
555− let workspace_root = exe_dir
556− .ancestors()
557− .skip(1) // skip `target/<profile>` itself
558− .find(|d| d.join("crates/knot-runtime").is_dir());
559−
560− let workspace_root = match workspace_root {
561− Some(r) => r,
562− None => return false, // can't locate workspace — assume fresh
563− };
564−
565− let lib_mtime = match std::fs::metadata(lib).and_then(|m| m.modified()) {
566− Ok(m) => m,
567− Err(_) => return false,
568− };
569−
570− // Recursively find the newest mtime among all `*.rs` files under
571− // `crates/knot-runtime/src/`, plus the runtime crate's `Cargo.toml`.
572− let mut newest_src: Option<std::time::SystemTime> = None;
573−
574− fn consider(
575− p: &std::path::Path,
576− newest: &mut Option<std::time::SystemTime>,
577− ) {
578− if let Ok(m) = std::fs::metadata(p).and_then(|m| m.modified())
579− && newest.map(|n| m > n).unwrap_or(true) {
580− *newest = Some(m);
581− }
582− }
583−
584− fn walk_dir(dir: &std::path::Path, newest: &mut Option<std::time::SystemTime>) {
585− if let Ok(rd) = std::fs::read_dir(dir) {
586− for entry in rd.flatten() {
587− let p = entry.path();
588− if p.is_dir() {
589− walk_dir(&p, newest);
590− } else if p.extension().map(|e| e == "rs").unwrap_or(false) {
591− consider(&p, newest);
592− }
593− }
594− }
595− }
596−
597− walk_dir(&workspace_root.join("crates/knot-runtime/src"), &mut newest_src);
598− consider(
599− &workspace_root.join("crates/knot-runtime/Cargo.toml"),
600− &mut newest_src,
601− );
545+/// The blake3 content hash of the runtime archive this compiler embeds (set by
546+/// build.rs when an archive was embedded). Comparing a candidate archive's hash
547+/// against this is the freshness check: a match means byte-identical bits, so
548+/// the archive is exactly what this compiler would link — independent of file
549+/// mtimes, clock skew, or which build produced it.
550+const EMBEDDED_RUNTIME_HASH: Option<&str> = option_env!("KNOT_EMBEDDED_RUNTIME_HASH");
551+
552+/// Hash an archive's bytes. `None` on read error.
553+fn hash_archive(p: &std::path::Path) -> Option<String> {
554+ std::fs::read(p)
555+ .ok()
556+ .map(|bytes| blake3::hash(&bytes).to_hex().to_string())
557+}
602558
603− match newest_src {
604− Some(src_mtime) => src_mtime > lib_mtime,
605− None => false, // no source files found — assume fresh
559+/// True if `lib` is stale — its content differs from the runtime archive this
560+/// compiler embeds. Falls back to `false` (assume fresh) when there's no
561+/// embedded runtime to compare against or the file can't be read, so we never
562+/// block builds spuriously. Replaces the old mtime walk: content comparison is
563+/// immune to clock skew, `touch`, and VCS checkouts that reset mtimes.
564+fn is_runtime_stale(lib: &std::path::Path) -> bool {
565+ match EMBEDDED_RUNTIME_HASH {
566+ Some(expected) => hash_archive(lib).map(|h| h != expected).unwrap_or(false),
567+ None => false,
606568 }
607569}
608570
@@ -611,19 +573,15 @@ fn find_runtime() -> PathBuf {
611573 if let Ok(path) = std::env::var("KNOT_RUNTIME_LIB") {
612574 let p = PathBuf::from(&path);
613575 if p.exists() {
614− // Warn if the archive is stale relative to the runtime source,
576+ // Warn if the archive's content differs from the embedded runtime,
615577 // but still use it — the user set the override explicitly.
616− if let Some(exe_dir) = std::env::current_exe()
617− .ok()
618− .and_then(|e| e.parent().map(|p| p.to_path_buf()))
619− && is_runtime_stale(&p, &exe_dir)
620− {
621− eprintln!(
622− "Warning: KNOT_RUNTIME_LIB archive '{}' is older than \
623− crates/knot-runtime/src/ — rebuild knot-runtime to \
624− pick up source changes",
625− path
626− );
578+ if is_runtime_stale(&p) {
579+ eprintln!(
580+ "Warning: KNOT_RUNTIME_LIB archive '{}' content differs from \
581+ the runtime this compiler embeds — rebuild knot-runtime to \
582+ pick up source changes",
583+ path
584+ );
627585 }
628586 return p;
629587 }
@@ -642,13 +600,14 @@ fn find_runtime() -> PathBuf {
642600 && let Some(exe_dir) = exe.parent() {
643601 let candidate = exe_dir.join("libknot_runtime.a");
644602 if candidate.exists() {
645− // Freshness check: if the runtime source is newer than the
646− // archive, the archive is stale (e.g. only knot-compiler was
647− // rebuilt). Skip it and fall through to the embedded runtime
603+ // Freshness check: if the archive's content differs from the
604+ // runtime this compiler embeds (e.g. only knot-compiler was
605+ // rebuilt, or knot-runtime sources changed), the archive is
606+ // stale. Skip it and fall through to the embedded runtime
648607 // rather than silently linking stale code.
649− if is_runtime_stale(&candidate, exe_dir) {
608+ if is_runtime_stale(&candidate) {
650609 eprintln!(
651− "Warning: {} is older than crates/knot-runtime/src/ \
610+ "Warning: {} content differs from the embedded runtime \
652611 — skipping stale archive, falling back to embedded \
653612 runtime. Run `cargo build -p knot-runtime` to refresh.",
654613 candidate.display()