use std::fs;
use std::io::{self, Read as _};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use serde::{de::DeserializeOwned, Serialize};
use sha2::{Digest, Sha256};
use crate::chunk::{CachedChunk, Chunk};
use crate::compiler::CompilerOptions;
use crate::context_manifest::{
ContextManifest, GraphLinkTable, ManifestCheck, ManifestFile, ManifestUnreadable,
ManifestUnresolved,
};
use crate::module_artifact::ModuleArtifact;
use crate::module_source::{self, ModuleSource};
pub const MAGIC: &[u8; 8] = b"HARNBC\0\0";
pub const SCHEMA_VERSION: u32 = 9;
pub const HARN_VERSION: &str = env!("CARGO_PKG_VERSION");
pub const CODEGEN_FINGERPRINT: &str = env!("HARN_CODEGEN_FINGERPRINT");
pub const CACHE_EXTENSION: &str = "harnbc";
pub const MODULE_CACHE_EXTENSION: &str = "harnmod";
const KIND_ENTRY_CHUNK: u8 = 1;
const KIND_MODULE_ARTIFACT: u8 = 2;
pub const CACHE_DIR_ENV: &str = "HARN_CACHE_DIR";
pub const CACHE_ENABLED_ENV: &str = "HARN_BYTECODE_CACHE";
pub struct LookupOutcome {
pub key: CacheKey,
pub chunk: Option<Chunk>,
pub manifest: Option<ContextManifest>,
pub link_table: Option<Arc<GraphLinkTable>>,
}
impl LookupOutcome {
pub fn store(&self, chunk: &Chunk) -> io::Result<()> {
store(&self.key, chunk, self.manifest.as_ref())
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CacheKey {
pub source_hash: [u8; 32],
pub context_hash: [u8; 32],
pub harn_version: &'static str,
pub compiler_tag: u8,
}
impl CacheKey {
pub fn from_source(source_path: &Path, source: &str) -> Self {
let source_hash = sha256(source.as_bytes());
let context_hash = hash_transitive_user_imports(source_path, source);
Self {
source_hash,
context_hash,
harn_version: HARN_VERSION,
compiler_tag: compiler_options_tag(CompilerOptions::from_env()),
}
}
pub fn from_module_source(source: &ModuleSource) -> Self {
Self::from_module_content_hash(source.sha256())
}
pub fn from_module_content_hash(content_hash: [u8; 32]) -> Self {
Self {
source_hash: content_hash,
context_hash: module_compilation_context_hash(),
harn_version: HARN_VERSION,
compiler_tag: compiler_options_tag(CompilerOptions::from_env()),
}
}
pub fn filename(&self) -> String {
format!("{}.{}", hex(&self.source_hash), CACHE_EXTENSION)
}
pub fn module_filename(&self) -> String {
let mut hasher = Sha256::new();
hasher.update(self.source_hash);
hasher.update(self.context_hash);
hasher.update(self.harn_version.as_bytes());
hasher.update([self.compiler_tag]);
let identity: [u8; 32] = hasher.finalize().into();
format!("{}.{}", hex(&identity), MODULE_CACHE_EXTENSION)
}
}
pub fn cache_dir() -> PathBuf {
if let Some(custom) = std::env::var_os(CACHE_DIR_ENV) {
return PathBuf::from(custom);
}
if let Some(xdg) = std::env::var_os("XDG_CACHE_HOME") {
let xdg = PathBuf::from(xdg);
if !xdg.as_os_str().is_empty() {
return xdg.join("harn").join("bytecode");
}
}
if let Some(home) = crate::user_dirs::home_dir() {
return home.join(".cache").join("harn").join("bytecode");
}
PathBuf::from(".harn-cache").join("bytecode")
}
pub fn packs_cache_dir() -> PathBuf {
if let Some(custom) = std::env::var_os(CACHE_DIR_ENV) {
return PathBuf::from(custom).join("packs");
}
if let Some(xdg) = std::env::var_os("XDG_CACHE_HOME") {
let xdg = PathBuf::from(xdg);
if !xdg.as_os_str().is_empty() {
return xdg.join("harn").join("packs");
}
}
if let Some(home) = crate::user_dirs::home_dir() {
return home.join(".cache").join("harn").join("packs");
}
PathBuf::from(".harn-cache").join("packs")
}
pub fn cache_enabled() -> bool {
match std::env::var(CACHE_ENABLED_ENV).ok().as_deref() {
Some(value) => !matches!(
value.to_ascii_lowercase().as_str(),
"0" | "false" | "no" | "off"
),
None => true,
}
}
pub fn load(source_path: &Path, source: &str) -> LookupOutcome {
let mut key = CacheKey {
source_hash: sha256(source.as_bytes()),
context_hash: [0u8; 32],
harn_version: HARN_VERSION,
compiler_tag: compiler_options_tag(CompilerOptions::from_env()),
};
let mut walk = GraphWalk::new(source_path, source);
if !cache_enabled() {
let (context_hash, manifest) = walk.finish();
key.context_hash = context_hash;
return LookupOutcome {
key,
chunk: None,
manifest,
link_table: None,
};
}
let mut candidates: Vec<PathBuf> = Vec::with_capacity(2);
if let Some(adjacent) = adjacent_cache_path(source_path) {
candidates.push(adjacent);
}
candidates.push(cache_dir().join(key.filename()));
let entry = module_source::canonical_identity(source_path);
for path in candidates {
let Ok(Some(candidate)) = read_entry_candidate(&path, &key) else {
continue;
};
match candidate
.manifest
.as_ref()
.map(|manifest| manifest.check(&entry))
{
Some(ManifestCheck::Valid) => {
key.context_hash = candidate.context_hash;
return LookupOutcome {
key,
chunk: Some(candidate.chunk),
link_table: candidate.manifest.as_ref().map(link_table_for),
manifest: candidate.manifest,
};
}
Some(ManifestCheck::ValidAfterRecheck { refreshed }) => {
key.context_hash = candidate.context_hash;
let _ = write_atomic_chunk(&path, &key, &candidate.chunk, Some(&refreshed));
return LookupOutcome {
key,
chunk: Some(candidate.chunk),
link_table: Some(link_table_for(&refreshed)),
manifest: Some(refreshed),
};
}
Some(ManifestCheck::Stale) | None => {}
}
if walk.context_hash() != candidate.context_hash {
continue;
}
key.context_hash = candidate.context_hash;
let manifest = walk.manifest().cloned();
let _ = write_atomic_chunk(&path, &key, &candidate.chunk, manifest.as_ref());
return LookupOutcome {
key,
chunk: Some(candidate.chunk),
manifest,
link_table: None,
};
}
let (context_hash, manifest) = walk.finish();
key.context_hash = context_hash;
LookupOutcome {
key,
chunk: None,
manifest,
link_table: None,
}
}
fn link_table_for(manifest: &ContextManifest) -> Arc<GraphLinkTable> {
Arc::new(GraphLinkTable::from_validated(manifest))
}
struct GraphWalk<'a> {
source_path: &'a Path,
source: &'a str,
result: Option<([u8; 32], Option<ContextManifest>)>,
}
impl<'a> GraphWalk<'a> {
fn new(source_path: &'a Path, source: &'a str) -> Self {
Self {
source_path,
source,
result: None,
}
}
fn run(&mut self) -> &([u8; 32], Option<ContextManifest>) {
self.result.get_or_insert_with(|| {
hash_transitive_user_imports_with_manifest(self.source_path, self.source)
})
}
fn context_hash(&mut self) -> [u8; 32] {
self.run().0
}
fn manifest(&mut self) -> Option<&ContextManifest> {
self.run().1.as_ref()
}
fn finish(mut self) -> ([u8; 32], Option<ContextManifest>) {
self.run();
self.result.expect("the walk was just run")
}
}
pub fn store(key: &CacheKey, chunk: &Chunk, manifest: Option<&ContextManifest>) -> io::Result<()> {
if !cache_enabled() {
return Ok(());
}
let dir = cache_dir();
fs::create_dir_all(&dir)?;
write_atomic_chunk(&dir.join(key.filename()), key, chunk, manifest)
}
pub fn store_at(path: &Path, key: &CacheKey, chunk: &Chunk) -> io::Result<()> {
ensure_parent_dir(path)?;
write_atomic_chunk(path, key, chunk, None)
}
pub fn load_module(source_path: &Path, source: &ModuleSource) -> ModuleLookupOutcome {
load_module_for_key(source_path, CacheKey::from_module_source(source))
}
pub fn load_module_for_key(source_path: &Path, key: CacheKey) -> ModuleLookupOutcome {
if !cache_enabled() {
return ModuleLookupOutcome {
key,
artifact: None,
};
}
let mut candidates: Vec<PathBuf> = Vec::with_capacity(2);
if let Some(adjacent) = adjacent_module_cache_path(source_path) {
candidates.push(adjacent);
}
candidates.push(cache_dir().join(key.module_filename()));
for path in candidates {
match read_module_if_matches(&path, &key, source_path) {
Ok(Some(artifact)) => {
return ModuleLookupOutcome {
key,
artifact: Some(artifact),
}
}
Ok(None) => continue,
Err(_) => continue,
}
}
ModuleLookupOutcome {
key,
artifact: None,
}
}
pub fn store_module(key: &CacheKey, artifact: &ModuleArtifact) -> io::Result<()> {
if !cache_enabled() {
return Ok(());
}
let dir = cache_dir();
fs::create_dir_all(&dir)?;
write_atomic_module(&dir.join(key.module_filename()), key, artifact)
}
pub fn store_module_at(path: &Path, key: &CacheKey, artifact: &ModuleArtifact) -> io::Result<()> {
ensure_parent_dir(path)?;
write_atomic_module(path, key, artifact)
}
pub struct ModuleLookupOutcome {
pub key: CacheKey,
pub artifact: Option<ModuleArtifact>,
}
pub fn adjacent_cache_path(source_path: &Path) -> Option<PathBuf> {
adjacent_path_with_extension(source_path, CACHE_EXTENSION)
}
pub fn adjacent_module_cache_path(source_path: &Path) -> Option<PathBuf> {
adjacent_path_with_extension(source_path, MODULE_CACHE_EXTENSION)
}
fn adjacent_path_with_extension(source_path: &Path, ext: &str) -> Option<PathBuf> {
let stem = source_path.file_stem()?;
if stem.is_empty() {
return None;
}
let parent = source_path.parent().unwrap_or_else(|| Path::new(""));
let mut out = parent.join(stem);
out.set_extension(ext);
Some(out)
}
fn ensure_parent_dir(path: &Path) -> io::Result<()> {
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
fs::create_dir_all(parent)?;
}
}
Ok(())
}
fn write_atomic_chunk(
target: &Path,
key: &CacheKey,
chunk: &Chunk,
manifest: Option<&ContextManifest>,
) -> io::Result<()> {
let buf = serialize_chunk_artifact_with_manifest(key, chunk, manifest)?;
crate::atomic_io::atomic_write(target, &buf)
}
fn write_atomic_module(target: &Path, key: &CacheKey, artifact: &ModuleArtifact) -> io::Result<()> {
let buf = serialize_module_artifact(key, artifact)?;
crate::atomic_io::atomic_write(target, &buf)
}
pub fn serialize_chunk_artifact(key: &CacheKey, chunk: &Chunk) -> io::Result<Vec<u8>> {
serialize_chunk_artifact_with_manifest(key, chunk, None)
}
pub fn serialize_chunk_artifact_with_manifest(
key: &CacheKey,
chunk: &Chunk,
manifest: Option<&ContextManifest>,
) -> io::Result<Vec<u8>> {
let payload = serialize_cache_payload(&EntryPayload {
manifest: manifest.cloned(),
chunk: chunk.freeze_for_cache(),
})?;
Ok(encode_artifact(key, KIND_ENTRY_CHUNK, &payload))
}
pub fn serialize_module_artifact(key: &CacheKey, artifact: &ModuleArtifact) -> io::Result<Vec<u8>> {
let payload = serialize_cache_payload(artifact)?;
Ok(encode_artifact(key, KIND_MODULE_ARTIFACT, &payload))
}
#[derive(serde::Serialize, serde::Deserialize)]
struct EntryPayload {
manifest: Option<ContextManifest>,
chunk: CachedChunk,
}
fn serialize_cache_payload<T: Serialize>(value: &T) -> io::Result<Vec<u8>> {
postcard::to_allocvec(value)
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err.to_string()))
}
fn deserialize_cache_payload<T: DeserializeOwned>(payload: &[u8]) -> Result<T, String> {
let (value, remaining) = postcard::take_from_bytes(payload).map_err(|err| err.to_string())?;
if remaining.is_empty() {
Ok(value)
} else {
Err("cache payload contains trailing bytes".to_string())
}
}
fn encode_artifact(key: &CacheKey, kind: u8, payload: &[u8]) -> Vec<u8> {
encode_artifact_fingerprinted(key, kind, payload, CODEGEN_FINGERPRINT)
}
fn encode_artifact_fingerprinted(
key: &CacheKey,
kind: u8,
payload: &[u8],
codegen_fingerprint: &str,
) -> Vec<u8> {
let mut buf: Vec<u8> = Vec::with_capacity(payload.len() + 128);
buf.extend_from_slice(MAGIC);
buf.extend_from_slice(&SCHEMA_VERSION.to_le_bytes());
let version_bytes = HARN_VERSION.as_bytes();
buf.extend_from_slice(&(version_bytes.len() as u32).to_le_bytes());
buf.extend_from_slice(version_bytes);
let fingerprint_bytes = codegen_fingerprint.as_bytes();
buf.extend_from_slice(&(fingerprint_bytes.len() as u32).to_le_bytes());
buf.extend_from_slice(fingerprint_bytes);
buf.push(key.compiler_tag);
buf.push(kind);
buf.extend_from_slice(&key.source_hash);
buf.extend_from_slice(&key.context_hash);
buf.extend_from_slice(payload);
buf
}
fn read_length_prefixed_match(file: &mut fs::File, len: usize, expected: &[u8]) -> bool {
if len > 256 || len != expected.len() {
return false;
}
let mut buf = vec![0u8; len];
file.read_exact(&mut buf).is_ok() && buf == expected
}
struct ParsedHeader {
kind: u8,
context_hash: [u8; 32],
payload: Vec<u8>,
}
fn read_header_if_matches(
path: &Path,
key: &CacheKey,
expected_context: Option<&[u8; 32]>,
) -> io::Result<Option<ParsedHeader>> {
let mut file = match fs::File::open(path) {
Ok(f) => f,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(err) => return Err(err),
};
let mut header = [0u8; 8 + 4 + 4];
if file.read_exact(&mut header).is_err() {
return Ok(None);
}
if &header[..8] != MAGIC {
return Ok(None);
}
let schema = u32::from_le_bytes(header[8..12].try_into().unwrap());
if schema != SCHEMA_VERSION {
return Ok(None);
}
let version_len = u32::from_le_bytes(header[12..16].try_into().unwrap()) as usize;
if !read_length_prefixed_match(&mut file, version_len, key.harn_version.as_bytes()) {
return Ok(None);
}
let mut fingerprint_len_bytes = [0u8; 4];
if file.read_exact(&mut fingerprint_len_bytes).is_err() {
return Ok(None);
}
let fingerprint_len = u32::from_le_bytes(fingerprint_len_bytes) as usize;
if !read_length_prefixed_match(&mut file, fingerprint_len, CODEGEN_FINGERPRINT.as_bytes()) {
return Ok(None);
}
let mut compiler_and_kind = [0u8; 2];
if file.read_exact(&mut compiler_and_kind).is_err() {
return Ok(None);
}
if compiler_and_kind[0] != key.compiler_tag {
return Ok(None);
}
let kind = compiler_and_kind[1];
let mut hashes = [0u8; 64];
if file.read_exact(&mut hashes).is_err() {
return Ok(None);
}
if hashes[..32] != key.source_hash {
return Ok(None);
}
let mut context_hash = [0u8; 32];
context_hash.copy_from_slice(&hashes[32..]);
if expected_context.is_some_and(|expected| *expected != context_hash) {
return Ok(None);
}
let mut payload = Vec::new();
if file.read_to_end(&mut payload).is_err() {
return Ok(None);
}
Ok(Some(ParsedHeader {
kind,
context_hash,
payload,
}))
}
struct CandidateEntry {
context_hash: [u8; 32],
manifest: Option<ContextManifest>,
chunk: Chunk,
}
fn read_entry_candidate(path: &Path, key: &CacheKey) -> io::Result<Option<CandidateEntry>> {
let Some(header) = read_header_if_matches(path, key, None)? else {
return Ok(None);
};
if header.kind != KIND_ENTRY_CHUNK {
return Ok(None);
}
let payload: EntryPayload = match deserialize_cache_payload(&header.payload) {
Ok(p) => p,
Err(_) => return Ok(None),
};
Ok(Some(CandidateEntry {
context_hash: header.context_hash,
manifest: payload.manifest,
chunk: Chunk::from_cached(payload.chunk),
}))
}
fn read_module_if_matches(
path: &Path,
key: &CacheKey,
source_path: &Path,
) -> io::Result<Option<ModuleArtifact>> {
let Some(header) = read_header_if_matches(path, key, Some(&key.context_hash))? else {
return Ok(None);
};
if header.kind != KIND_MODULE_ARTIFACT {
return Ok(None);
}
match deserialize_cache_payload::<ModuleArtifact>(&header.payload) {
Ok(mut artifact) => {
artifact.bind_source_file(source_path);
Ok(Some(artifact))
}
Err(_) => Ok(None),
}
}
fn compiler_options_tag(options: CompilerOptions) -> u8 {
let mut tag: u8 = 0;
if options.optimizations_enabled() {
tag |= 0b0000_0001;
}
tag
}
fn sha256(bytes: &[u8]) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(bytes);
hasher.finalize().into()
}
fn hex(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
out.push_str(&format!("{byte:02x}"));
}
out
}
fn embedded_stdlib_digest() -> &'static [u8; 32] {
use std::sync::OnceLock;
static DIGEST: OnceLock<[u8; 32]> = OnceLock::new();
DIGEST.get_or_init(|| {
let mut entries: Vec<(&'static str, &'static str)> = harn_stdlib::STDLIB_SOURCES
.iter()
.map(|src| (src.module, src.source))
.collect();
entries.sort_by(|a, b| a.0.cmp(b.0));
let mut hasher = Sha256::new();
for (module, source) in entries {
hasher.update(module.as_bytes());
hasher.update(b"\0");
hasher.update(source.as_bytes());
hasher.update(b"\0");
}
hasher.finalize().into()
})
}
fn module_compilation_context_hash() -> [u8; 32] {
module_compilation_context_hash_fingerprinted(CODEGEN_FINGERPRINT)
}
fn module_compilation_context_hash_fingerprinted(codegen_fingerprint: &str) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(b"module-artifact-source-local-v3\0");
hasher.update(b"stdlib-digest\0");
hasher.update(embedded_stdlib_digest());
hasher.update(b"\0codegen-fingerprint\0");
hasher.update(codegen_fingerprint.as_bytes());
hasher.finalize().into()
}
#[cfg(test)]
thread_local! {
pub(crate) static WALKS_PERFORMED: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
}
fn hash_transitive_user_imports(source_path: &Path, source: &str) -> [u8; 32] {
hash_transitive_user_imports_fingerprinted(source_path, source, CODEGEN_FINGERPRINT).0
}
fn hash_transitive_user_imports_with_manifest(
source_path: &Path,
source: &str,
) -> ([u8; 32], Option<ContextManifest>) {
hash_transitive_user_imports_fingerprinted(source_path, source, CODEGEN_FINGERPRINT)
}
fn hash_transitive_user_imports_fingerprinted(
source_path: &Path,
source: &str,
codegen_fingerprint: &str,
) -> ([u8; 32], Option<ContextManifest>) {
#[cfg(test)]
WALKS_PERFORMED.with(|c| c.set(c.get() + 1));
let mut visited: std::collections::BTreeMap<PathBuf, ImportNode> =
std::collections::BTreeMap::new();
let entry = ModuleSource::from_text(source);
let mut frontier: Vec<(PathBuf, Arc<str>)> = entry
.imports()
.iter()
.map(|import| (source_path.to_path_buf(), Arc::clone(import)))
.collect();
let mut manifest = Some(ContextManifest::begin(module_source::canonical_identity(
source_path,
)));
while let Some((anchor, import)) = frontier.pop() {
let Some(resolved) = harn_modules::resolve_import_path(&anchor, &import) else {
let sentinel = anchor.join(format!("__unresolved__/{import}"));
if let std::collections::btree_map::Entry::Vacant(slot) = visited.entry(sentinel) {
slot.insert(ImportNode::Unresolved {
import: Arc::clone(&import),
});
if let Some(m) = manifest.as_mut() {
m.unresolved.push(ManifestUnresolved {
anchor: anchor.clone(),
import: import.to_string(),
});
}
}
continue;
};
let canonical = module_source::canonical_identity(&resolved);
if visited.contains_key(&canonical) {
continue;
}
match module_source::read(&resolved) {
Ok(module) => {
visited.insert(
canonical.clone(),
ImportNode::Resolved {
content: Arc::clone(module.text()),
},
);
match ManifestFile::observe(&canonical, &module) {
Some(file) => {
if let Some(m) = manifest.as_mut() {
m.files.push(file);
}
}
None => manifest = None,
}
for nested_import in module.imports() {
frontier.push((resolved.clone(), Arc::clone(nested_import)));
}
}
Err(error) => {
let unreadable_path = canonical.clone();
visited.insert(
canonical,
ImportNode::IoError {
kind: error.kind().to_string(),
},
);
if let Some(m) = manifest.as_mut() {
m.unreadable.push(ManifestUnreadable {
path: unreadable_path,
kind: error.kind().to_string(),
});
}
}
}
}
let mut hasher = Sha256::new();
hasher.update(b"stdlib-digest\0");
hasher.update(embedded_stdlib_digest());
hasher.update(b"\0");
hasher.update(b"codegen-fingerprint\0");
hasher.update(codegen_fingerprint.as_bytes());
hasher.update(b"\0");
for (path, node) in &visited {
hasher.update(path.to_string_lossy().as_bytes());
hasher.update(b"\0");
match node {
ImportNode::Resolved { content } => {
hasher.update(b"resolved\0");
hasher.update(content.as_bytes());
}
ImportNode::Unresolved { import } => {
hasher.update(b"unresolved\0");
hasher.update(import.as_bytes());
}
ImportNode::IoError { kind } => {
hasher.update(b"ioerror\0");
hasher.update(kind.as_bytes());
}
}
hasher.update(b"\0");
}
if let Some(m) = manifest.as_mut() {
m.files.sort_by(|a, b| a.path.cmp(&b.path));
m.unresolved
.sort_by(|a, b| (&a.anchor, &a.import).cmp(&(&b.anchor, &b.import)));
m.unreadable.sort_by(|a, b| a.path.cmp(&b.path));
}
(hasher.finalize().into(), manifest)
}
enum ImportNode {
Resolved { content: Arc<str> },
Unresolved { import: Arc<str> },
IoError { kind: String },
}
#[cfg(test)]
#[path = "bytecode_cache_tests.rs"]
mod tests;