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