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;