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