Skip to main content

harn_vm/
bytecode_cache.rs

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