Skip to main content

harn_vm/
bytecode_cache.rs

1//! Content-addressed on-disk cache for compiled `.harn` pipelines.
2//!
3//! Cold-start `harn run` re-parses, type-checks, and compiles the entry
4//! pipeline before the VM gets a single instruction to execute. For short
5//! Harn subcommands that wrap a few `llm_call`s in a small pipeline, that
6//! compile cost dominates wall-clock time.
7//!
8//! This module persists [`Chunk`] bytecode under
9//! `$HARN_CACHE_DIR/<source-hash>.harnbc` (XDG-aware). The cache key is
10//! derived from the source plus its compilation context. Entry chunks include
11//! the content of every transitively-imported user file because they compile
12//! the complete program. Module artifacts compile exactly one file and retain
13//! unresolved import specs, so their context includes compiler and embedded
14//! stdlib identity plus the typed imported interface (names and kinds, not
15//! dependency bodies). Any change to an artifact's actual compilation inputs
16//! flips the key and recompiles it.
17//!
18//! File layout — little-endian throughout:
19//!
20//! ```text
21//! magic        : [u8; 8]   = "HARNBC\0\0"
22//! schema_ver   : u32       = SCHEMA_VERSION
23//! version_len  : u32
24//! harn_version : [u8; version_len]
25//! fp_len       : u32
26//! codegen_fp   : [u8; fp_len]   CODEGEN_FINGERPRINT of the producing build
27//! compiler_tag : u8        bitmask of active CompilerOptions
28//! kind         : u8        1 = entry chunk, 2 = module artifact
29//! provenance   : u8        authority the artifact was compiled under
30//! source_hash  : [u8; 32]
31//! context_hash : [u8; 32]
32//! payload      : postcard-serialized payload for `kind`
33//! ```
34//!
35//! The header lets a stale binary detect a future-version artifact
36//! without crashing: a magic mismatch, schema mismatch, or version
37//! mismatch is returned as `Ok(None)` so the caller transparently
38//! recompiles. Real I/O errors propagate.
39//!
40//! Concurrency: writes go through [`crate::atomic_io`] (write-tmp, fsync,
41//! rename, fsync parent dir), and parallel invocations on a cache miss race
42//! safely — the last writer wins, but every reader observes a consistent file
43//! because the rename is atomic on every supported filesystem.
44
45use std::borrow::Cow;
46use std::fs;
47use std::io::{self, Read as _};
48use std::path::{Path, PathBuf};
49use std::sync::Arc;
50
51use serde::{de::DeserializeOwned, Serialize};
52use sha2::{Digest, Sha256};
53
54use crate::chunk::{CachedChunk, Chunk};
55use crate::compiler::CompilerOptions;
56use crate::context_manifest::{
57    ContextManifest, GraphLinkTable, ManifestCheck, ManifestFile, ManifestUnreadable,
58    ManifestUnresolved,
59};
60use crate::module_artifact::{ModuleArtifact, ModuleCompilationContext, ModuleProvenance};
61use crate::module_source::{self, ModuleSource};
62
63/// Header magic for all bytecode-cache artifact families.
64pub const MAGIC: &[u8; 8] = b"HARNBC\0\0";
65
66/// On-disk format version. Bump when [`CachedChunk`] or the header
67/// layout changes in a backwards-incompatible way.
68/// v5: `ModuleArtifact` gained `public_type_names` (`pub type` exports).
69/// v6: payload encoding replaced with postcard.
70/// v7: exported type schemas moved from eager JSON strings to an initializer
71/// chunk that resolves imported aliases in the module environment.
72/// v7: `ModuleArtifact` replaced split name sets with the typed public export
73/// contract shared by the module graph and runtime.
74/// v8: entry-chunk payload carries a [`ContextManifest`] so a warm lookup can
75/// prove the import graph is unchanged with stats instead of re-walking it.
76/// v9: the manifest records the entry it was walked from, so it cannot vouch
77/// for a different entry that happens to have identical source bytes (#5591);
78/// the header carries [`CODEGEN_FINGERPRINT`], which the manifest fast path
79/// needs in order to reject a chunk built by another compiler at the same
80/// version (#5610); and manifest entries carry a content digest, and the
81/// manifest a capture time, so a rewrite inside the filesystem's timestamp
82/// granularity cannot present itself as unchanged (#5582).
83/// v10: module namespace imports carry conservative static member demand.
84/// v11: manifest link entries carry the typed imported interface needed to
85/// reconstruct an exact module compilation key.
86/// v12: the header carries the authority the artifact was compiled under, so an
87/// ordinary lookup cannot accept an adjacent artifact compiled with privileged
88/// authority. `compiler_tag` could not express this: it folds the optimization
89/// and legacy-ambient bits only, never `privileged_wire_authority`.
90pub const SCHEMA_VERSION: u32 = 12;
91
92/// Compile-time Harn release. Cache files written by a different release
93/// are rejected on load.
94pub const HARN_VERSION: &str = env!("CARGO_PKG_VERSION");
95
96/// Build-time fingerprint of the compiler front-end — the lexer, parser, IR,
97/// and code generator — computed in `build.rs` from those crates' source and
98/// baked in via `cargo:rustc-env`. Folded into the cache key so a compiler
99/// change that alters emitted bytecode for unchanged source invalidates stale
100/// entries automatically, within a single version, with no manual cache wipe.
101/// `HARN_VERSION` only busts the cache across release bumps; this closes the
102/// same gap for the within-version compiler edits that masked #2610. See #2621.
103///
104/// It reaches a lookup two ways. The header comparison is what *rejects* a
105/// stale artifact, and is the only one the entry fast path can afford, since
106/// that path proves its graph from a manifest and never recomputes the context
107/// hash (#5610). Folding it into the context hash as well is what keeps two
108/// builds' module artifacts on distinct filenames rather than overwriting each
109/// other, since `module_filename` is derived from that hash.
110pub const CODEGEN_FINGERPRINT: &str = env!("HARN_CODEGEN_FINGERPRINT");
111
112/// Conventional extension for entry-chunk cache files.
113pub const CACHE_EXTENSION: &str = "harnbc";
114
115/// Conventional extension for module-artifact cache files. Distinct from
116/// [`CACHE_EXTENSION`] so the same `.harn` source can have both shipped
117/// adjacent if needed (e.g. when a file is both an executable entry and
118/// imported by other files).
119pub const MODULE_CACHE_EXTENSION: &str = "harnmod";
120
121/// On-disk discriminant for a [`Chunk`] payload.
122const KIND_ENTRY_CHUNK: u8 = 1;
123/// On-disk discriminant for a [`ModuleArtifact`] payload.
124const KIND_MODULE_ARTIFACT: u8 = 2;
125
126/// Environment override for the cache directory. When set, takes
127/// precedence over the XDG and home-directory fallbacks.
128pub const CACHE_DIR_ENV: &str = "HARN_CACHE_DIR";
129
130/// Environment override that turns the cache off entirely. Setting this
131/// to `0`, `false`, `no`, or `off` skips both reads and writes; useful
132/// when debugging compiler changes.
133pub const CACHE_ENABLED_ENV: &str = "HARN_BYTECODE_CACHE";
134
135/// Result of a cache lookup. Carries the precomputed key so the caller
136/// can write it back on a miss without rehashing.
137pub struct LookupOutcome {
138    pub key: CacheKey,
139    pub chunk: Option<Chunk>,
140    /// Graph observations to persist alongside the chunk, so the next spawn can
141    /// re-check them with stats instead of walking. `None` when the graph holds
142    /// something stats cannot describe.
143    pub manifest: Option<ContextManifest>,
144    /// The graph's link table, present only when this lookup proved a stored
145    /// manifest current. Hand it to the VM and module loading resolves every
146    /// module the table names without reading its source.
147    ///
148    /// Deliberately absent whenever the walk ran, even though the walk's
149    /// observations are just as accurate: the walk has already read every file
150    /// into [`module_source`]'s memo, so a table would save nothing and cost a
151    /// map to build.
152    pub link_table: Option<Arc<GraphLinkTable>>,
153}
154
155impl LookupOutcome {
156    /// Persist `chunk` under the key this lookup computed, with the manifest it
157    /// observed.
158    ///
159    /// The pairing is the point: the key and the manifest describe one walk of
160    /// one graph, and storing a chunk against a manifest from a different walk
161    /// would let a later spawn prove the wrong thing. Callers cannot get that
162    /// pairing wrong if they never have to assemble it.
163    pub fn store(&self, chunk: &Chunk) -> io::Result<()> {
164        store(&self.key, chunk, self.manifest.as_ref())
165    }
166}
167
168/// Cache key components for a single pipeline source. Equality of all
169/// fields is necessary and sufficient for cache reuse.
170#[derive(Clone, Debug, PartialEq, Eq)]
171pub struct CacheKey {
172    pub source_hash: [u8; 32],
173    pub context_hash: [u8; 32],
174    /// Harn version stamped into, and required by, this artifact's header.
175    ///
176    /// Normally the running binary's own [`HARN_VERSION`]. Release preparation
177    /// bumps `Cargo.toml` *after* snapshotting the generator, so that one
178    /// caller must stamp the version the shipped binary will report instead of
179    /// the version the generator was built at — otherwise the shipped runtime
180    /// rejects its own embedded payload and silently falls back to source
181    /// compilation. Use [`CacheKey::for_artifact_version`].
182    pub harn_version: Cow<'static, str>,
183    /// Compact tag for active [`CompilerOptions`]. Flipping
184    /// `HARN_DISABLE_OPTIMIZATIONS` between runs would otherwise reuse a
185    /// chunk compiled under the wrong setting.
186    pub compiler_tag: u8,
187    /// The authority the artifact was compiled under.
188    ///
189    /// Part of the identity because it selects what the emitted bytecode is
190    /// permitted to do: the same source compiled as
191    /// [`ModuleProvenance::TrustedHostDispatch`] may call privileged builtins
192    /// that the same source compiled as [`ModuleProvenance::User`] may not.
193    /// Two compiles that differ in that are not one artifact, and `compiler_tag`
194    /// cannot express the difference — it folds only the optimization and
195    /// legacy-ambient bits, never `privileged_wire_authority`. Without this
196    /// field they collide on one key and one filename.
197    pub provenance: ModuleProvenance,
198}
199
200impl CacheKey {
201    /// Compute the cache key for a `.harn` source file plus its transitive
202    /// user imports. `source` is the entry-file contents; the import
203    /// graph is walked from disk relative to `source_path`.
204    pub fn from_source(source_path: &Path, source: &str) -> Self {
205        let source_hash = sha256(source.as_bytes());
206        let context_hash = hash_transitive_user_imports(source_path, source);
207        Self {
208            source_hash,
209            context_hash,
210            harn_version: Cow::Borrowed(HARN_VERSION),
211            compiler_tag: compiler_options_tag(CompilerOptions::from_env()),
212            // Entry chunks are always compiled ordinary. The trusted
213            // latch governs `module_provenance`, i.e. the graph a VM
214            // imports, not the entry it was handed.
215            provenance: ModuleProvenance::User,
216        }
217    }
218
219    /// Compute a relocatable entry-chunk key for a closed source tree.
220    ///
221    /// Unlike [`Self::from_source`], the dependency graph identifies files by
222    /// their path relative to the entrypoint directory. Moving the complete
223    /// tree therefore preserves the key, while changing a relative path,
224    /// source byte, compiler build, or embedded stdlib still invalidates it.
225    /// This is the key used by packaged adjacent artifacts; ordinary shared
226    /// cache entries remain anchored to canonical host paths.
227    pub fn from_relocatable_source(source_path: &Path, source: &str) -> Self {
228        let source_hash = sha256(source.as_bytes());
229        let context_hash = hash_relocatable_user_imports(source_path, source);
230        Self {
231            source_hash,
232            context_hash,
233            harn_version: Cow::Borrowed(HARN_VERSION),
234            compiler_tag: compiler_options_tag(CompilerOptions::from_env()),
235            // Entry chunks are always compiled ordinary. The trusted
236            // latch governs `module_provenance`, i.e. the graph a VM
237            // imports, not the entry it was handed.
238            provenance: ModuleProvenance::User,
239        }
240    }
241
242    /// Restamp this key for an artifact that a *different* Harn version will
243    /// load. Only release preparation needs this; every ordinary compile and
244    /// lookup keeps the running binary's own version.
245    #[must_use]
246    pub fn for_artifact_version(mut self, harn_version: impl Into<String>) -> Self {
247        self.harn_version = Cow::Owned(harn_version.into());
248        self
249    }
250
251    /// Compute the cache key for one independently-compiled module artifact.
252    ///
253    /// A [`ModuleArtifact`] stores unresolved import specs and never compiles
254    /// dependency bodies into the parent artifact. Its lowering can still
255    /// depend on the imported interface supplied explicitly here; every
256    /// dependency body remains protected by its own source-local key.
257    /// Diagnostic paths are rebound when the artifact is loaded, so adjacent
258    /// and packaged artifacts remain relocatable without aliasing attribution.
259    pub fn from_module_source(
260        source: &ModuleSource,
261        compilation_context: &ModuleCompilationContext,
262        provenance: ModuleProvenance,
263    ) -> Self {
264        Self::from_module_content_hash(source.sha256(), compilation_context, provenance)
265    }
266
267    /// As [`from_module_source`](Self::from_module_source), but from a digest
268    /// recorded earlier instead of bytes in hand.
269    ///
270    /// The source digest and typed imported interface are the graph-local parts
271    /// of a module key. A validated [`GraphLinkTable`] carries both so it can
272    /// name an artifact without reading the file or rebuilding the graph.
273    pub fn from_module_content_hash(
274        content_hash: [u8; 32],
275        compilation_context: &ModuleCompilationContext,
276        provenance: ModuleProvenance,
277    ) -> Self {
278        Self {
279            source_hash: content_hash,
280            context_hash: module_compilation_context_hash(compilation_context),
281            harn_version: Cow::Borrowed(HARN_VERSION),
282            compiler_tag: compiler_options_tag(CompilerOptions::from_env()),
283            provenance,
284        }
285    }
286
287    /// Identity for an embedded stdlib module artifact, derived without
288    /// parsing the module.
289    ///
290    /// Every input to a stdlib module's imported interface is already part of
291    /// this key: its own bytes as `content_hash`, and the rest of the closure
292    /// it can import as the embedded stdlib table, whose
293    /// [`embedded_stdlib_digest`] every module key's context hash folds in. The
294    /// interface digest adds no discriminating power here, while deriving it
295    /// costs a full lex and parse in front of the lookup that exists to avoid
296    /// one. User files keep the real digest: their dependencies are mutable
297    /// files no other field of the key can see, which is the aliasing that
298    /// field was added to prevent.
299    pub fn from_embedded_stdlib_module_content_hash(
300        content_hash: [u8; 32],
301        provenance: ModuleProvenance,
302    ) -> Self {
303        Self {
304            source_hash: content_hash,
305            context_hash: module_compilation_context_hash_fingerprinted(
306                CODEGEN_FINGERPRINT,
307                EMBEDDED_STDLIB_INTERFACE_DIGEST,
308            ),
309            harn_version: Cow::Borrowed(HARN_VERSION),
310            compiler_tag: compiler_options_tag(CompilerOptions::from_env()),
311            provenance,
312        }
313    }
314
315    /// Entry-chunk filename for this key. We hash by source content
316    /// alone so two invocations of the same source from different paths
317    /// share a cache entry; the header's compilation-context hash still gates
318    /// reuse on a per-load basis.
319    pub fn filename(&self) -> String {
320        format!("{}.{}", hex(&self.source_hash), CACHE_EXTENSION)
321    }
322
323    /// Module-artifact filename for this complete compilation key. Diagnostic
324    /// source paths are rebound at load time, so identical source and compiler
325    /// inputs share one relocatable artifact across paths.
326    pub fn module_filename(&self) -> String {
327        let mut hasher = Sha256::new();
328        hasher.update(self.source_hash);
329        hasher.update(self.context_hash);
330        hasher.update(self.harn_version.as_bytes());
331        hasher.update([self.compiler_tag]);
332        // Authority is part of the filename, not only of the header check, so a
333        // trusted and an ordinary artifact for one source cannot occupy the same
334        // path and overwrite each other.
335        hasher.update([provenance_tag(self.provenance)]);
336        let identity: [u8; 32] = hasher.finalize().into();
337        format!("{}.{}", hex(&identity), MODULE_CACHE_EXTENSION)
338    }
339}
340
341/// Why a configured cache root cannot be used.
342///
343/// Only ever produced for an *explicitly configured* `$HARN_CACHE_DIR`. A
344/// value the operator set and Harn cannot honor is an error, never a silent
345/// downgrade.
346#[derive(Debug, Clone, PartialEq, Eq)]
347pub enum CacheDirError {
348    /// `$HARN_CACHE_DIR` is set to an empty value.
349    Empty,
350    /// `$HARN_CACHE_DIR` is relative, so the cache location would change with
351    /// the working directory.
352    Relative(PathBuf),
353}
354
355impl std::fmt::Display for CacheDirError {
356    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
357        match self {
358            Self::Empty => write!(
359                formatter,
360                "{CACHE_DIR_ENV} is set but empty; unset it to use the default cache location, \
361                 or set it to an absolute path"
362            ),
363            Self::Relative(path) => write!(
364                formatter,
365                "{CACHE_DIR_ENV} must be an absolute path, got {}; a relative cache directory \
366                 moves with the working directory, so the same process run from two directories \
367                 would keep two unrelated caches",
368                path.display()
369            ),
370        }
371    }
372}
373
374impl std::error::Error for CacheDirError {}
375
376/// A validated location for this user's Harn caches.
377///
378/// The two shapes are not interchangeable, and the difference is load-bearing:
379/// `$HARN_CACHE_DIR` *is* the bytecode directory (its `packs` sibling lives
380/// beneath it), while a discovered root is a `harn` cache home whose families
381/// each get a named subdirectory. Collapsing them would move every existing
382/// bytecode cache.
383#[derive(Debug, Clone, PartialEq, Eq)]
384enum CacheRoot {
385    Explicit(PathBuf),
386    Discovered(PathBuf),
387}
388
389impl CacheRoot {
390    fn bytecode_dir(&self) -> PathBuf {
391        match self {
392            Self::Explicit(path) => path.clone(),
393            Self::Discovered(path) => path.join("bytecode"),
394        }
395    }
396
397    fn packs_dir(&self) -> PathBuf {
398        match self {
399            Self::Explicit(path) | Self::Discovered(path) => path.join("packs"),
400        }
401    }
402}
403
404/// The one owner of where this user's bytecode and pack caches live.
405///
406/// `Ok(None)` means no root resolves at all — no override, no `XDG_CACHE_HOME`,
407/// no home directory — in which case caching is off for this process. It is
408/// deliberately not a working-directory-relative fallback: that made the cache
409/// location depend on where the process happened to be started, so the same
410/// process kept two unrelated caches and neither warmed the other, and it read
411/// and wrote compiled bytecode in a directory that may not be under the
412/// operator's control.
413///
414/// [`cache_dir`] and [`packs_cache_dir`] both derive from this and neither
415/// reads the environment again.
416fn cache_root() -> Result<Option<CacheRoot>, CacheDirError> {
417    cache_root_from(
418        std::env::var_os(CACHE_DIR_ENV).map(PathBuf::from),
419        std::env::var_os("XDG_CACHE_HOME").map(PathBuf::from),
420        crate::user_dirs::home_dir(),
421    )
422}
423
424/// The deterministic core of [`cache_root`], with the three inputs supplied.
425///
426/// Split out so the contract is testable without mutating process environment
427/// state that parallel tests share.
428fn cache_root_from(
429    explicit: Option<PathBuf>,
430    xdg: Option<PathBuf>,
431    home: Option<PathBuf>,
432) -> Result<Option<CacheRoot>, CacheDirError> {
433    if let Some(custom) = explicit {
434        if custom.as_os_str().is_empty() {
435            return Err(CacheDirError::Empty);
436        }
437        if !custom.is_absolute() {
438            return Err(CacheDirError::Relative(custom));
439        }
440        return Ok(Some(CacheRoot::Explicit(custom)));
441    }
442    if let Some(xdg) = xdg {
443        // Unlike `$HARN_CACHE_DIR` this is not Harn's own knob, and the XDG
444        // spec says a relative value must be ignored rather than rejected, so
445        // an unusable value falls through to the home directory instead of
446        // failing the process.
447        if !xdg.as_os_str().is_empty() && xdg.is_absolute() {
448            return Ok(Some(CacheRoot::Discovered(xdg.join("harn"))));
449        }
450    }
451    if let Some(home) = home {
452        return Ok(Some(CacheRoot::Discovered(
453            home.join(".cache").join("harn"),
454        )));
455    }
456    Ok(None)
457}
458
459/// Validate the cache configuration once, at process startup.
460///
461/// Returns `Ok(None)` when the cache is usable, or `Ok(Some(reason))` when no
462/// root resolves and caching is therefore off — the caller emits that reason
463/// as a single warning line. An `Err` is an operator-configured value Harn
464/// cannot honor and should stop the process.
465pub fn check_cache_config() -> Result<Option<&'static str>, CacheDirError> {
466    Ok(cache_root()?.is_none().then_some(
467        "no cache directory resolves (no HARN_CACHE_DIR, no XDG_CACHE_HOME, no home \
468         directory); compiled bytecode will not be cached for this run",
469    ))
470}
471
472/// Returns the directory the shared cache lives in, or `None` when no cache
473/// location resolves. Honors `$HARN_CACHE_DIR`, then `$XDG_CACHE_HOME/harn/bytecode`,
474/// then `$HOME/.cache/harn/bytecode`. The directory is *not* created here —
475/// [`store`] creates it lazily on write so read-only environments don't
476/// pay an mkdir cost.
477pub fn cache_dir() -> Option<PathBuf> {
478    cache_root().ok().flatten().map(|root| root.bytecode_dir())
479}
480
481/// Root for `.harnpack` archives unpacked by `harn run <bundle.harnpack>`.
482/// Each verified bundle is replayed into `<root>/<sanitized-bundle-hash>/`
483/// so re-runs reuse the unpacked tree. Honors `$HARN_CACHE_DIR/packs`
484/// when set, otherwise XDG / `$HOME/.cache/harn/packs`.
485pub fn packs_cache_dir() -> Option<PathBuf> {
486    cache_root().ok().flatten().map(|root| root.packs_dir())
487}
488
489/// True when the cache is enabled by the current environment.
490///
491/// A cache with nowhere to live is off, not "on, writing somewhere arbitrary".
492/// That is the state a missing cache root degrades into, so every read and
493/// write path already routes around it through the checks they had.
494pub fn cache_enabled() -> bool {
495    let switched_on = match std::env::var(CACHE_ENABLED_ENV).ok().as_deref() {
496        Some(value) => !matches!(
497            value.to_ascii_lowercase().as_str(),
498            "0" | "false" | "no" | "off"
499        ),
500        None => true,
501    };
502    switched_on && matches!(cache_root(), Ok(Some(_)))
503}
504
505/// Try to load a cached chunk for `source_path` whose contents are
506/// `source`. Returns the key alongside the (optional) chunk so callers
507/// avoid recomputing the key on miss.
508pub fn load(source_path: &Path, source: &str) -> LookupOutcome {
509    // Only the entry file's own hash is needed to find candidates. The context
510    // hash — the expensive half — is deferred until a candidate actually asks
511    // for it, because a candidate carrying a still-valid manifest never does.
512    let mut key = CacheKey {
513        source_hash: sha256(source.as_bytes()),
514        context_hash: [0u8; 32],
515        harn_version: Cow::Borrowed(HARN_VERSION),
516        compiler_tag: compiler_options_tag(CompilerOptions::from_env()),
517        // Entry chunks are always compiled ordinary. The trusted
518        // latch governs `module_provenance`, i.e. the graph a VM
519        // imports, not the entry it was handed.
520        provenance: ModuleProvenance::User,
521    };
522    let mut walk = GraphWalk::new(source_path, source);
523
524    if !cache_enabled() {
525        let (context_hash, manifest) = walk.finish();
526        key.context_hash = context_hash;
527        return LookupOutcome {
528            key,
529            chunk: None,
530            manifest,
531            link_table: None,
532        };
533    }
534
535    let mut candidates: Vec<(PathBuf, bool)> = Vec::with_capacity(2);
536    if let Some(adjacent) = adjacent_cache_path(source_path) {
537        candidates.push((adjacent, true));
538    }
539    if let Some(dir) = cache_dir() {
540        candidates.push((dir.join(key.filename()), false));
541    }
542
543    // Candidates are found by entry source hash alone, so a candidate may have
544    // been written by a *different* entry with byte-identical source. Its
545    // manifest has to say it describes this one before its observations mean
546    // anything here.
547    let entry = module_source::canonical_identity(source_path);
548
549    for (path, allow_relocatable) in candidates {
550        let Ok(Some(candidate)) = read_entry_candidate(&path, &key) else {
551            continue;
552        };
553        match candidate
554            .manifest
555            .as_ref()
556            .map(|manifest| manifest.check(&entry))
557        {
558            // The graph is provably unchanged, so the stored context hash is
559            // still the one this source would produce.
560            Some(ManifestCheck::Valid) => {
561                key.context_hash = candidate.context_hash;
562                return LookupOutcome {
563                    key,
564                    chunk: Some(candidate.chunk),
565                    link_table: candidate.manifest.as_ref().map(link_table_for),
566                    manifest: candidate.manifest,
567                };
568            }
569            // Same answer, but it cost a content read because some entry was
570            // still inside the racy window when this manifest was captured.
571            // Writing the re-stamped manifest back settles those entries, so
572            // the read is paid once rather than on every later spawn.
573            Some(ManifestCheck::ValidAfterRecheck { refreshed }) => {
574                key.context_hash = candidate.context_hash;
575                let _ = write_atomic_chunk(&path, &key, &candidate.chunk, Some(&refreshed));
576                return LookupOutcome {
577                    key,
578                    chunk: Some(candidate.chunk),
579                    link_table: Some(link_table_for(&refreshed)),
580                    manifest: Some(refreshed),
581                };
582            }
583            Some(ManifestCheck::Stale) | None => {}
584        }
585        if walk.context_hash() != candidate.context_hash {
586            if !allow_relocatable || walk.relocatable_context_hash() != candidate.context_hash {
587                continue;
588            }
589            // Packaged chunks carry no host-specific manifest. Their distinct
590            // root-relative context is accepted only from the adjacent path;
591            // shared-cache candidates must always match the canonical graph.
592            key.context_hash = candidate.context_hash;
593            return LookupOutcome {
594                key,
595                chunk: Some(candidate.chunk),
596                manifest: walk.manifest().cloned(),
597                link_table: None,
598            };
599        }
600        // The graph moved in a way that does not change the key — a touched
601        // mtime, a restored checkout. Refresh the artifact so the next spawn
602        // gets the fast path back instead of re-walking forever.
603        key.context_hash = candidate.context_hash;
604        let manifest = walk.manifest().cloned();
605        let _ = write_atomic_chunk(&path, &key, &candidate.chunk, manifest.as_ref());
606        return LookupOutcome {
607            key,
608            chunk: Some(candidate.chunk),
609            manifest,
610            link_table: None,
611        };
612    }
613
614    let (context_hash, manifest) = walk.finish();
615    key.context_hash = context_hash;
616    LookupOutcome {
617        key,
618        chunk: None,
619        manifest,
620        link_table: None,
621    }
622}
623
624/// Index `manifest` for the module loader. Called only where a re-check has
625/// just proven the manifest current, which is the whole basis for loading the
626/// artifacts it names without reading their sources.
627fn link_table_for(manifest: &ContextManifest) -> Arc<GraphLinkTable> {
628    Arc::new(GraphLinkTable::from_validated(manifest))
629}
630
631/// The import-graph walk, run at most once per lookup and only when a
632/// candidate cannot prove itself with its manifest.
633struct GraphWalk<'a> {
634    source_path: &'a Path,
635    source: &'a str,
636    result: Option<GraphHashes>,
637}
638
639impl<'a> GraphWalk<'a> {
640    fn new(source_path: &'a Path, source: &'a str) -> Self {
641        Self {
642            source_path,
643            source,
644            result: None,
645        }
646    }
647
648    fn run(&mut self) -> &GraphHashes {
649        self.result.get_or_insert_with(|| {
650            walk_import_graph_fingerprinted(self.source_path, self.source, CODEGEN_FINGERPRINT)
651        })
652    }
653
654    fn context_hash(&mut self) -> [u8; 32] {
655        self.run().canonical
656    }
657
658    fn relocatable_context_hash(&mut self) -> [u8; 32] {
659        self.run().relocatable
660    }
661
662    fn manifest(&mut self) -> Option<&ContextManifest> {
663        self.run().manifest.as_ref()
664    }
665
666    fn finish(mut self) -> ([u8; 32], Option<ContextManifest>) {
667        self.run();
668        let result = self.result.expect("the walk was just run");
669        (result.canonical, result.manifest)
670    }
671}
672
673/// Persist `chunk` to the shared cache directory under `key`. Atomic: a
674/// temp file is written then renamed into place. Concurrent invocations
675/// on the same key race safely.
676pub fn store(key: &CacheKey, chunk: &Chunk, manifest: Option<&ContextManifest>) -> io::Result<()> {
677    if !cache_enabled() {
678        return Ok(());
679    }
680    // `cache_enabled` is false when no root resolves, so this is reachable
681    // only with a directory in hand.
682    let Some(dir) = cache_dir() else {
683        return Ok(());
684    };
685    fs::create_dir_all(&dir)?;
686    write_atomic_chunk(&dir.join(key.filename()), key, chunk, manifest)
687}
688
689/// Write a precompiled entry-chunk artifact to an explicit path, for
690/// use by the `harn precompile` subcommand. The header still records
691/// the key, so adjacent artifacts shipped with source are validated
692/// like any other cache hit.
693pub fn store_at(path: &Path, key: &CacheKey, chunk: &Chunk) -> io::Result<()> {
694    ensure_parent_dir(path)?;
695    write_atomic_chunk(path, key, chunk, None)
696}
697
698/// Look up the [`ModuleArtifact`] for `source_path` (whose contents are
699/// `source`). Mirrors [`load`] but for the `.harnmod` family.
700pub fn load_module(
701    source_path: &Path,
702    source: &ModuleSource,
703    compilation_context: &ModuleCompilationContext,
704    provenance: ModuleProvenance,
705) -> ModuleLookupOutcome {
706    load_module_for_key(
707        source_path,
708        CacheKey::from_module_source(source, compilation_context, provenance),
709    )
710}
711
712/// As [`load_module`], but for a key already known without reading the source.
713///
714/// `artifact` is `None` when nothing is stored under `key` — including when it
715/// was evicted from the shared cache directory. A known key is a shortcut to an
716/// artifact, not a promise that one exists, so a caller that gets `None` must
717/// fall back to reading and compiling.
718pub fn load_module_for_key(source_path: &Path, key: CacheKey) -> ModuleLookupOutcome {
719    if !cache_enabled() {
720        return ModuleLookupOutcome {
721            key,
722            artifact: None,
723        };
724    }
725    let mut candidates: Vec<PathBuf> = Vec::with_capacity(2);
726    if let Some(adjacent) = adjacent_module_cache_path(source_path) {
727        candidates.push(adjacent);
728    }
729    if let Some(dir) = cache_dir() {
730        candidates.push(dir.join(key.module_filename()));
731    }
732    for path in candidates {
733        match read_module_if_matches(&path, &key, source_path) {
734            Ok(Some(artifact)) => {
735                return ModuleLookupOutcome {
736                    key,
737                    artifact: Some(artifact),
738                }
739            }
740            Ok(None) => continue,
741            Err(_) => continue,
742        }
743    }
744    ModuleLookupOutcome {
745        key,
746        artifact: None,
747    }
748}
749
750/// Persist `artifact` to the shared cache under `key`. Atomic;
751/// concurrent invocations race safely.
752pub fn store_module(key: &CacheKey, artifact: &ModuleArtifact) -> io::Result<()> {
753    if !cache_enabled() {
754        return Ok(());
755    }
756    let Some(dir) = cache_dir() else {
757        return Ok(());
758    };
759    fs::create_dir_all(&dir)?;
760    write_atomic_module(&dir.join(key.module_filename()), key, artifact)
761}
762
763/// Write a module artifact to an explicit path.
764pub fn store_module_at(path: &Path, key: &CacheKey, artifact: &ModuleArtifact) -> io::Result<()> {
765    ensure_parent_dir(path)?;
766    write_atomic_module(path, key, artifact)
767}
768
769/// Result of a [`load_module`] lookup. Carries the precomputed key so
770/// the caller can write it back on a miss without rehashing.
771pub struct ModuleLookupOutcome {
772    pub key: CacheKey,
773    pub artifact: Option<ModuleArtifact>,
774}
775
776/// Path to the adjacent precompiled entry-chunk artifact for
777/// `source_path`. `foo.harn` → `foo.harnbc`.
778pub fn adjacent_cache_path(source_path: &Path) -> Option<PathBuf> {
779    adjacent_path_with_extension(source_path, CACHE_EXTENSION)
780}
781
782/// Path to the adjacent precompiled module-artifact for `source_path`.
783/// `foo.harn` → `foo.harnmod`.
784pub fn adjacent_module_cache_path(source_path: &Path) -> Option<PathBuf> {
785    adjacent_path_with_extension(source_path, MODULE_CACHE_EXTENSION)
786}
787
788fn adjacent_path_with_extension(source_path: &Path, ext: &str) -> Option<PathBuf> {
789    let stem = source_path.file_stem()?;
790    if stem.is_empty() {
791        return None;
792    }
793    let parent = source_path.parent().unwrap_or_else(|| Path::new(""));
794    let mut out = parent.join(stem);
795    out.set_extension(ext);
796    Some(out)
797}
798
799fn ensure_parent_dir(path: &Path) -> io::Result<()> {
800    if let Some(parent) = path.parent() {
801        if !parent.as_os_str().is_empty() {
802            fs::create_dir_all(parent)?;
803        }
804    }
805    Ok(())
806}
807
808/// Permissions for a cached artifact.
809///
810/// These files hold compiled bytecode for whatever source a run loaded, in a
811/// shared per-user cache directory, so they get the same owner-only treatment
812/// as the rest of this user's state rather than whatever the process umask
813/// happens to allow.
814const ARTIFACT_MODE: u32 = 0o600;
815
816fn write_atomic_chunk(
817    target: &Path,
818    key: &CacheKey,
819    chunk: &Chunk,
820    manifest: Option<&ContextManifest>,
821) -> io::Result<()> {
822    let buf = serialize_chunk_artifact_with_manifest(key, chunk, manifest)?;
823    crate::atomic_io::atomic_write_with_mode(target, &buf, ARTIFACT_MODE)
824}
825
826fn write_atomic_module(target: &Path, key: &CacheKey, artifact: &ModuleArtifact) -> io::Result<()> {
827    let buf = serialize_module_artifact(key, artifact)?;
828    crate::atomic_io::atomic_write_with_mode(target, &buf, ARTIFACT_MODE)
829}
830
831/// Serialize an entry-chunk artifact (header + payload) to bytes. The
832/// resulting buffer is byte-identical to the file [`store_at`] would
833/// have written for the same `(key, chunk)`. Use this when packaging
834/// artifacts into a container (e.g. `harn pack`) without going through
835/// the filesystem.
836pub fn serialize_chunk_artifact(key: &CacheKey, chunk: &Chunk) -> io::Result<Vec<u8>> {
837    serialize_chunk_artifact_with_manifest(key, chunk, None)
838}
839
840/// As [`serialize_chunk_artifact`], but records `manifest` so a later lookup
841/// can prove the graph unchanged without walking it.
842///
843/// Callers producing *relocatable* artifacts (`harn pack`, `harn precompile`)
844/// pass `None`: a manifest names absolute paths on the machine that built it,
845/// which say nothing on the machine that runs it. Those artifacts stay on the
846/// walk, which is correct everywhere.
847pub fn serialize_chunk_artifact_with_manifest(
848    key: &CacheKey,
849    chunk: &Chunk,
850    manifest: Option<&ContextManifest>,
851) -> io::Result<Vec<u8>> {
852    let payload = serialize_cache_payload(&EntryPayload {
853        manifest: manifest.cloned(),
854        chunk: chunk.freeze_for_cache(),
855    })?;
856    Ok(encode_artifact(key, KIND_ENTRY_CHUNK, &payload))
857}
858
859/// Serialize a module artifact (header + payload) to bytes. Companion
860/// to [`serialize_chunk_artifact`] for the `.harnmod` family.
861pub fn serialize_module_artifact(key: &CacheKey, artifact: &ModuleArtifact) -> io::Result<Vec<u8>> {
862    let payload = serialize_cache_payload(artifact)?;
863    Ok(encode_artifact(key, KIND_MODULE_ARTIFACT, &payload))
864}
865
866/// Entry-chunk payload. The manifest rides with the chunk so one atomic write
867/// keeps them consistent: a chunk can never be paired with a manifest that
868/// describes a different graph.
869#[derive(serde::Serialize, serde::Deserialize)]
870struct EntryPayload {
871    manifest: Option<ContextManifest>,
872    chunk: CachedChunk,
873}
874
875fn serialize_cache_payload<T: Serialize>(value: &T) -> io::Result<Vec<u8>> {
876    postcard::to_allocvec(value)
877        .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err.to_string()))
878}
879
880fn deserialize_cache_payload<T: DeserializeOwned>(payload: &[u8]) -> Result<T, String> {
881    let (value, remaining) = postcard::take_from_bytes(payload).map_err(|err| err.to_string())?;
882    if remaining.is_empty() {
883        Ok(value)
884    } else {
885        Err("cache payload contains trailing bytes".to_string())
886    }
887}
888
889fn encode_artifact(key: &CacheKey, kind: u8, payload: &[u8]) -> Vec<u8> {
890    encode_artifact_fingerprinted(key, kind, payload, CODEGEN_FINGERPRINT)
891}
892
893/// Inner form of [`encode_artifact`] parameterized on the compiler fingerprint
894/// so tests can write an artifact as if a different build had produced it;
895/// production always passes [`CODEGEN_FINGERPRINT`].
896fn encode_artifact_fingerprinted(
897    key: &CacheKey,
898    kind: u8,
899    payload: &[u8],
900    codegen_fingerprint: &str,
901) -> Vec<u8> {
902    let mut buf: Vec<u8> = Vec::with_capacity(payload.len() + 128);
903    buf.extend_from_slice(MAGIC);
904    buf.extend_from_slice(&SCHEMA_VERSION.to_le_bytes());
905    let version_bytes = key.harn_version.as_bytes();
906    buf.extend_from_slice(&(version_bytes.len() as u32).to_le_bytes());
907    buf.extend_from_slice(version_bytes);
908    let fingerprint_bytes = codegen_fingerprint.as_bytes();
909    buf.extend_from_slice(&(fingerprint_bytes.len() as u32).to_le_bytes());
910    buf.extend_from_slice(fingerprint_bytes);
911    buf.push(key.compiler_tag);
912    buf.push(kind);
913    buf.push(provenance_tag(key.provenance));
914    buf.extend_from_slice(&key.source_hash);
915    buf.extend_from_slice(&key.context_hash);
916    buf.extend_from_slice(payload);
917    buf
918}
919
920/// Stable on-disk byte for an authority.
921///
922/// Written out rather than derived from the enum's discriminant so that
923/// reordering [`ModuleProvenance`]'s variants cannot silently repoint existing
924/// artifacts at a different authority.
925fn provenance_tag(provenance: ModuleProvenance) -> u8 {
926    match provenance {
927        ModuleProvenance::User => 0,
928        ModuleProvenance::PrivilegedWire => 1,
929        ModuleProvenance::TrustedHostDispatch => 2,
930    }
931}
932
933/// Reads `len` bytes and reports whether they equal `expected`.
934///
935/// `len` comes off disk, so it is bounded before it becomes an allocation: a
936/// corrupted or hostile file must not be able to ask for an unbounded read.
937/// A length that cannot match `expected` is rejected without reading at all.
938fn read_length_prefixed_match(file: &mut fs::File, len: usize, expected: &[u8]) -> bool {
939    if len > 256 || len != expected.len() {
940        return false;
941    }
942    let mut buf = vec![0u8; len];
943    file.read_exact(&mut buf).is_ok() && buf == expected
944}
945
946/// Parsed cache header. Read by both the chunk and module loaders so the
947/// header-validation logic stays in one place.
948struct ParsedHeader {
949    kind: u8,
950    context_hash: [u8; 32],
951    payload: Vec<u8>,
952}
953
954/// Read and validate a header.
955///
956/// `expected_context` is `None` for entry chunks, which decide validity from
957/// the artifact's own manifest before they are willing to pay for the
958/// context hash. Every other field is checked the same way for both families.
959fn read_header_if_matches(
960    path: &Path,
961    key: &CacheKey,
962    expected_context: Option<&[u8; 32]>,
963) -> io::Result<Option<ParsedHeader>> {
964    let mut file = match fs::File::open(path) {
965        Ok(f) => f,
966        Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
967        Err(err) => return Err(err),
968    };
969    let mut header = [0u8; 8 + 4 + 4];
970    if file.read_exact(&mut header).is_err() {
971        return Ok(None);
972    }
973    if &header[..8] != MAGIC {
974        return Ok(None);
975    }
976    let schema = u32::from_le_bytes(header[8..12].try_into().unwrap());
977    if schema != SCHEMA_VERSION {
978        return Ok(None);
979    }
980    let version_len = u32::from_le_bytes(header[12..16].try_into().unwrap()) as usize;
981    if !read_length_prefixed_match(&mut file, version_len, key.harn_version.as_bytes()) {
982        return Ok(None);
983    }
984    // Which build produced this artifact, checkable without computing anything.
985    // The entry fast path proves its graph unchanged from a manifest and never
986    // recomputes the context hash, so a fingerprint carried only inside that
987    // hash would go unexamined and a chunk from a previous build of the same
988    // release would be replayed. See #5610.
989    let mut fingerprint_len_bytes = [0u8; 4];
990    if file.read_exact(&mut fingerprint_len_bytes).is_err() {
991        return Ok(None);
992    }
993    let fingerprint_len = u32::from_le_bytes(fingerprint_len_bytes) as usize;
994    if !read_length_prefixed_match(&mut file, fingerprint_len, CODEGEN_FINGERPRINT.as_bytes()) {
995        return Ok(None);
996    }
997    let mut compiler_kind_provenance = [0u8; 3];
998    if file.read_exact(&mut compiler_kind_provenance).is_err() {
999        return Ok(None);
1000    }
1001    if compiler_kind_provenance[0] != key.compiler_tag {
1002        return Ok(None);
1003    }
1004    let kind = compiler_kind_provenance[1];
1005    // The authority assertion, and the reason it is a header field rather than
1006    // only a key field. Shared-cache entries are found by a key-derived
1007    // filename, so the key alone separates them. An ADJACENT artifact is found
1008    // by path: `dep.harnmod` beside `dep.harn` is offered to whoever imports
1009    // that file. Without this comparison an ordinary import accepts an
1010    // artifact compiled under a privileged authority, because every other
1011    // header field is identical for the same source and context.
1012    if compiler_kind_provenance[2] != provenance_tag(key.provenance) {
1013        return Ok(None);
1014    }
1015    let mut hashes = [0u8; 64];
1016    if file.read_exact(&mut hashes).is_err() {
1017        return Ok(None);
1018    }
1019    if hashes[..32] != key.source_hash {
1020        return Ok(None);
1021    }
1022    let mut context_hash = [0u8; 32];
1023    context_hash.copy_from_slice(&hashes[32..]);
1024    if expected_context.is_some_and(|expected| *expected != context_hash) {
1025        return Ok(None);
1026    }
1027    let mut payload = Vec::new();
1028    if file.read_to_end(&mut payload).is_err() {
1029        return Ok(None);
1030    }
1031    Ok(Some(ParsedHeader {
1032        kind,
1033        context_hash,
1034        payload,
1035    }))
1036}
1037
1038/// A candidate entry artifact whose header matches everything except the
1039/// context hash, which the caller decides about.
1040struct CandidateEntry {
1041    context_hash: [u8; 32],
1042    manifest: Option<ContextManifest>,
1043    chunk: Chunk,
1044}
1045
1046fn read_entry_candidate(path: &Path, key: &CacheKey) -> io::Result<Option<CandidateEntry>> {
1047    let Some(header) = read_header_if_matches(path, key, None)? else {
1048        return Ok(None);
1049    };
1050    if header.kind != KIND_ENTRY_CHUNK {
1051        return Ok(None);
1052    }
1053    let payload: EntryPayload = match deserialize_cache_payload(&header.payload) {
1054        Ok(p) => p,
1055        Err(_) => return Ok(None),
1056    };
1057    Ok(Some(CandidateEntry {
1058        context_hash: header.context_hash,
1059        manifest: payload.manifest,
1060        chunk: Chunk::from_cached(payload.chunk),
1061    }))
1062}
1063
1064fn read_module_if_matches(
1065    path: &Path,
1066    key: &CacheKey,
1067    source_path: &Path,
1068) -> io::Result<Option<ModuleArtifact>> {
1069    let Some(header) = read_header_if_matches(path, key, Some(&key.context_hash))? else {
1070        return Ok(None);
1071    };
1072    if header.kind != KIND_MODULE_ARTIFACT {
1073        return Ok(None);
1074    }
1075    match deserialize_cache_payload::<ModuleArtifact>(&header.payload) {
1076        Ok(mut artifact) => {
1077            artifact.bind_source_file(source_path);
1078            Ok(Some(artifact))
1079        }
1080        Err(_) => Ok(None),
1081    }
1082}
1083
1084/// Compact representation of [`CompilerOptions`] for the cache header.
1085/// Independent flags get distinct bits so adding a new flag never
1086/// silently changes existing keys when an old binary reads a new
1087/// artifact — the header check will fail-closed before we get there
1088/// anyway, but mapping to bits also keeps the tag a stable function
1089/// of the option set.
1090fn compiler_options_tag(options: CompilerOptions) -> u8 {
1091    let mut tag: u8 = 0;
1092    if options.optimizations_enabled() {
1093        tag |= 0b0000_0001;
1094    }
1095    if options.legacy_ambient_capabilities() {
1096        tag |= 0b0000_0010;
1097    }
1098    tag
1099}
1100
1101fn sha256(bytes: &[u8]) -> [u8; 32] {
1102    let mut hasher = Sha256::new();
1103    hasher.update(bytes);
1104    hasher.finalize().into()
1105}
1106
1107fn hex(bytes: &[u8]) -> String {
1108    let mut out = String::with_capacity(bytes.len() * 2);
1109    for byte in bytes {
1110        out.push_str(&format!("{byte:02x}"));
1111    }
1112    out
1113}
1114
1115/// Stable digest over every embedded stdlib source. Folded into the
1116/// user-file cache key so that bumping a stdlib module (changing its
1117/// embedded `.harn` content) invalidates cached user bytecode that may
1118/// reference stale function-pool layouts from a prior stdlib snapshot.
1119/// `HARN_VERSION` already busts the cache across release bumps; this
1120/// closes the same gap for within-version stdlib edits (a frequent
1121/// pattern during local development).
1122///
1123/// Cached in a `OnceLock` because `STDLIB_SOURCES` is a static `const`
1124/// slice — the digest is identical for the lifetime of the process.
1125fn embedded_stdlib_digest() -> &'static [u8; 32] {
1126    use std::sync::OnceLock;
1127    static DIGEST: OnceLock<[u8; 32]> = OnceLock::new();
1128    DIGEST.get_or_init(|| {
1129        let mut entries: Vec<(&'static str, &'static str)> = harn_stdlib::STDLIB_SOURCES
1130            .iter()
1131            .map(|src| (src.module, src.source))
1132            .collect();
1133        entries.sort_by(|a, b| a.0.cmp(b.0));
1134        let mut hasher = Sha256::new();
1135        for (module, source) in entries {
1136            hasher.update(module.as_bytes());
1137            hasher.update(b"\0");
1138            hasher.update(source.as_bytes());
1139            hasher.update(b"\0");
1140        }
1141        hasher.finalize().into()
1142    })
1143}
1144
1145/// Stable compilation context for a source-local module artifact.
1146///
1147/// Module compilation does not embed user dependency bodies. Artifact-local
1148/// compiler and stdlib identity plus the imported interface belong in the key;
1149/// the source path does not, because it is load context and is rebound after
1150/// deserialization.
1151fn module_compilation_context_hash(compilation_context: &ModuleCompilationContext) -> [u8; 32] {
1152    module_compilation_context_hash_fingerprinted(CODEGEN_FINGERPRINT, compilation_context.digest())
1153}
1154
1155/// Stands in for the imported-interface digest of an embedded stdlib module.
1156///
1157/// See [`CacheKey::from_embedded_stdlib_module_content_hash`] for why these
1158/// modules need no interface digest. It is a fixed domain separator rather
1159/// than a real digest so a stdlib artifact can never land on the identity of a
1160/// user module that shares its bytes: [`ModuleCompilationContext::digest`] is a
1161/// SHA-256 over the interface, so reproducing this value would take a preimage.
1162const EMBEDDED_STDLIB_INTERFACE_DIGEST: [u8; 32] = *b"harn.embedded-stdlib.interface\0\0";
1163
1164fn module_compilation_context_hash_fingerprinted(
1165    codegen_fingerprint: &str,
1166    imported_interface_digest: [u8; 32],
1167) -> [u8; 32] {
1168    let mut hasher = Sha256::new();
1169    hasher.update(b"module-artifact-source-local-v4\0");
1170    hasher.update(b"stdlib-digest\0");
1171    hasher.update(embedded_stdlib_digest());
1172    hasher.update(b"\0codegen-fingerprint\0");
1173    hasher.update(codegen_fingerprint.as_bytes());
1174    hasher.update(b"\0imported-interface\0");
1175    hasher.update(imported_interface_digest);
1176    hasher.finalize().into()
1177}
1178
1179// Test seam: how many times the import-graph walk has actually run on this
1180// thread.
1181//
1182// The manifest fast path and the walk agree on results *by construction* —
1183// both trust the same `(len, mtime_ns)` identity — so no observable output can
1184// tell them apart. Only the work done differs, and this counts it. Thread-local
1185// so tests running in parallel cannot perturb each other.
1186#[cfg(test)]
1187thread_local! {
1188    pub(crate) static WALKS_PERFORMED: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
1189}
1190
1191/// Walk the user-import graph rooted at `source_path` and produce a
1192/// stable hash of every transitively-reachable file. The hash is
1193/// order-independent: each visited file is keyed by canonical path and
1194/// emitted in sorted order, so reordering imports inside a file does
1195/// not invalidate the cache while changing any file's content does.
1196///
1197/// Embedded stdlib content is folded into the hash too — `collect_user_imports`
1198/// deliberately skips `std/*` paths (they resolve to in-binary sources, not
1199/// disk files), so without this fold a stdlib edit between development
1200/// builds would leave user-file caches pinned to a stale stdlib snapshot.
1201fn hash_transitive_user_imports(source_path: &Path, source: &str) -> [u8; 32] {
1202    hash_transitive_user_imports_fingerprinted(source_path, source, CODEGEN_FINGERPRINT).0
1203}
1204
1205/// Root-relative companion to [`hash_transitive_user_imports`]. Only closed,
1206/// packaged source trees use this identity; shared host caches stay canonical.
1207fn hash_relocatable_user_imports(source_path: &Path, source: &str) -> [u8; 32] {
1208    walk_import_graph_fingerprinted(source_path, source, CODEGEN_FINGERPRINT).relocatable
1209}
1210
1211/// As [`hash_transitive_user_imports`], but also returns the manifest that
1212/// proves the walk's observations, for callers that will persist it.
1213#[cfg(test)]
1214fn hash_transitive_user_imports_with_manifest(
1215    source_path: &Path,
1216    source: &str,
1217) -> ([u8; 32], Option<ContextManifest>) {
1218    hash_transitive_user_imports_fingerprinted(source_path, source, CODEGEN_FINGERPRINT)
1219}
1220
1221/// Inner form of [`hash_transitive_user_imports`] parameterized on the compiler
1222/// fingerprint so tests can vary it; production always passes
1223/// [`CODEGEN_FINGERPRINT`].
1224fn hash_transitive_user_imports_fingerprinted(
1225    source_path: &Path,
1226    source: &str,
1227    codegen_fingerprint: &str,
1228) -> ([u8; 32], Option<ContextManifest>) {
1229    let result = walk_import_graph_fingerprinted(source_path, source, codegen_fingerprint);
1230    (result.canonical, result.manifest)
1231}
1232
1233struct GraphHashes {
1234    canonical: [u8; 32],
1235    relocatable: [u8; 32],
1236    manifest: Option<ContextManifest>,
1237}
1238
1239fn walk_import_graph_fingerprinted(
1240    source_path: &Path,
1241    source: &str,
1242    codegen_fingerprint: &str,
1243) -> GraphHashes {
1244    #[cfg(test)]
1245    WALKS_PERFORMED.with(|c| c.set(c.get() + 1));
1246
1247    let mut visited: std::collections::BTreeMap<PathBuf, ImportNode> =
1248        std::collections::BTreeMap::new();
1249    let entry = ModuleSource::from_text(source);
1250    let mut frontier: Vec<(PathBuf, Arc<str>)> = entry
1251        .imports()
1252        .iter()
1253        .map(|import| (source_path.to_path_buf(), Arc::clone(import)))
1254        .collect();
1255    // Built alongside the hash: the same observations, in a form a later
1256    // process can re-check with stats instead of repeating this walk. Anchored
1257    // at the entry, because that is what the observations are relative to, and
1258    // stamped before the first file is stat'ed, so entries observed inside a
1259    // timestamp tick are recognizable as such later. Set to `None` the moment
1260    // the graph contains something stats cannot describe.
1261    let mut manifest = Some(ContextManifest::begin(module_source::canonical_identity(
1262        source_path,
1263    )));
1264
1265    while let Some((anchor, import)) = frontier.pop() {
1266        let Some(resolved) = harn_modules::resolve_import_path(&anchor, &import) else {
1267            // Unresolved imports get a sentinel keyed by their resolution
1268            // anchor so that dropping a real file under that anchor later
1269            // produces a different key.
1270            let sentinel = anchor.join(format!("__unresolved__/{import}"));
1271            if let std::collections::btree_map::Entry::Vacant(slot) = visited.entry(sentinel) {
1272                slot.insert(ImportNode::Unresolved {
1273                    import: Arc::clone(&import),
1274                });
1275                if let Some(m) = manifest.as_mut() {
1276                    m.unresolved.push(ManifestUnresolved {
1277                        anchor: anchor.clone(),
1278                        import: import.to_string(),
1279                    });
1280                }
1281            }
1282            continue;
1283        };
1284        let canonical = module_source::canonical_identity(&resolved);
1285        if visited.contains_key(&canonical) {
1286            continue;
1287        }
1288        // The read and the import scan are owned by [`module_source`], which
1289        // memoizes both by the file's stat identity. The same handful of core
1290        // library modules (`lib/host/*`, `lib/runtime/*`, ...) sit on the import
1291        // graph of nearly every module, and the VM's module loader reads every
1292        // one of these files again — so without a shared owner a single spawn
1293        // re-reads and re-scans the same sources many times over.
1294        match module_source::read(&resolved) {
1295            Ok(module) => {
1296                visited.insert(
1297                    canonical.clone(),
1298                    ImportNode::Resolved {
1299                        content: Arc::clone(module.text()),
1300                    },
1301                );
1302                match ManifestFile::observe(&canonical, &module) {
1303                    Some(file) => {
1304                        if let Some(m) = manifest.as_mut() {
1305                            m.files.push(file);
1306                        }
1307                    }
1308                    // Read succeeded but the file cannot be stat'ed now. Rather
1309                    // than record a fact we could not re-check, drop the
1310                    // manifest and leave this graph on the walk.
1311                    None => manifest = None,
1312                }
1313                for nested_import in module.imports() {
1314                    frontier.push((resolved.clone(), Arc::clone(nested_import)));
1315                }
1316            }
1317            Err(error) => {
1318                let unreadable_path = canonical.clone();
1319                visited.insert(
1320                    canonical,
1321                    ImportNode::IoError {
1322                        kind: error.kind().to_string(),
1323                    },
1324                );
1325                // Real trees contain these — an `import "./types"` where
1326                // `types/` is a directory resolves, then fails to read. Dropping
1327                // the manifest for them would silently disable the fast path on
1328                // exactly the graphs it exists for.
1329                if let Some(m) = manifest.as_mut() {
1330                    m.unreadable.push(ManifestUnreadable {
1331                        path: unreadable_path,
1332                        kind: error.kind().to_string(),
1333                    });
1334                }
1335            }
1336        }
1337    }
1338
1339    // The entry manifest is also the authority for warm module linking. Build
1340    // the imported-interface projection once for the complete graph and carry
1341    // it beside each source digest; content alone has not been a complete
1342    // module compilation identity since imported callable lowering shipped.
1343    if let Some(recorded) = manifest.as_ref() {
1344        let graph = harn_modules::build_with_source(source_path, source);
1345        let contexts = recorded
1346            .files
1347            .iter()
1348            .map(|file| match visited.get(&file.path) {
1349                Some(ImportNode::Resolved { content }) => {
1350                    ModuleCompilationContext::for_source_in_graph(
1351                        &graph,
1352                        &file.path,
1353                        content.as_ref(),
1354                    )
1355                    .ok()
1356                }
1357                _ => None,
1358            })
1359            .collect::<Option<Vec<_>>>();
1360        if let Some(contexts) = contexts {
1361            for (file, context) in manifest
1362                .as_mut()
1363                .expect("the manifest was just borrowed")
1364                .files
1365                .iter_mut()
1366                .zip(contexts)
1367            {
1368                file.compilation_context = context;
1369            }
1370        } else {
1371            // An invalid module cannot produce a trustworthy warm module key.
1372            // Drop only the optimization; the canonical graph hash still owns
1373            // entry compilation's diagnostic path.
1374            manifest = None;
1375        }
1376    }
1377
1378    let mut canonical_hasher = Sha256::new();
1379    seed_entry_context_hasher(&mut canonical_hasher, codegen_fingerprint);
1380    let mut relocatable_hasher = Sha256::new();
1381    relocatable_hasher.update(b"relocatable-entry-graph-v1\0");
1382    seed_entry_context_hasher(&mut relocatable_hasher, codegen_fingerprint);
1383
1384    let entry_identity = module_source::canonical_identity(source_path);
1385    let entry_dir = entry_identity.parent().unwrap_or(Path::new(""));
1386    let mut relocatable_nodes = Vec::with_capacity(visited.len());
1387    for (path, node) in &visited {
1388        canonical_hasher.update(path.to_string_lossy().as_bytes());
1389        canonical_hasher.update(b"\0");
1390        hash_import_node(&mut canonical_hasher, node);
1391        canonical_hasher.update(b"\0");
1392
1393        let Some(label) = relative_path_label(entry_dir, path) else {
1394            // A dependency on another filesystem root cannot be moved as one
1395            // closed tree. Preserve fail-closed behavior by retaining its
1396            // canonical identity in the packaged key.
1397            relocatable_nodes.push((path.to_string_lossy().replace('\\', "/"), node));
1398            continue;
1399        };
1400        relocatable_nodes.push((label, node));
1401    }
1402    relocatable_nodes.sort_by(|left, right| left.0.cmp(&right.0));
1403    for (path, node) in relocatable_nodes {
1404        relocatable_hasher.update(path.as_bytes());
1405        relocatable_hasher.update(b"\0");
1406        hash_import_node(&mut relocatable_hasher, node);
1407        relocatable_hasher.update(b"\0");
1408    }
1409
1410    // Sorted so one graph always serializes to one byte sequence, whatever
1411    // order the frontier happened to pop.
1412    if let Some(m) = manifest.as_mut() {
1413        m.files.sort_by(|a, b| a.path.cmp(&b.path));
1414        m.unresolved
1415            .sort_by(|a, b| (&a.anchor, &a.import).cmp(&(&b.anchor, &b.import)));
1416        m.unreadable.sort_by(|a, b| a.path.cmp(&b.path));
1417    }
1418    GraphHashes {
1419        canonical: canonical_hasher.finalize().into(),
1420        relocatable: relocatable_hasher.finalize().into(),
1421        manifest,
1422    }
1423}
1424
1425fn seed_entry_context_hasher(hasher: &mut Sha256, codegen_fingerprint: &str) {
1426    hasher.update(b"stdlib-digest\0");
1427    hasher.update(embedded_stdlib_digest());
1428    hasher.update(b"\0");
1429    // Fold in the compiler's code-generation fingerprint so a compiler change
1430    // that alters emitted bytecode for unchanged source busts stale cache
1431    // entries within a single version — the gap that masked the #2610 fix until
1432    // the cache was cleared by hand. See `build.rs` and `CODEGEN_FINGERPRINT`.
1433    hasher.update(b"codegen-fingerprint\0");
1434    hasher.update(codegen_fingerprint.as_bytes());
1435    hasher.update(b"\0");
1436}
1437
1438fn hash_import_node(hasher: &mut Sha256, node: &ImportNode) {
1439    match node {
1440        ImportNode::Resolved { content } => {
1441            hasher.update(b"resolved\0");
1442            hasher.update(content.as_bytes());
1443        }
1444        ImportNode::Unresolved { import } => {
1445            hasher.update(b"unresolved\0");
1446            hasher.update(import.as_bytes());
1447        }
1448        ImportNode::IoError { kind } => {
1449            hasher.update(b"ioerror\0");
1450            hasher.update(kind.as_bytes());
1451        }
1452    }
1453}
1454
1455/// Render `target` relative to `base` with `/` separators. Both inputs are
1456/// canonical identities in production, so differing roots are the only case
1457/// that cannot produce a relocatable label.
1458fn relative_path_label(base: &Path, target: &Path) -> Option<String> {
1459    let base_components = base.components().collect::<Vec<_>>();
1460    let target_components = target.components().collect::<Vec<_>>();
1461    let common = base_components
1462        .iter()
1463        .zip(&target_components)
1464        .take_while(|(left, right)| left == right)
1465        .count();
1466    if common == 0 && (base.is_absolute() || target.is_absolute()) {
1467        return None;
1468    }
1469
1470    let mut parts = Vec::new();
1471    for component in &base_components[common..] {
1472        if matches!(component, std::path::Component::Normal(_)) {
1473            parts.push("..".to_string());
1474        }
1475    }
1476    for component in &target_components[common..] {
1477        match component {
1478            std::path::Component::Normal(part) => parts.push(part.to_string_lossy().into_owned()),
1479            std::path::Component::ParentDir => parts.push("..".to_string()),
1480            std::path::Component::CurDir => {}
1481            std::path::Component::RootDir | std::path::Component::Prefix(_) => return None,
1482        }
1483    }
1484    Some(if parts.is_empty() {
1485        ".".to_string()
1486    } else {
1487        parts.join("/")
1488    })
1489}
1490
1491enum ImportNode {
1492    Resolved { content: Arc<str> },
1493    Unresolved { import: Arc<str> },
1494    IoError { kind: String },
1495}
1496
1497#[cfg(test)]
1498#[path = "bytecode_cache_tests.rs"]
1499mod tests;