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