Skip to main content

harn_vm/
linked_program.rs

1//! Closed-program bytecode artifact and program-scoped module repository.
2//!
3//! Ordinary module artifacts are caller-independent and therefore retain their
4//! complete export surface. A linked program is different: its graph and every
5//! namespace use are closed at build time, so the linker may specialize module
6//! exports without weakening generic cache correctness. The runtime installs
7//! the decoded module templates for one VM execution tree; it never inserts
8//! them into the ordinary source-keyed cache.
9
10use std::collections::BTreeMap;
11use std::fmt;
12use std::path::{Path, PathBuf};
13use std::sync::Arc;
14
15use serde::{Deserialize, Serialize};
16
17use crate::bytecode_cache;
18use crate::chunk::{CachedChunk, Chunk};
19use crate::module_artifact::ModuleArtifact;
20use crate::prepared_module::PreparedModuleArtifact;
21
22pub const LINKED_PROGRAM_SCHEMA_VERSION: u32 = 1;
23pub const LINKED_PROGRAM_ARCHIVE_PATH: &str = "artifacts/program.harnlink";
24pub const LINKER_ALGORITHM_VERSION: u32 = 1;
25const MAGIC: &[u8; 8] = b"HARNLINK";
26
27/// Compile and specialize one closed source graph into the runtime's single
28/// linked-program artifact. Package policy, signing, and source inclusion stay
29/// with the archive caller; graph discovery and bytecode reachability live here.
30pub fn link_program(
31    entrypoint: &Path,
32    project_root: &Path,
33) -> Result<LinkedProgramArtifact, LinkedProgramError> {
34    let entrypoint = harn_modules::canonical_path(entrypoint);
35    let project_root = harn_modules::canonical_path(project_root);
36    let build = harn_modules::build_closed_program(std::slice::from_ref(&entrypoint));
37    let reachability = harn_modules::closed_program_reachability(&build, &entrypoint);
38    let entry_source = build.parsed_sources.get(&entrypoint).ok_or_else(|| {
39        LinkedProgramError::invalid(format!(
40            "entrypoint {} was not parsed by the closed graph",
41            entrypoint.display()
42        ))
43    })?;
44    let imported_enums = build
45        .graph
46        .imported_names_by_kind_for_file(&entrypoint, harn_modules::DefKind::Enum)
47        .unwrap_or_default();
48    let imported_callables = build
49        .graph
50        .imported_callable_names_for_file(&entrypoint)
51        .unwrap_or_default();
52    let entry_chunk = crate::Compiler::new()
53        .with_imported_enum_candidates(imported_enums)
54        .with_imported_source_callable_names(imported_callables)
55        .compile(&entry_source.program)
56        .map_err(|error| {
57            LinkedProgramError::invalid(format!(
58                "entrypoint compile failed for {}: {error}",
59                entrypoint.display()
60            ))
61        })?
62        .freeze_for_cache();
63
64    let entrypoint_rel = entrypoint.strip_prefix(&project_root).map_err(|_| {
65        LinkedProgramError::invalid(format!(
66            "entrypoint {} is outside package root {}",
67            entrypoint.display(),
68            project_root.display()
69        ))
70    })?;
71    let entry_bytes = postcard::to_allocvec(&entry_chunk)
72        .map_err(|error| LinkedProgramError::invalid(format!("entry size failed: {error}")))?;
73    let mut report = LinkReport {
74        linker_algorithm_version: LINKER_ALGORITHM_VERSION,
75        harn_version: bytecode_cache::HARN_VERSION.to_string(),
76        codegen_fingerprint: bytecode_cache::CODEGEN_FINGERPRINT.to_string(),
77        input_bytecode_bytes: entry_bytes.len() as u64,
78        output_bytecode_bytes: entry_bytes.len() as u64,
79        user_input_bytes: entry_bytes.len() as u64,
80        user_output_bytes: entry_bytes.len() as u64,
81        modules: vec![LinkModuleReport {
82            path: entrypoint_rel.to_path_buf(),
83            demand: LinkModuleDemand::WholeNamespace,
84            input_bytes: entry_bytes.len() as u64,
85            output_bytes: entry_bytes.len() as u64,
86            initializer_bytes: 0,
87            type_schema_bytes: 0,
88            widening_reason: None,
89            retained_symbols: vec![LinkSymbolReason {
90                symbol: "<entry>".to_string(),
91                reason: "typed entry chunk".to_string(),
92            }],
93            removed_symbols: Vec::new(),
94        }],
95        ..LinkReport::default()
96    };
97
98    let mut modules = BTreeMap::new();
99    let mut digest_inputs = Vec::new();
100    for path in build.graph.module_paths() {
101        let path = harn_modules::canonical_path(&path);
102        // A closed-program build retains every source that parsed as Harn, so a
103        // graph node with none is an imported non-Harn asset. Assets are archive
104        // payload owned by the packaging caller, not executable modules: they
105        // carry no bytecode to link and their integrity is bound by the SBOM and
106        // archive hashes rather than the program's graph digest.
107        let Some(parsed) = build.parsed_sources.get(&path) else {
108            continue;
109        };
110        let archive_path = archive_module_path(&project_root, &path)?;
111        digest_inputs.push((archive_path.clone(), parsed.source.as_bytes().to_vec()));
112        if path == entrypoint {
113            continue;
114        }
115        let compilation_context =
116            crate::module_artifact::ModuleCompilationContext::for_source_in_graph(
117                &build.graph,
118                &path,
119                &parsed.source,
120            )
121            .map_err(|error| {
122                LinkedProgramError::invalid(format!(
123                    "module context failed for {}: {error}",
124                    path.display()
125                ))
126            })?;
127        let compile_path = runtime_compile_path(&path);
128        let full = if harn_modules::stdlib_module_name(&path).is_some() {
129            crate::module_artifact::compile_embedded_stdlib_module_artifact_from_source_with_context(
130                &compile_path,
131                &parsed.source,
132                &compilation_context,
133            )
134        } else {
135            crate::module_artifact::compile_module_artifact_from_source_with_context(
136                &compile_path,
137                &parsed.source,
138                &compilation_context,
139            )
140        }
141        .map_err(|error| {
142            LinkedProgramError::invalid(format!(
143                "module compile failed for {}: {error}",
144                path.display()
145            ))
146        })?;
147        let full_symbols = artifact_symbols(&full);
148        let input_bytes = postcard::to_allocvec(&full)
149            .map_err(|error| LinkedProgramError::invalid(format!("module size failed: {error}")))?
150            .len() as u64;
151        let requested = reachability.demand_for(&path);
152        let widening_reason = full.imports.iter().any(|import| import.is_pub).then(|| {
153            "public re-export shares the module's local import projection; retained whole namespace"
154                .to_string()
155        });
156        let effective = if widening_reason.is_some() {
157            harn_modules::ExportDemand::WholeNamespace
158        } else {
159            requested
160        };
161        let selected = crate::module_artifact::specialize_module_artifact(
162            &parsed.program,
163            Some(compile_path.display().to_string()),
164            full,
165            &effective,
166        )
167        .map_err(|error| {
168            LinkedProgramError::invalid(format!(
169                "module specialization failed for {}: {error}",
170                path.display()
171            ))
172        })?;
173        let selected_symbols = artifact_symbols(&selected);
174        let output_bytes = postcard::to_allocvec(&selected)
175            .map_err(|error| LinkedProgramError::invalid(format!("module size failed: {error}")))?
176            .len() as u64;
177        let mut retained_symbols = selected_symbols
178            .iter()
179            .map(|symbol| LinkSymbolReason {
180                symbol: symbol.clone(),
181                reason: if effective.contains(symbol) {
182                    "observable export".to_string()
183                } else {
184                    "initializer or private callable dependency".to_string()
185                },
186            })
187            .collect::<Vec<_>>();
188        let initializer_bytes = selected.init_chunk.as_ref().map_or(0, |chunk| {
189            postcard::to_allocvec(chunk).map_or(0, |bytes| bytes.len() as u64)
190        });
191        let type_schema_bytes = selected
192            .type_schema_init_chunks
193            .iter()
194            .map(|chunk| postcard::to_allocvec(chunk).map_or(0, |bytes| bytes.len() as u64))
195            .sum();
196        if initializer_bytes > 0 {
197            retained_symbols.push(LinkSymbolReason {
198                symbol: "<module_initializer>".to_string(),
199                reason: "module effects are preserved conservatively".to_string(),
200            });
201        }
202        let removed_symbols = full_symbols
203            .difference(&selected_symbols)
204            .cloned()
205            .collect::<Vec<_>>();
206        report.input_bytecode_bytes += input_bytes;
207        report.output_bytecode_bytes += output_bytes;
208        if archive_path
209            .to_str()
210            .is_some_and(|path| path.starts_with("<std>/"))
211        {
212            report.stdlib_input_bytes += input_bytes;
213            report.stdlib_output_bytes += output_bytes;
214        } else {
215            report.user_input_bytes += input_bytes;
216            report.user_output_bytes += output_bytes;
217        }
218        report.retained_symbols += retained_symbols.len() as u64;
219        report.removed_symbols += removed_symbols.len() as u64;
220        report.modules.push(LinkModuleReport {
221            path: archive_path.clone(),
222            demand: match effective {
223                harn_modules::ExportDemand::InitializationOnly => {
224                    LinkModuleDemand::InitializationOnly
225                }
226                harn_modules::ExportDemand::Members(_) => LinkModuleDemand::Members,
227                harn_modules::ExportDemand::WholeNamespace => LinkModuleDemand::WholeNamespace,
228            },
229            input_bytes,
230            output_bytes,
231            initializer_bytes,
232            type_schema_bytes,
233            widening_reason,
234            retained_symbols,
235            removed_symbols,
236        });
237        modules.insert(archive_path, selected);
238    }
239    digest_inputs.sort_by(|left, right| left.0.cmp(&right.0));
240    let graph_digest_blake3 = graph_digest_from_sources(&digest_inputs);
241    report.graph_digest_blake3.clone_from(&graph_digest_blake3);
242    report
243        .modules
244        .sort_by(|left, right| left.path.cmp(&right.path));
245
246    Ok(LinkedProgramArtifact {
247        schema_version: LINKED_PROGRAM_SCHEMA_VERSION,
248        identity: LinkedProgramIdentity::current(graph_digest_blake3),
249        entrypoint: entrypoint_rel.to_path_buf(),
250        entry_chunk,
251        modules,
252        report,
253    })
254}
255
256fn archive_module_path(project_root: &Path, path: &Path) -> Result<PathBuf, LinkedProgramError> {
257    if path.to_str().is_some_and(|path| path.starts_with("<std>/")) {
258        return Ok(path.to_path_buf());
259    }
260    path.strip_prefix(project_root)
261        .map(Path::to_path_buf)
262        .map_err(|_| {
263            LinkedProgramError::invalid(format!(
264                "module {} is outside package root {}",
265                path.display(),
266                project_root.display()
267            ))
268        })
269}
270
271fn runtime_compile_path(path: &Path) -> PathBuf {
272    path.to_str()
273        .and_then(|path| path.strip_prefix("<std>/"))
274        .map_or_else(
275            || path.to_path_buf(),
276            |module| PathBuf::from(format!("<stdlib>/{module}.harn")),
277        )
278}
279
280fn artifact_symbols(artifact: &ModuleArtifact) -> std::collections::BTreeSet<String> {
281    artifact
282        .functions
283        .keys()
284        .chain(artifact.public_exports.keys())
285        .cloned()
286        .collect()
287}
288
289/// Digest the exact normalized source graph compiled into a linked program.
290/// The pack verifier reconstructs this independently from verified archive
291/// sources plus the current embedded stdlib modules before installation.
292pub fn graph_digest_from_sources(sources: &[(PathBuf, Vec<u8>)]) -> String {
293    let mut hasher = blake3::Hasher::new();
294    hasher.update(b"harn-linked-program-graph-v1\0");
295    for (path, source) in sources {
296        hasher.update(path.to_string_lossy().as_bytes());
297        hasher.update(&[0]);
298        hasher.update(&(source.len() as u64).to_le_bytes());
299        hasher.update(source);
300    }
301    format!("blake3:{}", hasher.finalize().to_hex())
302}
303
304/// Reconstruct and verify a linked graph from independently verified user
305/// sources plus the runtime's embedded stdlib. Archive verification and direct
306/// execution share this boundary so neither can accept a self-consistent but
307/// manifest-detached artifact.
308pub fn verify_graph_binding(
309    report: &LinkReport,
310    expected_digest: &str,
311    mut user_source: impl FnMut(&Path) -> Option<Vec<u8>>,
312) -> Result<(), LinkedProgramError> {
313    let mut sources = Vec::new();
314    for module in &report.modules {
315        let bytes = if let Some(name) = module
316            .path
317            .to_str()
318            .and_then(|path| path.strip_prefix("<std>/"))
319        {
320            crate::stdlib_modules::get_stdlib_source(name)
321                .ok_or_else(|| {
322                    LinkedProgramError::invalid(format!(
323                        "runtime has no embedded stdlib module std/{name}"
324                    ))
325                })?
326                .as_bytes()
327                .to_vec()
328        } else {
329            user_source(&module.path).ok_or_else(|| {
330                LinkedProgramError::invalid(format!(
331                    "verified source graph has no {}",
332                    module.path.display()
333                ))
334            })?
335        };
336        sources.push((module.path.clone(), bytes));
337    }
338    sources.sort_by(|left, right| left.0.cmp(&right.0));
339    let actual = graph_digest_from_sources(&sources);
340    if actual != expected_digest {
341        return Err(LinkedProgramError::invalid(format!(
342            "linked graph digest mismatch: manifest {expected_digest}, verified sources {actual}"
343        )));
344    }
345    Ok(())
346}
347
348#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
349pub struct LinkedProgramIdentity {
350    pub graph_digest_blake3: String,
351    pub harn_version: String,
352    pub codegen_fingerprint: String,
353    pub bytecode_schema_version: u32,
354    pub linker_algorithm_version: u32,
355    pub optimizations_enabled: bool,
356}
357
358impl LinkedProgramIdentity {
359    pub fn current(graph_digest_blake3: String) -> Self {
360        Self {
361            graph_digest_blake3,
362            harn_version: bytecode_cache::HARN_VERSION.to_string(),
363            codegen_fingerprint: bytecode_cache::CODEGEN_FINGERPRINT.to_string(),
364            bytecode_schema_version: bytecode_cache::SCHEMA_VERSION,
365            linker_algorithm_version: LINKER_ALGORITHM_VERSION,
366            optimizations_enabled: crate::CompilerOptions::from_env().optimizations_enabled(),
367        }
368    }
369
370    fn validate_current(&self) -> Result<(), LinkedProgramError> {
371        let expected = Self::current(self.graph_digest_blake3.clone());
372        if self.harn_version != expected.harn_version {
373            return Err(LinkedProgramError::incompatible(format!(
374                "linked program was built by harn {}; this runtime is {}",
375                self.harn_version, expected.harn_version
376            )));
377        }
378        if self.codegen_fingerprint != expected.codegen_fingerprint
379            || self.bytecode_schema_version != expected.bytecode_schema_version
380            || self.linker_algorithm_version != expected.linker_algorithm_version
381            || self.optimizations_enabled != expected.optimizations_enabled
382        {
383            return Err(LinkedProgramError::incompatible(
384                "linked program compiler, bytecode, or linker identity does not match this runtime",
385            ));
386        }
387        Ok(())
388    }
389}
390
391#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
392pub struct LinkReport {
393    pub graph_digest_blake3: String,
394    pub linker_algorithm_version: u32,
395    pub harn_version: String,
396    pub codegen_fingerprint: String,
397    pub input_bytecode_bytes: u64,
398    pub output_bytecode_bytes: u64,
399    pub user_input_bytes: u64,
400    pub user_output_bytes: u64,
401    pub stdlib_input_bytes: u64,
402    pub stdlib_output_bytes: u64,
403    pub retained_symbols: u64,
404    pub removed_symbols: u64,
405    pub modules: Vec<LinkModuleReport>,
406}
407
408#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
409pub struct LinkModuleReport {
410    pub path: PathBuf,
411    pub demand: LinkModuleDemand,
412    pub input_bytes: u64,
413    pub output_bytes: u64,
414    pub initializer_bytes: u64,
415    pub type_schema_bytes: u64,
416    pub widening_reason: Option<String>,
417    pub retained_symbols: Vec<LinkSymbolReason>,
418    pub removed_symbols: Vec<String>,
419}
420
421#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
422#[serde(rename_all = "snake_case")]
423pub enum LinkModuleDemand {
424    InitializationOnly,
425    Members,
426    WholeNamespace,
427}
428
429#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
430pub struct LinkSymbolReason {
431    pub symbol: String,
432    pub reason: String,
433}
434
435#[derive(Clone, Debug, Serialize, Deserialize)]
436pub struct LinkedProgramArtifact {
437    pub schema_version: u32,
438    pub identity: LinkedProgramIdentity,
439    /// Archive-relative user source path.
440    pub entrypoint: PathBuf,
441    pub entry_chunk: CachedChunk,
442    /// Archive-relative user source paths or `<std>/<module>` virtual paths.
443    pub modules: BTreeMap<PathBuf, ModuleArtifact>,
444    pub report: LinkReport,
445}
446
447impl LinkedProgramArtifact {
448    pub fn encode(&self) -> Result<Vec<u8>, LinkedProgramError> {
449        let payload = postcard::to_allocvec(self)
450            .map_err(|error| LinkedProgramError::invalid(format!("encode failed: {error}")))?;
451        let mut bytes = Vec::with_capacity(MAGIC.len() + 4 + payload.len());
452        bytes.extend_from_slice(MAGIC);
453        bytes.extend_from_slice(&LINKED_PROGRAM_SCHEMA_VERSION.to_le_bytes());
454        bytes.extend_from_slice(&payload);
455        Ok(bytes)
456    }
457
458    pub fn decode(bytes: &[u8]) -> Result<Self, LinkedProgramError> {
459        let Some((magic, rest)) = bytes.split_at_checked(MAGIC.len()) else {
460            return Err(LinkedProgramError::invalid(
461                "linked program header is truncated",
462            ));
463        };
464        if magic != MAGIC {
465            return Err(LinkedProgramError::invalid(
466                "linked program magic is invalid",
467            ));
468        }
469        let Some((schema, payload)) = rest.split_at_checked(4) else {
470            return Err(LinkedProgramError::invalid(
471                "linked program schema header is truncated",
472            ));
473        };
474        let actual = u32::from_le_bytes(schema.try_into().expect("four-byte schema"));
475        if actual != LINKED_PROGRAM_SCHEMA_VERSION {
476            return Err(LinkedProgramError::incompatible(format!(
477                "linked program schema {actual} is unsupported; expected {LINKED_PROGRAM_SCHEMA_VERSION}"
478            )));
479        }
480        let (artifact, trailing): (Self, &[u8]) = postcard::take_from_bytes(payload)
481            .map_err(|error| LinkedProgramError::invalid(format!("decode failed: {error}")))?;
482        if !trailing.is_empty() {
483            return Err(LinkedProgramError::invalid(
484                "linked program contains trailing bytes",
485            ));
486        }
487        if artifact.schema_version != LINKED_PROGRAM_SCHEMA_VERSION {
488            return Err(LinkedProgramError::invalid(format!(
489                "linked program payload schema {} disagrees with its header",
490                artifact.schema_version
491            )));
492        }
493        artifact.identity.validate_current()?;
494        Ok(artifact)
495    }
496
497    pub fn into_runtime(self, source_root: &Path) -> LinkedProgramRuntime {
498        let modules = self
499            .modules
500            .into_iter()
501            .map(|(path, mut artifact)| {
502                let runtime_path = runtime_module_path(source_root, &path);
503                artifact.bind_source_file(&runtime_path);
504                (
505                    runtime_path,
506                    Arc::new(PreparedModuleArtifact::from_cached(artifact)),
507                )
508            })
509            .collect();
510        LinkedProgramRuntime {
511            digest: self.identity.graph_digest_blake3,
512            entry_chunk: Chunk::from_cached(self.entry_chunk),
513            repository: Arc::new(LinkedProgramRepository { modules }),
514            report: self.report,
515        }
516    }
517}
518
519fn runtime_module_path(source_root: &Path, path: &Path) -> PathBuf {
520    if let Some(path) = path.to_str().and_then(|path| path.strip_prefix("<std>/")) {
521        return PathBuf::from(format!("<stdlib>/{path}.harn"));
522    }
523    let path = source_root.join(path);
524    path.canonicalize().unwrap_or(path)
525}
526
527pub struct LinkedProgramRuntime {
528    pub digest: String,
529    pub entry_chunk: Chunk,
530    pub report: LinkReport,
531    pub(crate) repository: Arc<LinkedProgramRepository>,
532}
533
534pub(crate) struct LinkedProgramRepository {
535    modules: BTreeMap<PathBuf, Arc<PreparedModuleArtifact>>,
536}
537
538impl LinkedProgramRepository {
539    pub(crate) fn get(&self, path: &Path) -> Option<Arc<PreparedModuleArtifact>> {
540        self.modules.get(path).cloned()
541    }
542}
543
544#[derive(Clone, Debug, PartialEq, Eq)]
545pub struct LinkedProgramError {
546    pub code: &'static str,
547    pub message: String,
548}
549
550impl LinkedProgramError {
551    fn invalid(message: impl Into<String>) -> Self {
552        Self {
553            code: "linked_program.invalid",
554            message: message.into(),
555        }
556    }
557
558    fn incompatible(message: impl Into<String>) -> Self {
559        Self {
560            code: "linked_program.incompatible",
561            message: message.into(),
562        }
563    }
564}
565
566impl fmt::Display for LinkedProgramError {
567    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
568        formatter.write_str(&self.message)
569    }
570}
571
572impl std::error::Error for LinkedProgramError {}
573
574#[cfg(test)]
575mod tests {
576    use super::*;
577    use crate::module_artifact::ModuleProvenance;
578    use std::fs;
579
580    /// Write `contents` to `path` keeping its length and its exact nanosecond
581    /// modification time, which is the one edit a length-and-timestamp identity
582    /// cannot observe.
583    fn overwrite_preserving_len_and_mtime(path: &Path, contents: &str) {
584        let before = fs::metadata(path).expect("stat before");
585        let previous = fs::read_to_string(path).expect("read before");
586        assert_eq!(
587            contents.len(),
588            previous.len(),
589            "the probe is only meaningful when the byte length is unchanged"
590        );
591        fs::write(path, contents).expect("write edit");
592        let handle = fs::File::options()
593            .write(true)
594            .open(path)
595            .expect("reopen to restamp");
596        handle
597            .set_modified(before.modified().expect("mtime before"))
598            .expect("restore mtime");
599        drop(handle);
600        let after = fs::metadata(path).expect("stat after");
601        assert_eq!(
602            before.modified().unwrap(),
603            after.modified().unwrap(),
604            "the probe is only meaningful when the modification time is unchanged"
605        );
606    }
607
608    /// harn#8138. A closed packaged tree is the one place the loader accepts an
609    /// artifact on a root-relative context, so it is the one place a stale
610    /// artifact could plausibly win over edited source. It cannot: the graph
611    /// binding is a digest over source CONTENT, so an edit that preserves both
612    /// length and nanosecond modification time still fails verification.
613    ///
614    /// The edit here is the reported one, an error-message string in an
615    /// imported module, and the falsifier is below it: the unedited graph must
616    /// verify, or this test would pass with a digest that never matched
617    /// anything.
618    #[test]
619    fn linked_graph_binding_refuses_an_edited_source_at_identical_length_and_mtime() {
620        let dir = tempfile::tempdir().unwrap();
621        let library = dir.path().join("library.harn");
622        let entry = dir.path().join("entry.harn");
623        fs::write(
624            &library,
625            "pub fn explain() { \"ERROR: original message\" }\n",
626        )
627        .unwrap();
628        fs::write(
629            &entry,
630            "import { explain } from \"./library.harn\"\nfn main() { println(explain()) }\n",
631        )
632        .unwrap();
633
634        let linked = link_program(&entry, dir.path()).expect("link succeeds");
635        let digest = linked.identity.graph_digest_blake3.clone();
636        let root = dir.path().to_path_buf();
637        let read_from_disk = |path: &Path| fs::read(root.join(path)).ok();
638
639        // Control: the graph as linked must verify. Without this the refusal
640        // below would prove only that the digest never matches anything.
641        verify_graph_binding(&linked.report, &digest, read_from_disk)
642            .expect("the unedited graph verifies");
643
644        overwrite_preserving_len_and_mtime(
645            &library,
646            "pub fn explain() { \"ERROR: replaced message\" }\n",
647        );
648
649        let error = verify_graph_binding(&linked.report, &digest, read_from_disk)
650            .expect_err("an edited source must not verify against the linked digest");
651        assert!(
652            error.message.contains("linked graph digest mismatch"),
653            "the refusal must name the mismatch, got: {}",
654            error.message
655        );
656    }
657
658    /// The other half of the same question: a source that no longer parses is
659    /// still read as bytes by the binding check, so the artifact cannot stand
660    /// in for it. This is the corrupt-source probe from the issue, at the
661    /// linked-program seam.
662    #[test]
663    fn linked_graph_binding_refuses_a_corrupted_source_it_cannot_parse() {
664        let dir = tempfile::tempdir().unwrap();
665        let library = dir.path().join("library.harn");
666        let entry = dir.path().join("entry.harn");
667        fs::write(
668            &library,
669            "pub fn explain() { \"ERROR: original message\" }\n",
670        )
671        .unwrap();
672        fs::write(
673            &entry,
674            "import { explain } from \"./library.harn\"\nfn main() { println(explain()) }\n",
675        )
676        .unwrap();
677
678        let linked = link_program(&entry, dir.path()).expect("link succeeds");
679        let digest = linked.identity.graph_digest_blake3.clone();
680        let root = dir.path().to_path_buf();
681        let read_from_disk = |path: &Path| fs::read(root.join(path)).ok();
682
683        overwrite_preserving_len_and_mtime(
684            &library,
685            "@@@ this source does not parse at all@@@@@@@@@\n",
686        );
687
688        let error = verify_graph_binding(&linked.report, &digest, read_from_disk)
689            .expect_err("a corrupted source must not verify against the linked digest");
690        assert!(error.message.contains("linked graph digest mismatch"));
691    }
692
693    #[test]
694    fn identity_rejects_codegen_drift() {
695        let mut identity = LinkedProgramIdentity::current("blake3:test".to_string());
696        identity.codegen_fingerprint.push_str("-different");
697        let error = identity.validate_current().unwrap_err();
698        assert_eq!(error.code, "linked_program.incompatible");
699    }
700
701    #[test]
702    fn closed_link_preserves_embedded_stdlib_authority() {
703        let dir = tempfile::tempdir().unwrap();
704        let entry = dir.path().join("entry.harn");
705        fs::write(
706            &entry,
707            r#"
708            import { ansi_enabled } from "std/ansi"
709            fn main() { ansi_enabled() }
710            "#,
711        )
712        .unwrap();
713
714        let linked = link_program(&entry, dir.path()).expect("embedded stdlib link succeeds");
715        assert_eq!(
716            linked.modules[Path::new("<std>/ansi")].provenance,
717            ModuleProvenance::EmbeddedStdlib
718        );
719    }
720
721    #[test]
722    fn closed_link_retains_private_callable_closure_and_initializer_roots() {
723        let dir = tempfile::tempdir().unwrap();
724        let library = dir.path().join("library.harn");
725        let entry = dir.path().join("entry.harn");
726        fs::write(
727            &library,
728            r#"
729            fn helper_a() { helper_b() }
730            fn helper_b() { 7 }
731            fn init_helper() { "initialized" }
732            const init_hook = init_helper
733            pub fn kept() { helper_a() }
734            pub fn dead() { "dead" }
735            pub type KeptShape = { value: int }
736            pub type DeadShape = { value: string }
737            "#,
738        )
739        .unwrap();
740        fs::write(
741            &entry,
742            r#"
743            import * as lib from "./library.harn"
744            fn main() { println(lib.kept()) }
745            "#,
746        )
747        .unwrap();
748
749        let linked = link_program(&entry, dir.path()).expect("link succeeds");
750        let library = &linked.modules[Path::new("library.harn")];
751        assert!(library.functions.contains_key("kept"));
752        assert!(library.functions.contains_key("helper_a"));
753        assert!(library.functions.contains_key("helper_b"));
754        assert!(library.functions.contains_key("init_helper"));
755        assert!(!library.functions.contains_key("dead"));
756        assert_eq!(
757            library.public_exports.keys().cloned().collect::<Vec<_>>(),
758            ["kept"]
759        );
760        let report = linked
761            .report
762            .modules
763            .iter()
764            .find(|module| module.path == Path::new("library.harn"))
765            .unwrap();
766        assert!(report.removed_symbols.iter().any(|name| name == "dead"));
767        assert!(report.initializer_bytes > 0);
768        assert!(report.output_bytes < report.input_bytes);
769    }
770
771    #[test]
772    fn selective_type_import_retains_only_its_schema_initializer() {
773        let dir = tempfile::tempdir().unwrap();
774        let library = dir.path().join("types.harn");
775        let entry = dir.path().join("entry.harn");
776        fs::write(
777            &library,
778            r"
779            pub type KeptShape = { value: int }
780            pub type DeadShape = { value: string }
781            ",
782        )
783        .unwrap();
784        fs::write(
785            &entry,
786            r#"
787            import { KeptShape } from "./types.harn"
788            fn accept(value: KeptShape) { value.value }
789            fn main() { accept({ value: 7 }) }
790            "#,
791        )
792        .unwrap();
793
794        let linked = link_program(&entry, dir.path()).expect("link succeeds");
795        let types = &linked.modules[Path::new("types.harn")];
796        assert_eq!(
797            types.public_type_names.iter().cloned().collect::<Vec<_>>(),
798            ["KeptShape"]
799        );
800        assert_eq!(types.type_schema_init_chunks.len(), 1);
801        let report = linked
802            .report
803            .modules
804            .iter()
805            .find(|module| module.path == Path::new("types.harn"))
806            .unwrap();
807        assert!(report.type_schema_bytes > 0);
808        assert!(report
809            .removed_symbols
810            .iter()
811            .any(|name| name == "DeadShape"));
812    }
813
814    #[test]
815    fn public_reexport_records_conservative_widening() {
816        let dir = tempfile::tempdir().unwrap();
817        let inner = dir.path().join("inner.harn");
818        let facade = dir.path().join("facade.harn");
819        let entry = dir.path().join("entry.harn");
820        fs::write(&inner, "pub fn kept() { 7 }\npub fn dead() { 8 }\n").unwrap();
821        fs::write(
822            &facade,
823            r#"
824            pub import { kept } from "./inner.harn"
825            pub fn local_dead() { 9 }
826            "#,
827        )
828        .unwrap();
829        fs::write(
830            &entry,
831            r#"
832            import { kept } from "./facade.harn"
833            fn main() { println(kept()) }
834            "#,
835        )
836        .unwrap();
837
838        let linked = link_program(&entry, dir.path()).expect("link succeeds");
839        let facade_report = linked
840            .report
841            .modules
842            .iter()
843            .find(|module| module.path == Path::new("facade.harn"))
844            .unwrap();
845        assert_eq!(facade_report.demand, LinkModuleDemand::WholeNamespace);
846        assert!(facade_report
847            .widening_reason
848            .as_deref()
849            .is_some_and(|reason| reason.contains("public re-export")));
850        assert!(linked.modules[Path::new("facade.harn")]
851            .functions
852            .contains_key("local_dead"));
853    }
854}