harn_vm/context_manifest.rs
1//! Stat-based validity proof for an entry chunk's import-graph context.
2//!
3//! The entry-chunk cache key folds in the content of every transitively
4//! reachable user file, so deciding whether a cached chunk is still valid used
5//! to mean re-reading, re-scanning and re-hashing that whole graph on every
6//! spawn — a cold-path algorithm running on the warm path.
7//!
8//! A manifest records what the graph looked like when the key was computed, in
9//! terms cheap enough to re-check: the entry it was walked from, each file's
10//! stat identity, and the negative facts the graph also depends on. The anchor
11//! is what makes the rest mean anything — the same set of unchanged files
12//! describes a different graph under a different entry, and a cache that names
13//! artifacts by entry source hash alone will hand one entry the other's
14//! manifest.
15//!
16//! Re-checking is stats only. A different anchor, any mismatch, any file that
17//! cannot be stat'ed, and any manifest that was never written all fall back to
18//! the full walk, which recomputes the key from scratch — so a manifest can
19//! only ever save work, never decide a hit on its own.
20//!
21//! A manifest that re-checks clean is also the graph's link table. It records
22//! each file's digest plus the typed imported interface consulted by lowering,
23//! so [`GraphLinkTable`] hands module loading the complete identities it would
24//! otherwise re-read 5.7 MB of source and rebuild the graph to rederive.
25//!
26//! Stat identity is already the trust boundary inside a process:
27//! [`crate::module_source`] memoizes reads on `(path, len, mtime_ns)`. This
28//! extends that same decision across process boundaries, matching how Cargo,
29//! Zig and Bazel gate their warm paths.
30//!
31//! Stats alone are not sufficient for a file written *while* the manifest was
32//! being captured. Filesystems quantize mtime — two seconds on FAT, one second
33//! on HFS+ and older NFS, a ~15.6ms clock tick on NTFS — so two writes inside
34//! one tick record one mtime, and if the second preserves length the recorded
35//! identity is byte-identical to the first. Programmatic agent edits land in
36//! that window routinely. Each manifest therefore records when its capture
37//! began, and an entry whose mtime is not a full granularity older than that
38//! is "racily clean" in git's sense: judged by content instead of by stats
39//! ([`crate::module_source::mtime_predates_capture`]). Re-checking a racy
40//! entry also re-stamps the manifest, so the entry settles onto the stats-only
41//! path on the next spawn rather than paying a read forever.
42//!
43//! The remaining gap is the one Cargo, Zig and Bazel accept: an edit that
44//! preserves length *and* restores a settled mtime is not noticed, which takes
45//! a deliberate timestamp forgery rather than an ordinary write. That gap is
46//! pinned by a test rather than left to prose, so closing it — or widening it —
47//! has to be a deliberate edit and cannot happen by accident.
48
49use std::collections::HashMap;
50use std::path::{Path, PathBuf};
51
52use serde::{Deserialize, Serialize};
53use sha2::{Digest, Sha256};
54
55use crate::module_artifact::ModuleCompilationContext;
56use crate::module_source::{self, ModuleSource};
57
58/// One transitively reachable source file, plus the path that must stay absent
59/// for the imports that reached it to keep resolving here.
60#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
61pub struct ManifestFile {
62 /// Canonical path, matching the key the walk dedups on.
63 pub path: PathBuf,
64 pub len: u64,
65 pub mtime_ns: i128,
66 /// SHA-256 of the bytes that were folded into the context hash.
67 ///
68 /// SHA-256 because the rest of the cache already identifies source by it —
69 /// entry `source_hash`, module keys, artifact filenames — so this adds no
70 /// second digest of the same bytes, and a warm process that has keyed the
71 /// module artifact has already paid for it.
72 ///
73 /// Two things read it. A racily-clean entry is decided by content when
74 /// stats cannot decide. And a manifest that re-checked clean hands these
75 /// digests to the module loader as a [`GraphLinkTable`], paired with the
76 /// imported-interface context below to form the module artifact key.
77 pub content_hash: [u8; 32],
78 /// Imported names and kinds that participate in this file's lowering.
79 /// The validated link table must carry the same typed context as the
80 /// prepared and on-disk module keys; a content digest alone is incomplete.
81 pub compilation_context: ModuleCompilationContext,
82 /// Extensionless sibling that would shadow this file if it appeared.
83 ///
84 /// `resolve_local_import` probes `base.join(import)` *before* appending
85 /// `.harn`, so creating `dep/` next to `dep.harn` silently re-points every
86 /// `import "./dep"` at the new directory. Refactoring a module into a
87 /// directory is an ordinary thing to do, and without this the cache would
88 /// keep serving bytecode compiled against the file it replaced.
89 pub shadow: Option<PathBuf>,
90}
91
92/// An import that resolved to nothing when the key was computed.
93///
94/// The graph depends on this staying true: a file that appears later adds a
95/// real dependency without changing any recorded file's content.
96#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
97pub struct ManifestUnresolved {
98 pub anchor: PathBuf,
99 pub import: String,
100}
101
102/// A path an import resolved to that could not be read.
103///
104/// Real trees contain these: an `import "./types"` where `types/` is a
105/// directory resolves, then fails to read. The error *kind* is folded into the
106/// key, so the manifest has to reproduce it exactly rather than approximate it
107/// from a stat — which is why this re-attempts the read. There are only ever a
108/// handful of these, so re-reading them is cheaper than the walk they avoid.
109#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
110pub struct ManifestUnreadable {
111 pub path: PathBuf,
112 pub kind: String,
113}
114
115/// Everything the entry key's import-graph walk observed, in re-checkable form.
116#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
117pub struct ContextManifest {
118 /// Canonical path of the entry file this walk started from.
119 ///
120 /// Every other field is relative to it: imports resolve against the entry's
121 /// directory, so the same observations describe a different graph under a
122 /// different anchor. The entry-chunk cache names files by entry *source*
123 /// hash alone, deliberately, so two entries with identical bytes in
124 /// different directories land on one cache file and each would otherwise
125 /// find the other's manifest re-checking perfectly clean. See #5591.
126 pub entry: PathBuf,
127 pub files: Vec<ManifestFile>,
128 pub unresolved: Vec<ManifestUnresolved>,
129 pub unreadable: Vec<ManifestUnreadable>,
130 /// When this capture began, in [`module_source::stat_identity`]'s units and
131 /// epoch. Every recorded stat was taken after this instant, which is what
132 /// makes an older mtime provably un-reproducible by a later write.
133 ///
134 /// [`ContextManifest::begin`] is the only way to start one, so this is
135 /// never absent. A manifest decoded from an artifact whose stamp is 0 —
136 /// which no writer produces — classifies every entry as racy, costing
137 /// reads rather than trusting stats that were never timestamped.
138 pub captured_ns: i128,
139}
140
141/// What a re-check concluded about a manifest.
142#[derive(Clone, Debug)]
143pub enum ManifestCheck {
144 /// The graph moved, or could not be proven unmoved. The caller must walk.
145 Stale,
146 /// Proven unchanged from stats alone.
147 Valid,
148 /// Proven unchanged, but at least one entry sat in the racy window and had
149 /// to be read to decide. `refreshed` is the same manifest stamped with this
150 /// re-check's capture time; persisting it settles those entries onto the
151 /// stats-only path, the way git rewrites a racily-clean index entry rather
152 /// than re-reading it on every command.
153 ValidAfterRecheck { refreshed: ContextManifest },
154}
155
156impl ContextManifest {
157 /// Begin a capture anchored at `entry`, stamping the time *before*
158 /// anything is observed.
159 ///
160 /// The order matters: a stat taken before the stamp could miss a write that
161 /// landed between the two and still be called settled.
162 pub fn begin(entry: PathBuf) -> Self {
163 Self {
164 entry,
165 captured_ns: module_source::now_ns(),
166 files: Vec::new(),
167 unresolved: Vec::new(),
168 unreadable: Vec::new(),
169 }
170 }
171
172 /// Whether this manifest describes the graph reachable from `entry`, and
173 /// that graph still looks exactly as it did when the manifest was written.
174 ///
175 /// Conservative in every direction: anything unreadable, ambiguous, or
176 /// changed reports `false` and costs a walk.
177 pub fn still_valid(&self, entry: &Path) -> bool {
178 !matches!(self.check(entry), ManifestCheck::Stale)
179 }
180
181 /// As [`Self::still_valid`], but also reports whether the answer needed a
182 /// content read, so a caller holding the artifact can re-stamp it.
183 pub fn check(&self, entry: &Path) -> ManifestCheck {
184 // The anchor first: it is a comparison, where everything below is at
185 // least a stat. The observations prove only that some set of files is
186 // unchanged, never that it is *this* entry's set (#5591).
187 if self.entry != entry {
188 return ManifestCheck::Stale;
189 }
190 // Sampled before the stats below, so the refreshed stamp is as
191 // trustworthy as one a fresh walk would produce.
192 let captured_ns = module_source::now_ns();
193 let mut rechecked = false;
194 for file in &self.files {
195 match file.check(self.captured_ns) {
196 FileCheck::Stale => return ManifestCheck::Stale,
197 FileCheck::Settled => {}
198 FileCheck::Rechecked => rechecked = true,
199 }
200 }
201 if !self
202 .unresolved
203 .iter()
204 .all(ManifestUnresolved::still_unresolved)
205 || !self
206 .unreadable
207 .iter()
208 .all(ManifestUnreadable::still_unreadable)
209 {
210 return ManifestCheck::Stale;
211 }
212 if rechecked {
213 ManifestCheck::ValidAfterRecheck {
214 refreshed: Self {
215 captured_ns,
216 ..self.clone()
217 },
218 }
219 } else {
220 ManifestCheck::Valid
221 }
222 }
223}
224
225/// A re-checked manifest, indexed the way module loading asks questions:
226/// canonical path to the source digest and imported-interface context that
227/// together name that module's artifact.
228///
229/// The walk that built the manifest read and digested every reachable file.
230/// Proving that manifest current proves those digests still describe the bytes
231/// on disk. The same graph walk records the imported names and kinds that can
232/// alter lowering. So a validated manifest already holds what module loading
233/// was re-reading and rebuilding the whole graph to rediscover: not dependency
234/// bodies, which independently key their artifacts, but complete identities.
235///
236/// A table is a shortcut, never an authority. Its digest names an artifact;
237/// whether one is on disk under that name is a separate question, and a module
238/// whose artifact was evicted — or which the graph never reached — falls back to
239/// being read and compiled.
240///
241/// Only [`crate::bytecode_cache::load`] can build one, at the point where its
242/// own re-check succeeded. That is what the table's existence means, and it is
243/// why there is no way to assemble one from observations nothing has validated.
244#[derive(Debug)]
245pub struct GraphLinkTable {
246 module_identity_by_path: HashMap<PathBuf, ([u8; 32], ModuleCompilationContext)>,
247}
248
249impl GraphLinkTable {
250 pub(crate) fn from_validated(manifest: &ContextManifest) -> Self {
251 Self {
252 module_identity_by_path: manifest
253 .files
254 .iter()
255 .map(|file| {
256 (
257 file.path.clone(),
258 (file.content_hash, file.compilation_context.clone()),
259 )
260 })
261 .collect(),
262 }
263 }
264
265 /// The complete cache identity recorded for `canonical`, or `None` when
266 /// this graph does not contain it.
267 pub(crate) fn module_identity(
268 &self,
269 canonical: &Path,
270 ) -> Option<([u8; 32], ModuleCompilationContext)> {
271 self.module_identity_by_path.get(canonical).cloned()
272 }
273}
274
275/// What a re-check concluded about one recorded file.
276enum FileCheck {
277 /// Stats matched and the entry was old enough for stats to be proof.
278 Settled,
279 /// Stats matched but the entry was racily clean, and its content confirmed
280 /// it.
281 Rechecked,
282 Stale,
283}
284
285impl ManifestFile {
286 /// Record `path` as observed on disk now, carrying the digest of the
287 /// `source` the walk folded into the context hash. `None` if the file
288 /// cannot be stat'ed — a file we cannot describe is one we must not claim
289 /// is unchanged.
290 ///
291 /// The digest comes from the caller's already-read bytes rather than from a
292 /// re-read here: it has to describe the version that went into the hash,
293 /// not whatever a second read would find.
294 pub fn observe(path: &Path, source: &ModuleSource) -> Option<Self> {
295 let (len, mtime_ns) = module_source::stat_identity(path)?;
296 Some(Self {
297 path: path.to_path_buf(),
298 len,
299 mtime_ns,
300 content_hash: source.sha256(),
301 compilation_context: ModuleCompilationContext::default(),
302 shadow: shadow_path(path),
303 })
304 }
305
306 fn check(&self, captured_ns: i128) -> FileCheck {
307 let Some((len, mtime_ns)) = module_source::stat_identity(&self.path) else {
308 return FileCheck::Stale;
309 };
310 if len != self.len || mtime_ns != self.mtime_ns {
311 return FileCheck::Stale;
312 }
313 if self.shadow.as_ref().is_some_and(|shadow| shadow.exists()) {
314 return FileCheck::Stale;
315 }
316 if module_source::mtime_predates_capture(mtime_ns, captured_ns) {
317 return FileCheck::Settled;
318 }
319 if self.content_matches() {
320 FileCheck::Rechecked
321 } else {
322 FileCheck::Stale
323 }
324 }
325
326 /// Whether the file still holds the bytes whose digest was recorded.
327 ///
328 /// Deliberately not [`module_source::read`]: that memo is keyed on the very
329 /// `(path, len, mtime_ns)` triple this entry just failed to trust, so a hit
330 /// would answer with the same bytes the racy window let through. Reading as
331 /// a string mirrors how the recorded digest was produced, so a file that
332 /// stopped being UTF-8 reads as changed.
333 fn content_matches(&self) -> bool {
334 let Ok(text) = std::fs::read_to_string(&self.path) else {
335 return false;
336 };
337 let mut hasher = Sha256::new();
338 hasher.update(text.as_bytes());
339 let digest: [u8; 32] = hasher.finalize().into();
340 digest == self.content_hash
341 }
342}
343
344impl ManifestUnreadable {
345 pub(crate) fn still_unreadable(&self) -> bool {
346 match module_source::read(&self.path) {
347 Ok(_) => false,
348 Err(error) => error.kind().to_string() == self.kind,
349 }
350 }
351}
352
353impl ManifestUnresolved {
354 pub(crate) fn still_unresolved(&self) -> bool {
355 harn_modules::resolve_import_path(&self.anchor, &self.import).is_none()
356 }
357}
358
359/// The extensionless path that would shadow `path`, for `*.harn` files only.
360fn shadow_path(path: &Path) -> Option<PathBuf> {
361 if path.extension()? != "harn" {
362 return None;
363 }
364 let mut shadow = path.to_path_buf();
365 shadow.set_extension("");
366 Some(shadow)
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372
373 fn write(path: &Path, body: &str) {
374 if let Some(parent) = path.parent() {
375 std::fs::create_dir_all(parent).unwrap();
376 }
377 std::fs::write(path, body).unwrap();
378 }
379
380 /// The entry every manifest in these tests is anchored at.
381 ///
382 /// They vary what the walk observed, not which entry it walked from, so one
383 /// constant anchor keeps the anchor out of their way. What the anchor itself
384 /// decides is pinned by `an_anchor_mismatch_invalidates` and, end to end,
385 /// by `bytecode_cache_tests`.
386 fn anchor() -> PathBuf {
387 PathBuf::from("/harn/tests/entry.harn")
388 }
389
390 fn manifest_for(paths: &[PathBuf]) -> ContextManifest {
391 ContextManifest {
392 entry: anchor(),
393 files: paths
394 .iter()
395 .map(|p| {
396 let source = ModuleSource::from_text(std::fs::read_to_string(p).unwrap());
397 ManifestFile::observe(p, &source).expect("observe")
398 })
399 .collect(),
400 ..ContextManifest::begin(anchor())
401 }
402 }
403
404 /// Re-checks under the anchor the manifest was built at.
405 fn revalidates(manifest: &ContextManifest) -> bool {
406 manifest.still_valid(&anchor())
407 }
408
409 /// Stamp `path`'s mtime. Every timestamp this module reasons about is
410 /// controlled outright rather than waited for, so the tests neither sleep
411 /// nor depend on how finely the host filesystem happens to keep time.
412 fn set_mtime(path: &Path, when: std::time::SystemTime) {
413 std::fs::File::options()
414 .write(true)
415 .open(path)
416 .unwrap()
417 .set_times(std::fs::FileTimes::new().set_modified(when))
418 .unwrap();
419 }
420
421 fn mtime_of(path: &Path) -> std::time::SystemTime {
422 std::fs::metadata(path).unwrap().modified().unwrap()
423 }
424
425 /// A manifest over files old enough that stats alone are proof — the
426 /// ordinary case, and the one the fast path exists for.
427 fn settled_manifest_for(paths: &[PathBuf]) -> ContextManifest {
428 for path in paths {
429 // Relative to the file's own mtime, so the ageing is expressed in
430 // the filesystem's clock rather than a second reading of the host's.
431 set_mtime(path, mtime_of(path) - std::time::Duration::from_hours(1));
432 }
433 let manifest = manifest_for(paths);
434 for file in &manifest.files {
435 assert!(
436 module_source::mtime_predates_capture(file.mtime_ns, manifest.captured_ns),
437 "{} must be outside the racy window for this helper to mean anything",
438 file.path.display()
439 );
440 }
441 manifest
442 }
443
444 /// Put `manifest`'s capture in the same timestamp tick as its first entry's
445 /// mtime — what a coarse filesystem does on its own (NTFS quantizes to a
446 /// ~15.6ms clock tick, FAT to two seconds) and what a nanosecond-resolution
447 /// APFS or ext4 would essentially never produce, which is why the tests
448 /// state it rather than hoping for it.
449 fn place_inside_racy_window(manifest: &mut ContextManifest) {
450 manifest.captured_ns = manifest.files[0].mtime_ns;
451 assert!(
452 !module_source::mtime_predates_capture(
453 manifest.files[0].mtime_ns,
454 manifest.captured_ns
455 ),
456 "the entry must be racily clean for the guard to be under test"
457 );
458 }
459
460 #[test]
461 fn an_unchanged_graph_stays_valid() {
462 let tmp = tempfile::tempdir().unwrap();
463 let dep = tmp.path().join("dep.harn");
464 write(&dep, "pub fn v() -> int { return 1 }\n");
465 assert!(revalidates(&manifest_for(&[dep])));
466 }
467
468 #[test]
469 fn a_same_length_edit_invalidates() {
470 // Two writes inside one filesystem timestamp tick record one mtime, so
471 // when the second preserves byte length the recorded `(len, mtime_ns)`
472 // is identical and stats have nothing left to notice. Windows hits this
473 // routinely; the agent edits Harn exists to orchestrate are exactly the
474 // fast programmatic writes that land inside a tick. The capture time is
475 // what makes it decidable: an entry that was not already settled when
476 // the manifest was taken is judged by content.
477 let tmp = tempfile::tempdir().unwrap();
478 let dep = tmp.path().join("dep.harn");
479 write(&dep, "pub fn v() -> int { return 111 }\n");
480 let mut manifest = manifest_for(&[dep.clone()]);
481 place_inside_racy_window(&mut manifest);
482
483 let recorded = mtime_of(&dep);
484 write(&dep, "pub fn v() -> int { return 222 }\n");
485 set_mtime(&dep, recorded);
486 assert_eq!(
487 module_source::stat_identity(&dep).unwrap(),
488 (manifest.files[0].len, manifest.files[0].mtime_ns),
489 "the edit must leave a byte-identical stat identity, or this test \
490 would pass on the stats path and prove nothing"
491 );
492
493 assert!(
494 !revalidates(&manifest),
495 "an edit of identical length must still invalidate the manifest"
496 );
497 }
498
499 #[test]
500 fn a_settled_entry_is_proven_by_stats_without_reading_content() {
501 // The counterweight to the racy-window check: the overwhelming majority
502 // of entries are old enough that stats decide, and they must not start
503 // paying a read. Proven by recording a digest that cannot match — a
504 // check that consulted content would reject this manifest, and one that
505 // needed a re-stamp would report `ValidAfterRecheck`.
506 let tmp = tempfile::tempdir().unwrap();
507 let dep = tmp.path().join("dep.harn");
508 write(&dep, "pub fn v() -> int { return 1 }\n");
509 let mut manifest = settled_manifest_for(&[dep]);
510 manifest.files[0].content_hash = [0xAB; 32];
511
512 assert!(
513 matches!(manifest.check(&anchor()), ManifestCheck::Valid),
514 "a settled entry must be decided by stats alone"
515 );
516 }
517
518 #[test]
519 fn an_edit_that_restores_a_settled_mtime_is_the_documented_gap() {
520 // The stated limit of the whole scheme, made executable so it stays a
521 // decision rather than a footnote. Once an entry is settled, stats are
522 // treated as proof and content is never consulted — so an edit that
523 // preserves length and puts the old, already-settled mtime back is not
524 // noticed. Unlike the racy window this replaces, that takes deliberate
525 // timestamp forgery rather than an ordinary fast write, which is the
526 // same line Cargo, Zig and Bazel draw.
527 //
528 // If this ever fails, the identity was strengthened and this test
529 // should be deleted on purpose, not repaired.
530 let tmp = tempfile::tempdir().unwrap();
531 let dep = tmp.path().join("dep.harn");
532 write(&dep, "pub fn v() -> int { return 111 }\n");
533 let manifest = settled_manifest_for(&[dep.clone()]);
534
535 let settled = mtime_of(&dep);
536 write(&dep, "pub fn v() -> int { return 222 }\n");
537 set_mtime(&dep, settled);
538 assert_eq!(
539 module_source::stat_identity(&dep).unwrap(),
540 (manifest.files[0].len, manifest.files[0].mtime_ns),
541 "the forgery must leave a byte-identical stat identity, or this \
542 test proves nothing"
543 );
544
545 assert!(
546 revalidates(&manifest),
547 "a settled entry is decided by stats, so a forged timestamp is not \
548 noticed; if this now fails the trade-off changed and the test \
549 should be removed deliberately"
550 );
551 }
552
553 #[test]
554 fn a_racy_entry_that_still_matches_settles_instead_of_re_reading_forever() {
555 // A racily clean entry that checks out is not a miss: the graph is
556 // unchanged, and re-stamping the manifest with this check's capture
557 // time moves the entry onto the stats path for later spawns. Without
558 // that, every future spawn would re-read the same file.
559 let tmp = tempfile::tempdir().unwrap();
560 let dep = tmp.path().join("dep.harn");
561 write(&dep, "pub fn v() -> int { return 1 }\n");
562 let mut manifest = manifest_for(&[dep.clone()]);
563 place_inside_racy_window(&mut manifest);
564
565 // Rewritten with the same bytes and stamped back, so only content can
566 // tell this apart from the edit above.
567 let recorded = mtime_of(&dep);
568 write(&dep, "pub fn v() -> int { return 1 }\n");
569 set_mtime(&dep, recorded);
570
571 let ManifestCheck::ValidAfterRecheck { refreshed } = manifest.check(&anchor()) else {
572 panic!("a racy entry whose content matches must validate, and report the re-check");
573 };
574 assert!(
575 refreshed.captured_ns > manifest.captured_ns,
576 "the re-stamped manifest must carry the newer capture"
577 );
578 assert_eq!(
579 refreshed.files, manifest.files,
580 "re-stamping must not disturb the observations themselves"
581 );
582
583 // A later spawn re-checks the re-stamped manifest, and once its capture
584 // is a full granularity past the write the entry is decided by stats.
585 // Advancing the recorded capture stands in for that elapsed time rather
586 // than sleeping through it.
587 let later = ContextManifest {
588 captured_ns: refreshed.captured_ns + module_source::TIMESTAMP_GRANULARITY_NS,
589 ..refreshed
590 };
591 assert!(
592 matches!(later.check(&anchor()), ManifestCheck::Valid),
593 "an entry the racy window has moved past must return to the stats path"
594 );
595 }
596
597 #[test]
598 fn a_deleted_file_invalidates() {
599 let tmp = tempfile::tempdir().unwrap();
600 let dep = tmp.path().join("dep.harn");
601 write(&dep, "pub fn v() -> int { return 1 }\n");
602 let manifest = manifest_for(&[dep.clone()]);
603 std::fs::remove_file(&dep).unwrap();
604 assert!(!revalidates(&manifest));
605 }
606
607 #[test]
608 fn a_directory_that_would_shadow_the_module_invalidates() {
609 // Refactoring `dep.harn` into `dep/` re-points every `import "./dep"`
610 // without touching dep.harn, because the resolver probes the
611 // extensionless path first. Nothing about the recorded file changes,
612 // so only the shadow check can catch it.
613 let tmp = tempfile::tempdir().unwrap();
614 let dep = tmp.path().join("dep.harn");
615 write(&dep, "pub fn v() -> int { return 1 }\n");
616 let manifest = manifest_for(&[dep]);
617 assert!(revalidates(&manifest));
618
619 std::fs::create_dir(tmp.path().join("dep")).unwrap();
620 assert!(
621 !revalidates(&manifest),
622 "a directory shadowing the module file must invalidate the manifest"
623 );
624 }
625
626 #[test]
627 fn an_import_that_starts_resolving_invalidates() {
628 // The mirror of the file checks: no recorded file changes at all, but
629 // the graph gains a dependency it did not have.
630 let tmp = tempfile::tempdir().unwrap();
631 let entry = tmp.path().join("entry.harn");
632 write(&entry, "import \"./late\"\n");
633 let manifest = ContextManifest {
634 unresolved: vec![ManifestUnresolved {
635 anchor: entry,
636 import: "./late".to_string(),
637 }],
638 ..ContextManifest::begin(anchor())
639 };
640 assert!(revalidates(&manifest));
641
642 write(
643 &tmp.path().join("late.harn"),
644 "pub fn l() -> int { return 1 }\n",
645 );
646 assert!(
647 !revalidates(&manifest),
648 "an import that now resolves must invalidate the manifest"
649 );
650 }
651
652 #[test]
653 fn an_anchor_mismatch_invalidates() {
654 // Every observation can be immaculate and the manifest still describe
655 // the wrong graph, because which files an import reaches depends on
656 // where the walk started. Nothing else in the manifest can notice that:
657 // the recorded paths are absolute and re-check clean from anywhere.
658 let tmp = tempfile::tempdir().unwrap();
659 let dep = tmp.path().join("dep.harn");
660 write(&dep, "pub fn v() -> int { return 1 }\n");
661 let manifest = manifest_for(&[dep]);
662
663 assert!(revalidates(&manifest), "unchanged under its own anchor");
664 assert!(
665 !manifest.still_valid(Path::new("/harn/tests/elsewhere/entry.harn")),
666 "a manifest must not vouch for an entry it was not walked from"
667 );
668 }
669
670 #[test]
671 fn a_file_without_a_harn_extension_has_no_shadow() {
672 let tmp = tempfile::tempdir().unwrap();
673 let odd = tmp.path().join("dep");
674 let body = "pub fn v() -> int { return 1 }\n";
675 write(&odd, body);
676 let source = ModuleSource::from_text(body);
677 assert_eq!(ManifestFile::observe(&odd, &source).unwrap().shadow, None);
678 }
679}