Skip to main content

memstead_git_branch/
mem_cache.rs

1//! Read-mem cache resolution, published-config reads, and the
2//! install-to-cache side effect.
3//!
4//! Every sealed-archive byte entering the cache goes through
5//! `validate_and_normalize_archive` — the install path reads the
6//! submitted archive, hands the bytes to the validator, and writes the
7//! validator's `canonical_bytes` via a temp-plus-atomic-rename so no
8//! partial archive ever lands on disk. Steady-state loads (through
9//! `read_published_config` or the entity loader) trust the cached
10//! bytes: they were canonical at write time and re-validation on every
11//! load would just pay for the same work twice.
12//!
13//! The cache base path resolves via `dirs::data_dir()` so the same path
14//! works on macOS (`~/Library/Application Support/memstead/mems`), Linux
15//! (`$XDG_DATA_HOME/memstead/mems` or `~/.local/share/memstead/mems`), and
16//! Windows (`%APPDATA%\memstead\mems`). For tests, `MEMSTEAD_MEM_CACHE`
17//! overrides the base so temp dirs can stand in without touching the
18//! user's real data directory.
19
20use std::io::Read as _;
21use std::path::{Path, PathBuf};
22
23use memstead_base::ops::WarningHint;
24use memstead_schema::{
25    ARCHIVE_CONFIG_PATH, ARCHIVE_EXTENSION, ARCHIVE_SCHEMA_PREFIX, PublishedMemConfig, SchemaRef,
26    SchemaRegistry,
27};
28
29use crate::entity::loader::LoadError;
30use crate::mem_repo_config::MemRepoWriteError;
31use crate::validator::{ValidationError, validate_and_normalize_archive};
32
33/// Env var that overrides `<data_dir>/memstead/mems` for tests.
34pub const CACHE_OVERRIDE_ENV: &str = "MEMSTEAD_MEM_CACHE";
35
36/// Resolve the global mem-cache directory.
37///
38/// Respects `MEMSTEAD_MEM_CACHE` if set — tests use this to point at a
39/// tempdir without touching the real user-data directory. Otherwise
40/// returns `<data_dir>/memstead/mems` on every platform (macOS / Linux /
41/// Windows), so the CLI and the Memstead app resolve to the same path
42/// without per-platform branching.
43///
44/// `dirs::data_dir()` is infallible on Tier-1 platforms; `expect` is
45/// fine for an engine that only runs on systems with a resolvable home.
46pub fn mem_cache_dir() -> PathBuf {
47    if let Ok(override_path) = std::env::var(CACHE_OVERRIDE_ENV)
48        && !override_path.is_empty()
49    {
50        return PathBuf::from(override_path);
51    }
52    dirs::data_dir()
53        .expect("platform provides a data directory")
54        .join("memstead")
55        .join("mems")
56}
57
58/// Read the whitelisted `.memstead/config.json` from a cached archive.
59///
60/// Does **not** re-run full archive validation — the cache only
61/// contains bytes the validator already approved, so entity parse and
62/// graph construction can be deferred to the caller. Configs are
63/// re-parsed with `parse_config_bytes` so the strict-ingress shape is
64/// enforced here as defense-in-depth against a tampered cache file.
65pub fn read_published_config(archive_path: &Path) -> Result<PublishedMemConfig, LoadError> {
66    if !archive_path.is_file() {
67        return Err(LoadError::ArchiveNotFound(
68            archive_path.display().to_string(),
69        ));
70    }
71    let file = std::fs::File::open(archive_path)?;
72    let mut archive = zip::ZipArchive::new(file)?;
73
74    // Take the mutable entry borrow only if the config member is
75    // present (`by_name` holds `&mut archive`).
76    let config_name = ARCHIVE_CONFIG_PATH;
77    if archive.index_for_name(config_name).is_none() {
78        return Err(LoadError::InvalidArchive(format!(
79            "missing {ARCHIVE_CONFIG_PATH} in {}",
80            archive_path.display()
81        )));
82    }
83    let mut entry = archive.by_name(config_name).map_err(|e| {
84        LoadError::InvalidArchive(format!(
85            "reading {config_name} in {}: {e}",
86            archive_path.display()
87        ))
88    })?;
89
90    let mut bytes = Vec::new();
91    entry.read_to_end(&mut bytes)?;
92
93    crate::validator::config::parse_config_bytes(&bytes).map_err(|e| {
94        LoadError::InvalidArchive(format!(
95            "invalid {ARCHIVE_CONFIG_PATH} in {}: {e}",
96            archive_path.display()
97        ))
98    })
99}
100
101#[derive(Debug, thiserror::Error)]
102pub enum InstallError {
103    #[error("could not read mem archive: {0}")]
104    Archive(#[from] LoadError),
105    #[error("io error while installing mem: {0}")]
106    Io(#[from] std::io::Error),
107    #[error("config error while registering mem: {0}")]
108    Config(#[from] memstead_schema::config::ConfigError),
109    #[error("archive failed strict validation: {0}")]
110    Validation(ValidationError),
111    /// Mem-db tree write failed. Carries the underlying gix error
112    /// message so callers can surface it without wrapping the variant.
113    #[error("mem-repo tree write failed: {0}")]
114    MemRepo(#[from] MemRepoWriteError),
115    /// The archive's authoritative mem name (carried in its canonical
116    /// config) matches a writable mount that already exists in this
117    /// workspace. A read-only mount cannot share a writable mount's
118    /// name (the archive's internal name is its sole identity and
119    /// nothing can rename it at install time), so the install surface
120    /// refuses up-front. The genuine recovery is to rename or
121    /// unregister the writable mount that shadows the archive's
122    /// internal name.
123    #[error(
124        "archive's mem name `{archive_name}` already exists as a writable mount in this workspace; \
125         rename the writable mount (`memstead mem rename`) or unregister it first — the archive's \
126         internal name is its sole identity and cannot be changed at install time"
127    )]
128    ShadowsWritable {
129        archive_name: String,
130        shadows_writable: String,
131    },
132    // `CacheNameCollision` was retired once the cache became
133    // content-addressed (`<name>-<content_key>.mem`): distinct bytes
134    // under the same mem name land in distinct files and the collision
135    // class it guarded no longer exists. No engine surface can produce it.
136}
137
138/// Short content-address for an installed archive: the first 16 hex chars
139/// of `sha256(canonical_bytes)`. Used as the cache-file key
140/// (`<name>-<key>.mem`) and recorded in the `readMems` registration so
141/// the loader resolves the right file. 64 bits is ample collision
142/// resistance for a per-user cache; the same convention (truncated SHA-256
143/// hex) the entity content-hash uses.
144fn content_cache_key(canonical_bytes: &[u8]) -> String {
145    use sha2::{Digest, Sha256};
146    let digest = Sha256::digest(canonical_bytes);
147    digest[..8].iter().map(|b| format!("{b:02x}")).collect()
148}
149
150/// Install a sealed mem archive into the global cache and register
151/// it in a writable mem's config. Accepts the `.mem` archive format.
152///
153/// Two independent side effects, both idempotent:
154///
155/// 1. If the content-addressed cache file does not exist: run the submitted bytes
156///    through `validate_and_normalize_archive` and write the
157///    validator's `canonical_bytes` via a `.tmp` sibling + atomic
158///    rename. A mid-write crash leaves the temp file behind, never a
159///    partial cache file. Existing cache files are left untouched —
160///    overwrite-on-newer-version is an app-level update flow, not a
161///    CLI install semantic. Users who want to force-replace can delete
162///    the cache file first.
163/// 2. If the target mem's config does not already list this mem
164///    under `readMems`, add an entry with `source: { type: "local" }`.
165///    Existing entries are left untouched so re-running install never
166///    clobbers a `type: "url"` (etc.) source the user configured by hand.
167///
168/// The `target` parameter selects where the registration lands:
169/// - `TargetMem::Disk(mem_dir)` writes the updated config back to
170///   `<mem_dir>/.memstead/config.json` (legacy disk shape).
171/// - `TargetMem::MemRepo { workspace_root, mem_name }` commits the
172///   updated `configs/<mem_name>.json` to `mem-repo-git:main` (post-
173///   cutover shape).
174///
175/// `ctx` and `commit_message` are used only by the `MemRepo` arm —
176/// the disk arm rewrites the file via the existing config-update path
177/// which has its own (file-mtime-based) provenance trail.
178///
179/// Returns an `InstallOutcome` describing which effects fired. The
180/// authoritative mem name comes from the validator's approved
181/// config, not from the submitted filename or caller argument.
182/// Outcome of [`install_to_cache`] — the cache-side half of an
183/// install, with everything the caller needs to register the archive
184/// as a workspace-level read-only mount.
185#[derive(Debug, Clone)]
186pub struct CacheInstallOutcome {
187    /// Mem name, taken from the validator's approved config — the
188    /// archive's sole identity.
189    pub mem_name: String,
190    /// The archive's schema pin, from its bundled config.
191    pub schema: memstead_schema::SchemaRef,
192    /// Content-addressed cache file the mount's `Archive` storage
193    /// points at.
194    pub cache_path: PathBuf,
195    /// The content digest half of the cache filename.
196    pub cache_key: String,
197    /// `true` if canonical bytes were written on this call; `false`
198    /// on the idempotent dedup path.
199    pub copied_to_cache: bool,
200    /// Typed non-fatal issues surfaced by the install.
201    pub warnings: Vec<WarningHint>,
202}
203
204/// Validate an archive and land it in the global content-addressed
205/// cache — the cache-side half of `memstead install`, with **no
206/// config or mount side effects** (the caller registers the returned
207/// archive as a workspace-level read-only mount). Shares the
208/// validator, the shadow-name gate, and the content-addressed
209/// atomic-rename write with the historical combined path.
210pub fn install_to_cache(
211    archive_path: &Path,
212    writable_mem_names: &[&str],
213) -> Result<CacheInstallOutcome, InstallError> {
214    let bytes = std::fs::read(archive_path)?;
215    let validated = validate_and_normalize_archive(&bytes).map_err(InstallError::Validation)?;
216
217    if let Some(shadowed) = writable_mem_names
218        .iter()
219        .find(|n| **n == validated.config.name.as_str())
220    {
221        return Err(InstallError::ShadowsWritable {
222            archive_name: validated.config.name.clone(),
223            shadows_writable: (*shadowed).to_string(),
224        });
225    }
226
227    let cache_dir = mem_cache_dir();
228    std::fs::create_dir_all(&cache_dir)?;
229    let cache_key = content_cache_key(&validated.canonical_bytes);
230    let dest = cache_dir.join(format!(
231        "{}-{}.{ARCHIVE_EXTENSION}",
232        validated.config.name, cache_key
233    ));
234    let copied_to_cache = if dest.exists() {
235        false
236    } else {
237        let tmp = dest.with_extension(format!("{ARCHIVE_EXTENSION}.tmp"));
238        std::fs::write(&tmp, &validated.canonical_bytes)?;
239        std::fs::rename(&tmp, &dest)?;
240        true
241    };
242
243    Ok(CacheInstallOutcome {
244        mem_name: validated.config.name,
245        schema: validated.config.schema.clone(),
246        cache_path: dest,
247        cache_key,
248        copied_to_cache,
249        warnings: Vec::new(),
250    })
251}
252
253/// What happened on the mount-registration side of an install.
254#[derive(Debug, Clone, Copy, PartialEq, Eq)]
255pub enum MountRegistration {
256    /// Fresh registration.
257    Registered,
258    /// Same name, same content-addressed cache file — nothing to do.
259    AlreadyRegistered,
260    /// Same name, new content — the mount was re-pointed at the new
261    /// cache file.
262    Refreshed,
263}
264
265/// Register a cached archive (the outcome of [`install_to_cache`]) as
266/// a workspace-level read-only mount on the live engine — the shared
267/// back half of `memstead install` and the MCP server's `--read-mem`
268/// boot flag. Idempotent per content: an existing read-only mount
269/// under the same name is a no-op when it already points at this
270/// cache file, and an in-place refresh (unregister + re-register)
271/// when the content changed. The caller persists the mount state
272/// (`engine.persist_state()`) after a `Registered` / `Refreshed`
273/// outcome.
274pub fn register_cached_archive(
275    engine: &mut memstead_base::Engine,
276    outcome: &CacheInstallOutcome,
277    by_tool: &'static str,
278) -> Result<MountRegistration, memstead_base::EngineError> {
279    let registration = match engine.mount(&outcome.mem_name) {
280        Some(existing) if existing.capability == memstead_base::MountCapability::ReadOnly => {
281            match &existing.storage {
282                memstead_base::MountStorage::Archive { path } if *path == outcome.cache_path => {
283                    return Ok(MountRegistration::AlreadyRegistered);
284                }
285                _ => {
286                    engine.unregister_read_mount(&outcome.mem_name)?;
287                    MountRegistration::Refreshed
288                }
289            }
290        }
291        // A writable mount of the same name is the caller's shadow
292        // gate's business (install_to_cache refuses it up-front).
293        _ => MountRegistration::Registered,
294    };
295
296    let mount = memstead_base::Mount {
297        mem: outcome.mem_name.clone(),
298        schema: Some(outcome.schema.clone()),
299        storage: memstead_base::MountStorage::Archive {
300            path: outcome.cache_path.clone(),
301        },
302        capability: memstead_base::MountCapability::ReadOnly,
303        lifecycle: memstead_base::MountLifecycle::Eager,
304        cross_linkable: false,
305        migration_target: None,
306    };
307    let backend: Box<dyn memstead_base::MemBackend> = Box::new(
308        memstead_base::storage::ArchiveBackend::new(outcome.cache_path.clone()),
309    );
310    let origin = memstead_base::MemOrigin::RuntimeCreated {
311        at: std::time::SystemTime::now(),
312        by_tool,
313    };
314    engine.register_read_mount(mount, backend, origin)?;
315    Ok(registration)
316}
317
318/// Outcome of `extract_archive_schema_if_needed` — so callers can log
319/// the specific reason a no-op happened, or know whether the mem's
320/// schema registry needs to be rebuilt.
321#[derive(Debug, Clone, PartialEq, Eq)]
322pub enum SchemaExtractionOutcome {
323    /// The archive's pinned schema is already registered — extraction
324    /// skipped. Author-layer schemas always shadow cache entries, so
325    /// skipping when the registry already knows the pin preserves the
326    /// documented precedence order.
327    AlreadyRegistered,
328    /// The archive carries no `.memstead/schema/` tree. Loading still works
329    /// if the pin happens to be in the registry; otherwise the normal
330    /// `resolve_mem_schema` path reports the missing schema with its
331    /// actionable error.
332    NoEmbeddedSchema,
333    /// A cache entry at
334    /// `<workspace_root>/.memstead.cache/schemas/<name>-<version>/`
335    /// already existed on disk — extraction skipped, but the registry
336    /// may still need a rebuild if the caller hadn't picked it up yet.
337    CacheAlreadyPopulated,
338    /// Fresh extraction wrote files into
339    /// `<workspace_root>/.memstead.cache/schemas/<name>-<version>/`. Caller
340    /// must rebuild the `SchemaRegistry` to pick it up.
341    Extracted { schema: SchemaRef, path: PathBuf },
342}
343
344#[derive(Debug, thiserror::Error)]
345pub enum SchemaExtractionError {
346    #[error("could not read mem archive {}: {source}", .archive_path.display())]
347    Archive {
348        archive_path: PathBuf,
349        #[source]
350        source: LoadError,
351    },
352    #[error("archive {} failed strict validation: {source}", .archive_path.display())]
353    Validation {
354        archive_path: PathBuf,
355        #[source]
356        source: ValidationError,
357    },
358    #[error("i/o error extracting schema to {}: {source}", .path.display())]
359    Io {
360        path: PathBuf,
361        #[source]
362        source: std::io::Error,
363    },
364}
365
366/// Extract the schema embedded in `archive_path` into the writable
367/// mem's cache, but only when the pinned `(name, version)` is not
368/// already registered.
369///
370/// Idempotent: repeated calls on the same archive are safe. Runs the
371/// archive through `validate_and_normalize_archive` — which enforces
372/// embedded-schema integrity (the loader-based manifest check + name/
373/// version match against `.memstead/config.json`), so a corrupt schema
374/// surfaces here as a `Validation` error instead of silently polluting
375/// the cache.
376///
377/// The extraction path is atomic: files are written into a sibling
378/// `.tmp` directory and renamed into place only after every byte has
379/// landed. A mid-write crash leaves the `.tmp` sibling behind, never
380/// a half-populated `<name>-<version>/` that a subsequent
381/// `SchemaRegistry::load_for_mem` might try to load.
382pub fn extract_archive_schema_if_needed(
383    archive_path: &Path,
384    workspace_root: &Path,
385    registry: &SchemaRegistry,
386) -> Result<SchemaExtractionOutcome, SchemaExtractionError> {
387    // Cheap prefix pass: read only the archive's published config so we can skip
388    // the full validation for archives whose pin is already in the
389    // registry (the common case for repeat loads).
390    let config =
391        read_published_config(archive_path).map_err(|source| SchemaExtractionError::Archive {
392            archive_path: archive_path.to_path_buf(),
393            source,
394        })?;
395    if registry
396        .get(&config.schema.name, &config.schema.version)
397        .is_some()
398    {
399        return Ok(SchemaExtractionOutcome::AlreadyRegistered);
400    }
401
402    let dest = workspace_root
403        .join(".memstead.cache/schemas")
404        .join(format!("{}-{}", config.schema.name, config.schema.version));
405    if dest.is_dir() {
406        // Someone already extracted; the registry just hasn't rebuilt
407        // with the cache pass yet. Caller rebuilds.
408        return Ok(SchemaExtractionOutcome::CacheAlreadyPopulated);
409    }
410
411    // Full validation — loads the archive, validates the embedded schema
412    // via `check_embedded_schema`, produces canonical bytes. We only
413    // need the schema files, but paying for the full pipeline once on
414    // cache-miss is correct: an attacker who drops a tampered archive
415    // into the global cache doesn't get to seed the workspace from an
416    // unvalidated payload.
417    let bytes = std::fs::read(archive_path).map_err(|source| SchemaExtractionError::Io {
418        path: archive_path.to_path_buf(),
419        source,
420    })?;
421    let validated = validate_and_normalize_archive(&bytes).map_err(|source| {
422        SchemaExtractionError::Validation {
423            archive_path: archive_path.to_path_buf(),
424            source,
425        }
426    })?;
427
428    if validated.schema_files.is_empty() {
429        return Ok(SchemaExtractionOutcome::NoEmbeddedSchema);
430    }
431
432    extract_schema_files_atomic(&validated.schema_files, &dest).map_err(|source| {
433        SchemaExtractionError::Io {
434            path: dest.clone(),
435            source,
436        }
437    })?;
438
439    Ok(SchemaExtractionOutcome::Extracted {
440        schema: config.schema,
441        path: dest,
442    })
443}
444
445/// Write `schema_files` to `dest` via a sibling `.tmp` directory that
446/// is renamed into place once every file has been written. Rename
447/// atomicity varies by FS but every supported target (ext4, HFS+, APFS,
448/// NTFS) gives us "dest contains every file or nothing," which is the
449/// invariant the load path relies on. The incoming paths always start
450/// with `.memstead/schema/` (legacy archives are normalized at extract
451/// time) — we strip that prefix so the on-disk layout matches the
452/// schema-cache shape `schema.yaml` + `types/<t>.yaml` exactly.
453fn extract_schema_files_atomic(
454    schema_files: &[crate::validator::archive::SchemaFile],
455    dest: &Path,
456) -> std::io::Result<()> {
457    let parent = dest
458        .parent()
459        .ok_or_else(|| std::io::Error::other("schema cache destination has no parent directory"))?;
460    std::fs::create_dir_all(parent)?;
461
462    // Sibling tmp dir name is dot-prefixed so `list_schema_subdirs`
463    // ignores it if a crash between write and rename leaves a straggler.
464    // PID + monotonic counter guarantees uniqueness across concurrent
465    // extractions of the same pin; the atomic rename serializes the
466    // winner and the loser's tmp dir gets best-effort cleanup on the
467    // returned error.
468    let ts = std::time::SystemTime::now()
469        .duration_since(std::time::UNIX_EPOCH)
470        .map(|d| d.as_nanos())
471        .unwrap_or(0);
472    let tmp = parent.join(format!(
473        ".memstead-schema-extract-{}-{}",
474        std::process::id(),
475        ts,
476    ));
477
478    // Wipe any leftover from a previous failed extract with the same
479    // PID+time — the path is ours by construction.
480    let _ = std::fs::remove_dir_all(&tmp);
481    std::fs::create_dir_all(&tmp)?;
482
483    for sf in schema_files {
484        let rel = sf
485            .archive_path
486            .strip_prefix(ARCHIVE_SCHEMA_PREFIX)
487            .unwrap_or(sf.archive_path.as_str());
488        let file_path = tmp.join(rel);
489        if let Some(file_parent) = file_path.parent() {
490            std::fs::create_dir_all(file_parent)?;
491        }
492        std::fs::write(&file_path, sf.content.as_bytes())?;
493    }
494
495    match std::fs::rename(&tmp, dest) {
496        Ok(()) => Ok(()),
497        Err(e) => {
498            // Rename lost (dest appeared from a racer, or some other
499            // filesystem error). Clean up our tmp so we don't leave a
500            // stray `.memstead-schema-extract-*` sibling behind.
501            let _ = std::fs::remove_dir_all(&tmp);
502            Err(e)
503        }
504    }
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510    use crate::ops::export::export_mem;
511    use tempfile::TempDir;
512
513    /// Write a minimal valid mem directory to `mem_dir` and export it
514    /// to `archive_path`. The resulting archive passes
515    /// `validate_and_normalize_archive` — the fixture exists precisely so
516    /// install tests don't have to hand-build validator-compliant bytes.
517    fn build_valid_archive(mem_dir: &Path, archive_path: &Path, name: &str) {
518        // Configs no longer carry an in-config `name` field. The
519        // archive's identity comes from the disk-path basename via the
520        // `published_config_from` fallback chain. Build the mem
521        // directory under `<mem_dir.parent>/<name>/` so the basename
522        // matches the requested name; tests can pass any throwaway
523        // `mem_dir` path and trust the helper to align them.
524        let mem_dir = mem_dir.parent().unwrap_or(mem_dir).join(name);
525        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
526        std::fs::write(
527            mem_dir.join(".memstead/config.json"),
528            r#"{"version":"1.2.0","schema":"default@1.0.0"}"#,
529        )
530        .unwrap();
531        std::fs::write(
532            mem_dir.join("alpha.md"),
533            "---\ntype: spec\ncreated_date: 2026-01-15\nlast_modified: 2026-01-15\nlevel: M0\n---\n# Alpha\n\n## Identity\n\nA.\n\n## Purpose\n\nB.\n\n## Specifies\n\nC.\n\n## Constraints\n\nD.\n\n## Rationale\n\nE.\n",
534        ).unwrap();
535
536        let config = memstead_schema::load_and_validate(&mem_dir).unwrap();
537        // No workspace context — the schema-source resolver falls through
538        // to the embedded builtin.
539        export_mem(&mem_dir, &config, archive_path, None, None).unwrap();
540    }
541
542    /// Cache-side install convenience for the test fixtures — no
543    /// shadow set, no mount registration (the engine-side half has its
544    /// own tests).
545    fn cache_install(archive: &Path) -> Result<CacheInstallOutcome, InstallError> {
546        install_to_cache(archive, &[])
547    }
548
549    /// Build a writable-mem config directory for install tests. Adds the
550    /// minimal fields the config writer expects on load.
551    fn write_minimal_mem_config(dir: &Path, _name: &str) {
552        std::fs::create_dir_all(dir.join(".memstead")).unwrap();
553        std::fs::write(
554            dir.join(".memstead/config.json"),
555            r#"{"version":"1.0.0","schema":"default@1.0.0"}"#,
556        )
557        .unwrap();
558    }
559
560    /// Process-global env lock. All install-helper tests take this before
561    /// touching `MEMSTEAD_MEM_CACHE` so parallel runs inside the same
562    /// cargo-test binary don't race on the shared process env. Rust 2024
563    /// makes `env::set_var` unsafe precisely because concurrent reads can
564    /// tear — the lock is the safety contract.
565    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
566
567    /// RAII guard for `MEMSTEAD_MEM_CACHE`: holds the global lock, installs
568    /// the override, restores the previous value on drop.
569    struct CacheGuard {
570        _lock: std::sync::MutexGuard<'static, ()>,
571        prev: Option<String>,
572    }
573    impl CacheGuard {
574        fn install(cache_dir: &Path) -> Self {
575            let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
576            let prev = std::env::var(CACHE_OVERRIDE_ENV).ok();
577            // SAFETY: the global mutex above serializes env access for
578            // every test in this module; no other reader runs concurrently.
579            unsafe {
580                std::env::set_var(CACHE_OVERRIDE_ENV, cache_dir);
581            }
582            Self { _lock: lock, prev }
583        }
584    }
585    impl Drop for CacheGuard {
586        fn drop(&mut self) {
587            // SAFETY: we still hold the lock acquired in `install`.
588            unsafe {
589                match self.prev.take() {
590                    Some(v) => std::env::set_var(CACHE_OVERRIDE_ENV, v),
591                    None => std::env::remove_var(CACHE_OVERRIDE_ENV),
592                }
593            }
594        }
595    }
596
597    #[test]
598    fn mem_cache_dir_honors_env_override() {
599        let custom = std::env::temp_dir().join("memstead-cache-override-test");
600        let _g = CacheGuard::install(&custom);
601        assert_eq!(mem_cache_dir(), custom);
602    }
603
604    #[test]
605    fn read_published_config_reads_whitelist_fields() {
606        let tmp = TempDir::new().unwrap();
607        // Published archive identity comes from the disk-path basename
608        // via the `published_config_from` fallback chain (the in-config
609        // `name` field is no longer authored).
610        let mem_src = tmp.path().join("sample");
611        let archive = tmp.path().join("sample.mem");
612        build_valid_archive(&mem_src, &archive, "sample");
613
614        let config = read_published_config(&archive).unwrap();
615        assert_eq!(config.format, memstead_schema::PUBLISHED_MEM_FORMAT);
616        assert_eq!(config.name, "sample");
617        assert_eq!(config.version.to_string(), "1.2.0");
618    }
619
620    #[test]
621    fn read_published_config_missing_file_is_archive_not_found() {
622        let err = read_published_config(&PathBuf::from("/nonexistent/nope.mem")).unwrap_err();
623        assert!(matches!(err, LoadError::ArchiveNotFound(_)));
624    }
625
626    #[test]
627    fn read_published_config_corrupt_archive_is_zip_error() {
628        let tmp = TempDir::new().unwrap();
629        let archive = tmp.path().join("corrupt.mem");
630        std::fs::write(&archive, b"definitely not a zip").unwrap();
631        let err = read_published_config(&archive).unwrap_err();
632        assert!(matches!(err, LoadError::Zip(_)));
633    }
634
635    #[test]
636    fn install_validates_and_canonicalizes() {
637        let tmp = TempDir::new().unwrap();
638        let cache = tmp.path().join("cache");
639        let project = tmp.path().join("project");
640        let src_dir = tmp.path().join("src");
641        let src = tmp.path().join("aws-patterns.mem");
642
643        std::fs::create_dir_all(&project).unwrap();
644        write_minimal_mem_config(&project, "specs");
645        build_valid_archive(&src_dir, &src, "aws-patterns");
646
647        let _g = CacheGuard::install(&cache);
648        let outcome = cache_install(&src).unwrap();
649
650        assert_eq!(outcome.mem_name, "aws-patterns");
651        assert!(outcome.copied_to_cache);
652        assert!(
653            outcome.warnings.is_empty(),
654            "current-format install must not warn: {:?}",
655            outcome.warnings
656        );
657
658        // The outcome carries the content-addressed cache reference the
659        // mount registration points at.
660        let key = outcome.cache_key.as_str();
661        let cached = cache.join(format!("aws-patterns-{key}.mem"));
662        assert_eq!(outcome.cache_path, cached);
663        assert!(cached.is_file(), "content-addressed cache file must exist");
664
665        // Cached bytes must equal the validator's canonical form, and the
666        // recorded key must be the digest of those bytes.
667        let cached_bytes = std::fs::read(&cached).unwrap();
668        let revalidated = validate_and_normalize_archive(&cached_bytes).unwrap();
669        assert_eq!(revalidated.canonical_bytes, cached_bytes);
670        assert_eq!(
671            key,
672            content_cache_key(&cached_bytes),
673            "cacheKey is the content digest"
674        );
675    }
676
677    #[test]
678    fn install_leaves_no_tmp_on_success() {
679        let tmp = TempDir::new().unwrap();
680        let cache = tmp.path().join("cache");
681        let project = tmp.path().join("project");
682        let src_dir = tmp.path().join("src");
683        let src = tmp.path().join("x.mem");
684        std::fs::create_dir_all(&project).unwrap();
685        write_minimal_mem_config(&project, "specs");
686        build_valid_archive(&src_dir, &src, "alpha");
687
688        let _g = CacheGuard::install(&cache);
689        cache_install(&src).unwrap();
690
691        // The temp-then-rename path must leave the content-addressed
692        // `<name>-<key>.mem` on disk and never the `.tmp` sibling. The
693        // filename is derived from the validator's approved `config.name`
694        // ("alpha") plus the content key, not from the submitted filename.
695        let entries: Vec<_> = std::fs::read_dir(&cache)
696            .unwrap()
697            .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
698            .collect();
699        assert_eq!(
700            entries.iter().filter(|n| n.ends_with(".mem")).count(),
701            1,
702            "exactly one cache file, no .tmp sibling: {entries:?}",
703        );
704        let cache_file = entries.iter().find(|n| n.ends_with(".mem")).unwrap();
705        assert!(
706            cache_file.starts_with("alpha-"),
707            "name-keyed prefix: {cache_file}"
708        );
709        assert!(!entries.iter().any(|n| n.ends_with(".tmp")));
710    }
711
712    #[test]
713    fn install_is_idempotent() {
714        let tmp = TempDir::new().unwrap();
715        let cache = tmp.path().join("cache");
716        let project = tmp.path().join("project");
717        let src_dir = tmp.path().join("src");
718        let src = tmp.path().join("x.mem");
719        std::fs::create_dir_all(&project).unwrap();
720        write_minimal_mem_config(&project, "specs");
721        build_valid_archive(&src_dir, &src, "alpha");
722
723        let _g = CacheGuard::install(&cache);
724        let first = cache_install(&src).unwrap();
725        assert!(first.copied_to_cache);
726
727        // Second run: the cache side effect reports `false`. The cache
728        // file survives untouched (existing-file guard fires before
729        // the canonical write) and the content key is stable.
730        let second = cache_install(&src).unwrap();
731        assert!(!second.copied_to_cache);
732        assert_eq!(first.cache_key, second.cache_key);
733    }
734
735    /// Two byte-distinct archives that share an internal mem name both
736    /// install successfully into distinct content-addressed cache files —
737    /// neither blocks nor silently shadows the other, and the registration
738    /// records each archive's own `cacheKey`. This replaces the prior
739    /// `CACHE_NAME_COLLISION` refusal, which was a dead end requiring
740    /// manual cache-file deletion.
741    #[test]
742    fn install_distinct_archives_same_name_coexist_via_content_address() {
743        let tmp = TempDir::new().unwrap();
744        let cache = tmp.path().join("cache");
745        let project = tmp.path().join("project");
746        let src_a_dir = tmp.path().join("src-a");
747        let src_a = tmp.path().join("a.mem");
748        std::fs::create_dir_all(&project).unwrap();
749        write_minimal_mem_config(&project, "specs");
750        build_valid_archive(&src_a_dir, &src_a, "alpha");
751
752        let _g = CacheGuard::install(&cache);
753        let first = cache_install(&src_a).unwrap();
754        assert!(first.copied_to_cache);
755        let key_a = first.cache_key.clone();
756
757        // Build a *different* archive that lands at the same canonical
758        // name (`alpha`) with distinct content.
759        let src_b_dir = tmp.path().join("src-b");
760        std::fs::create_dir_all(src_b_dir.join("alpha/.memstead")).unwrap();
761        std::fs::write(
762            src_b_dir.join("alpha/.memstead/config.json"),
763            r#"{"version":"1.2.0","schema":"default@1.0.0"}"#,
764        )
765        .unwrap();
766        std::fs::write(
767            src_b_dir.join("alpha/beta.md"),
768            "---\ntype: spec\ncreated_date: 2026-01-15\nlast_modified: 2026-01-15\nlevel: M0\n---\n# Beta\n\n## Identity\n\nA different content.\n\n## Purpose\n\nB different content.\n\n## Specifies\n\nC different content.\n\n## Constraints\n\nD different content.\n\n## Rationale\n\nE different content.\n",
769        ).unwrap();
770        let src_b = tmp.path().join("b.mem");
771        let cfg_b = memstead_schema::load_and_validate(&src_b_dir.join("alpha")).unwrap();
772        crate::ops::export::export_mem(&src_b_dir.join("alpha"), &cfg_b, &src_b, None, None)
773            .unwrap();
774        assert_ne!(
775            std::fs::read(&src_a).unwrap(),
776            std::fs::read(&src_b).unwrap(),
777            "fixture must produce two distinct archives sharing the name `alpha`"
778        );
779
780        // Second install (different bytes, same name): SUCCEEDS — no
781        // collision, no dead end.
782        let second = cache_install(&src_b).unwrap();
783        assert!(
784            second.copied_to_cache,
785            "distinct bytes must install, not collide"
786        );
787        let key_b = second.cache_key.clone();
788
789        // Distinct content ⇒ distinct keys ⇒ both cache files coexist.
790        assert_ne!(
791            key_a, key_b,
792            "distinct archives must get distinct content keys"
793        );
794        assert!(cache.join(format!("alpha-{key_a}.mem")).is_file());
795        assert!(cache.join(format!("alpha-{key_b}.mem")).is_file());
796    }
797
798    /// A re-install with byte-identical input is the idempotent success
799    /// path — no write, no commit, no churn, and
800    /// `copied_to_cache: false`. The pre-fix idempotency contract is
801    /// preserved; what's gone is the silent third state where
802    /// `copied_to_cache: false` admitted unrelated bytes.
803    #[test]
804    fn install_idempotent_path_returns_false_without_refusal() {
805        let tmp = TempDir::new().unwrap();
806        let cache = tmp.path().join("cache");
807        let project = tmp.path().join("project");
808        let src_dir = tmp.path().join("src");
809        let src = tmp.path().join("x.mem");
810        std::fs::create_dir_all(&project).unwrap();
811        write_minimal_mem_config(&project, "specs");
812        build_valid_archive(&src_dir, &src, "alpha");
813
814        let _g = CacheGuard::install(&cache);
815        let first = cache_install(&src).unwrap();
816        assert!(first.copied_to_cache);
817
818        // Re-install with the SAME archive bytes — canonical(input)
819        // matches the cache file → idempotent success.
820        let second = cache_install(&src).unwrap();
821        assert!(
822            !second.copied_to_cache,
823            "idempotent re-install must report copied_to_cache: false"
824        );
825    }
826
827    /// Rewrite a current-layout archive so its meta members live under a
828    /// non-whitelisted dir (`.other/` instead of `.memstead/`). Test-only.
829    fn repack_with_foreign_meta_dir(src: &Path, dest: &Path) {
830        use std::io::{Read as _, Write as _};
831        let file = std::fs::File::open(src).unwrap();
832        let mut archive = zip::ZipArchive::new(file).unwrap();
833        let out = std::fs::File::create(dest).unwrap();
834        let mut writer = zip::ZipWriter::new(out);
835        let opts = zip::write::SimpleFileOptions::default();
836        for i in 0..archive.len() {
837            let mut entry = archive.by_index(i).unwrap();
838            let name = entry.name().to_string();
839            let name = match name.strip_prefix(".memstead/") {
840                Some(rest) => format!(".other/{rest}"),
841                None => name,
842            };
843            let mut bytes = Vec::new();
844            entry.read_to_end(&mut bytes).unwrap();
845            writer.start_file(name, opts).unwrap();
846            writer.write_all(&bytes).unwrap();
847        }
848        writer.finish().unwrap();
849    }
850
851    /// Only the `.memstead/` meta layout is tolerated: an archive whose
852    /// meta members live under any other dir fails at validation — its
853    /// members fall outside the `.memstead/` whitelist.
854    #[test]
855    fn install_foreign_meta_layout_is_rejected() {
856        let tmp = TempDir::new().unwrap();
857        let cache = tmp.path().join("cache");
858        let project = tmp.path().join("project");
859        let src_dir = tmp.path().join("src");
860        let modern = tmp.path().join("modern.mem");
861        std::fs::create_dir_all(&project).unwrap();
862        write_minimal_mem_config(&project, "specs");
863        build_valid_archive(&src_dir, &modern, "foreign-mem");
864
865        let foreign = tmp.path().join("foreign-mem.mem");
866        repack_with_foreign_meta_dir(&modern, &foreign);
867
868        let _g = CacheGuard::install(&cache);
869        let err =
870            cache_install(&foreign).expect_err("a foreign meta-layout archive must not install");
871        assert!(matches!(err, InstallError::Validation(_)), "got {err:?}");
872    }
873
874    #[test]
875    fn install_rejects_non_archive_bytes() {
876        let tmp = TempDir::new().unwrap();
877        let cache = tmp.path().join("cache");
878        let project = tmp.path().join("project");
879        std::fs::create_dir_all(&project).unwrap();
880        write_minimal_mem_config(&project, "specs");
881        let src = tmp.path().join("bad.mem");
882        std::fs::write(&src, b"not a zip").unwrap();
883
884        let _g = CacheGuard::install(&cache);
885        let err = cache_install(&src).unwrap_err();
886        assert!(matches!(err, InstallError::Validation(_)));
887        // Validation failed up front → neither cache file nor temp
888        // sibling was written.
889        assert!(!cache.join("bad.mem").exists());
890        assert!(!cache.join("bad.mem.tmp").exists());
891    }
892}