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::{ARCHIVE_CONFIG_PATH, ARCHIVE_EXTENSION, PublishedMemConfig};
25
26use crate::entity::loader::LoadError;
27use crate::mem_repo_config::MemRepoWriteError;
28use crate::validator::{ValidationError, validate_and_normalize_archive};
29
30/// Env var that overrides `<data_dir>/memstead/mems` for tests.
31pub const CACHE_OVERRIDE_ENV: &str = "MEMSTEAD_MEM_CACHE";
32
33/// Resolve the global mem-cache directory.
34///
35/// Respects `MEMSTEAD_MEM_CACHE` if set — tests use this to point at a
36/// tempdir without touching the real user-data directory. Otherwise
37/// returns `<data_dir>/memstead/mems` on every platform (macOS / Linux /
38/// Windows), so the CLI and the Memstead app resolve to the same path
39/// without per-platform branching.
40///
41/// `dirs::data_dir()` is infallible on Tier-1 platforms; `expect` is
42/// fine for an engine that only runs on systems with a resolvable home.
43pub fn mem_cache_dir() -> PathBuf {
44    if let Ok(override_path) = std::env::var(CACHE_OVERRIDE_ENV)
45        && !override_path.is_empty()
46    {
47        return PathBuf::from(override_path);
48    }
49    dirs::data_dir()
50        .expect("platform provides a data directory")
51        .join("memstead")
52        .join("mems")
53}
54
55/// Read the whitelisted `.memstead/config.json` from a cached archive.
56///
57/// Does **not** re-run full archive validation — the cache only
58/// contains bytes the validator already approved, so entity parse and
59/// graph construction can be deferred to the caller. Configs are
60/// re-parsed with `parse_config_bytes` so the strict-ingress shape is
61/// enforced here as defense-in-depth against a tampered cache file.
62pub fn read_published_config(archive_path: &Path) -> Result<PublishedMemConfig, LoadError> {
63    if !archive_path.is_file() {
64        return Err(LoadError::ArchiveNotFound(
65            archive_path.display().to_string(),
66        ));
67    }
68    let file = std::fs::File::open(archive_path)?;
69    let mut archive = zip::ZipArchive::new(file)?;
70
71    // Take the mutable entry borrow only if the config member is
72    // present (`by_name` holds `&mut archive`).
73    let config_name = ARCHIVE_CONFIG_PATH;
74    if archive.index_for_name(config_name).is_none() {
75        return Err(LoadError::InvalidArchive(format!(
76            "missing {ARCHIVE_CONFIG_PATH} in {}",
77            archive_path.display()
78        )));
79    }
80    let mut entry = archive.by_name(config_name).map_err(|e| {
81        LoadError::InvalidArchive(format!(
82            "reading {config_name} in {}: {e}",
83            archive_path.display()
84        ))
85    })?;
86
87    let mut bytes = Vec::new();
88    entry.read_to_end(&mut bytes)?;
89
90    crate::validator::config::parse_config_bytes(&bytes).map_err(|e| {
91        LoadError::InvalidArchive(format!(
92            "invalid {ARCHIVE_CONFIG_PATH} in {}: {e}",
93            archive_path.display()
94        ))
95    })
96}
97
98#[derive(Debug, thiserror::Error)]
99pub enum InstallError {
100    #[error("could not read mem archive: {0}")]
101    Archive(#[from] LoadError),
102    #[error("io error while installing mem: {0}")]
103    Io(#[from] std::io::Error),
104    #[error("config error while registering mem: {0}")]
105    Config(#[from] memstead_schema::config::ConfigError),
106    #[error("archive failed strict validation: {0}")]
107    Validation(ValidationError),
108    /// Mem-db tree write failed. Carries the underlying gix error
109    /// message so callers can surface it without wrapping the variant.
110    #[error("mem-repo tree write failed: {0}")]
111    MemRepo(#[from] MemRepoWriteError),
112    /// The archive's authoritative mem name (carried in its canonical
113    /// config) matches a writable mount that already exists in this
114    /// workspace. A read-only mount cannot share a writable mount's
115    /// name (the archive's internal name is its sole identity and
116    /// nothing can rename it at install time), so the install surface
117    /// refuses up-front. The genuine recovery is to rename or
118    /// unregister the writable mount that shadows the archive's
119    /// internal name.
120    #[error(
121        "archive's mem name `{archive_name}` already exists as a writable mount in this workspace; \
122         rename the writable mount (`memstead mem rename`) or unregister it first — the archive's \
123         internal name is its sole identity and cannot be changed at install time"
124    )]
125    ShadowsWritable {
126        archive_name: String,
127        shadows_writable: String,
128    },
129    // `CacheNameCollision` was retired once the cache became
130    // content-addressed (`<name>-<content_key>.mem`): distinct bytes
131    // under the same mem name land in distinct files and the collision
132    // class it guarded no longer exists. No engine surface can produce it.
133}
134
135/// Short content-address for an installed archive: the first 16 hex chars
136/// of `sha256(canonical_bytes)`. Used as the cache-file key
137/// (`<name>-<key>.mem`) and recorded in the `readMems` registration so
138/// the loader resolves the right file. 64 bits is ample collision
139/// resistance for a per-user cache; the same convention (truncated SHA-256
140/// hex) the entity content-hash uses.
141fn content_cache_key(canonical_bytes: &[u8]) -> String {
142    use sha2::{Digest, Sha256};
143    let digest = Sha256::digest(canonical_bytes);
144    digest[..8].iter().map(|b| format!("{b:02x}")).collect()
145}
146
147/// Install a sealed mem archive into the global cache and register
148/// it in a writable mem's config. Accepts the `.mem` archive format.
149///
150/// Two independent side effects, both idempotent:
151///
152/// 1. If the content-addressed cache file does not exist: run the submitted bytes
153///    through `validate_and_normalize_archive` and write the
154///    validator's `canonical_bytes` via a `.tmp` sibling + atomic
155///    rename. A mid-write crash leaves the temp file behind, never a
156///    partial cache file. Existing cache files are left untouched —
157///    overwrite-on-newer-version is an app-level update flow, not a
158///    CLI install semantic. Users who want to force-replace can delete
159///    the cache file first.
160/// 2. If the target mem's config does not already list this mem
161///    under `readMems`, add an entry with `source: { type: "local" }`.
162///    Existing entries are left untouched so re-running install never
163///    clobbers a `type: "url"` (etc.) source the user configured by hand.
164///
165/// The `target` parameter selects where the registration lands:
166/// - `TargetMem::Disk(mem_dir)` writes the updated config back to
167///   `<mem_dir>/.memstead/config.json` (legacy disk shape).
168/// - `TargetMem::MemRepo { workspace_root, mem_name }` commits the
169///   updated `configs/<mem_name>.json` to `mem-repo-git:main` (post-
170///   cutover shape).
171///
172/// `ctx` and `commit_message` are used only by the `MemRepo` arm —
173/// the disk arm rewrites the file via the existing config-update path
174/// which has its own (file-mtime-based) provenance trail.
175///
176/// Returns an `InstallOutcome` describing which effects fired. The
177/// authoritative mem name comes from the validator's approved
178/// config, not from the submitted filename or caller argument.
179/// Outcome of [`install_to_cache`] — the cache-side half of an
180/// install, with everything the caller needs to register the archive
181/// as a workspace-level read-only mount.
182#[derive(Debug, Clone)]
183pub struct CacheInstallOutcome {
184    /// Mem name, taken from the validator's approved config — the
185    /// archive's sole identity.
186    pub mem_name: String,
187    /// The archive's schema pin, from its bundled config.
188    pub schema: memstead_schema::SchemaRef,
189    /// Content-addressed cache file the mount's `Archive` storage
190    /// points at.
191    pub cache_path: PathBuf,
192    /// The content digest half of the cache filename.
193    pub cache_key: String,
194    /// `true` if canonical bytes were written on this call; `false`
195    /// on the idempotent dedup path.
196    pub copied_to_cache: bool,
197    /// The archive's embedded schema package, package-relative
198    /// (`schema.yaml`, `types/<t>.yaml`, `schema-format.json`) and
199    /// byte-verbatim. Carried out of the validator because the mount
200    /// that follows can only be registered once this schema resolves,
201    /// and for a third party's vocabulary the archive is the only
202    /// place it exists. Empty when the archive embeds no schema tree.
203    pub schema_files: Vec<(String, Vec<u8>)>,
204    /// Typed non-fatal issues surfaced by the install.
205    pub warnings: Vec<WarningHint>,
206}
207
208/// Validate an archive and land it in the global content-addressed
209/// cache — the cache-side half of `memstead install`, with **no
210/// config or mount side effects** (the caller registers the returned
211/// archive as a workspace-level read-only mount). Shares the
212/// validator, the shadow-name gate, and the content-addressed
213/// atomic-rename write with the historical combined path.
214pub fn install_to_cache(
215    archive_path: &Path,
216    writable_mem_names: &[&str],
217) -> Result<CacheInstallOutcome, InstallError> {
218    let bytes = std::fs::read(archive_path)?;
219    let validated = validate_and_normalize_archive(&bytes).map_err(InstallError::Validation)?;
220
221    if let Some(shadowed) = writable_mem_names
222        .iter()
223        .find(|n| **n == validated.config.name.as_str())
224    {
225        return Err(InstallError::ShadowsWritable {
226            archive_name: validated.config.name.clone(),
227            shadows_writable: (*shadowed).to_string(),
228        });
229    }
230
231    let cache_dir = mem_cache_dir();
232    std::fs::create_dir_all(&cache_dir)?;
233    let cache_key = content_cache_key(&validated.canonical_bytes);
234    let dest = cache_dir.join(format!(
235        "{}-{}.{ARCHIVE_EXTENSION}",
236        validated.config.name, cache_key
237    ));
238    let copied_to_cache = if dest.exists() {
239        false
240    } else {
241        let tmp = dest.with_extension(format!("{ARCHIVE_EXTENSION}.tmp"));
242        std::fs::write(&tmp, &validated.canonical_bytes)?;
243        std::fs::rename(&tmp, &dest)?;
244        true
245    };
246
247    Ok(CacheInstallOutcome {
248        mem_name: validated.config.name,
249        schema: validated.config.schema.clone(),
250        cache_path: dest,
251        cache_key,
252        copied_to_cache,
253        schema_files: memstead_base::validator::archive::to_package_files(&validated.schema_files),
254        warnings: Vec::new(),
255    })
256}
257
258/// What happened on the mount-registration side of an install.
259#[derive(Debug, Clone, Copy, PartialEq, Eq)]
260pub enum MountRegistration {
261    /// Fresh registration.
262    Registered,
263    /// Same name, same content-addressed cache file — nothing to do.
264    AlreadyRegistered,
265    /// Same name, new content — the mount was re-pointed at the new
266    /// cache file.
267    Refreshed,
268}
269
270/// Register a cached archive (the outcome of [`install_to_cache`]) as
271/// a workspace-level read-only mount on the live engine — the shared
272/// back half of `memstead install` and the MCP server's `--read-mem`
273/// boot flag. Idempotent per content: an existing read-only mount
274/// under the same name is a no-op when it already points at this
275/// cache file, and an in-place refresh (unregister + re-register)
276/// when the content changed. The caller persists the mount state
277/// (`engine.persist_state()`) after a `Registered` / `Refreshed`
278/// outcome.
279///
280/// Staging comes first and for a reason: a mem published under a
281/// third party's vocabulary carries that vocabulary inside the
282/// archive and nowhere else, so the pin the mount resolves against
283/// only exists once the embedded package has been written into the
284/// workspace's local schema storage. Staging is idempotent and
285/// refuses before any mount side effect, so a broken embedded schema
286/// leaves neither a staged package nor a registered mount behind.
287pub fn register_cached_archive(
288    engine: &mut memstead_base::Engine,
289    outcome: &CacheInstallOutcome,
290    by_tool: &'static str,
291) -> Result<MountRegistration, memstead_base::EngineError> {
292    engine.stage_sealed_schema(&outcome.mem_name, &outcome.schema, &outcome.schema_files)?;
293
294    let registration = match engine.mount(&outcome.mem_name) {
295        Some(existing) if existing.capability == memstead_base::MountCapability::ReadOnly => {
296            match &existing.storage {
297                memstead_base::MountStorage::Archive { path } if *path == outcome.cache_path => {
298                    return Ok(MountRegistration::AlreadyRegistered);
299                }
300                _ => {
301                    engine.unregister_read_mount(&outcome.mem_name)?;
302                    MountRegistration::Refreshed
303                }
304            }
305        }
306        // A writable mount of the same name is the caller's shadow
307        // gate's business (install_to_cache refuses it up-front).
308        _ => MountRegistration::Registered,
309    };
310
311    let mount = memstead_base::Mount {
312        mem: outcome.mem_name.clone(),
313        schema: Some(outcome.schema.clone()),
314        storage: memstead_base::MountStorage::Archive {
315            path: outcome.cache_path.clone(),
316        },
317        capability: memstead_base::MountCapability::ReadOnly,
318        lifecycle: memstead_base::MountLifecycle::Eager,
319        cross_linkable: false,
320        migration_target: None,
321    };
322    let backend: Box<dyn memstead_base::MemBackend> = Box::new(
323        memstead_base::storage::ArchiveBackend::new(outcome.cache_path.clone()),
324    );
325    let origin = memstead_base::MemOrigin::RuntimeCreated {
326        at: std::time::SystemTime::now(),
327        by_tool,
328    };
329    engine.register_read_mount(mount, backend, origin)?;
330    Ok(registration)
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use crate::ops::export::export_mem;
337    use tempfile::TempDir;
338
339    /// Write a minimal valid mem directory to `mem_dir` and export it
340    /// to `archive_path`. The resulting archive passes
341    /// `validate_and_normalize_archive` — the fixture exists precisely so
342    /// install tests don't have to hand-build validator-compliant bytes.
343    fn build_valid_archive(mem_dir: &Path, archive_path: &Path, name: &str) {
344        // Configs no longer carry an in-config `name` field. The
345        // archive's identity comes from the disk-path basename via the
346        // `published_config_from` fallback chain. Build the mem
347        // directory under `<mem_dir.parent>/<name>/` so the basename
348        // matches the requested name; tests can pass any throwaway
349        // `mem_dir` path and trust the helper to align them.
350        let mem_dir = mem_dir.parent().unwrap_or(mem_dir).join(name);
351        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
352        std::fs::write(
353            mem_dir.join(".memstead/config.json"),
354            r#"{"version":"1.2.0","schema":"default@1.0.0"}"#,
355        )
356        .unwrap();
357        std::fs::write(
358            mem_dir.join("alpha.md"),
359            "---\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",
360        ).unwrap();
361
362        let config = memstead_schema::load_and_validate(&mem_dir).unwrap();
363        // No workspace context — the schema-source resolver falls through
364        // to the embedded builtin.
365        export_mem(&mem_dir, &config, archive_path, None, None).unwrap();
366    }
367
368    /// Cache-side install convenience for the test fixtures — no
369    /// shadow set, no mount registration (the engine-side half has its
370    /// own tests).
371    fn cache_install(archive: &Path) -> Result<CacheInstallOutcome, InstallError> {
372        install_to_cache(archive, &[])
373    }
374
375    /// Build a writable-mem config directory for install tests. Adds the
376    /// minimal fields the config writer expects on load.
377    fn write_minimal_mem_config(dir: &Path, _name: &str) {
378        std::fs::create_dir_all(dir.join(".memstead")).unwrap();
379        std::fs::write(
380            dir.join(".memstead/config.json"),
381            r#"{"version":"1.0.0","schema":"default@1.0.0"}"#,
382        )
383        .unwrap();
384    }
385
386    /// Process-global env lock. All install-helper tests take this before
387    /// touching `MEMSTEAD_MEM_CACHE` so parallel runs inside the same
388    /// cargo-test binary don't race on the shared process env. Rust 2024
389    /// makes `env::set_var` unsafe precisely because concurrent reads can
390    /// tear — the lock is the safety contract.
391    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
392
393    /// RAII guard for `MEMSTEAD_MEM_CACHE`: holds the global lock, installs
394    /// the override, restores the previous value on drop.
395    struct CacheGuard {
396        _lock: std::sync::MutexGuard<'static, ()>,
397        prev: Option<String>,
398    }
399    impl CacheGuard {
400        fn install(cache_dir: &Path) -> Self {
401            let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
402            let prev = std::env::var(CACHE_OVERRIDE_ENV).ok();
403            // SAFETY: the global mutex above serializes env access for
404            // every test in this module; no other reader runs concurrently.
405            unsafe {
406                std::env::set_var(CACHE_OVERRIDE_ENV, cache_dir);
407            }
408            Self { _lock: lock, prev }
409        }
410    }
411    impl Drop for CacheGuard {
412        fn drop(&mut self) {
413            // SAFETY: we still hold the lock acquired in `install`.
414            unsafe {
415                match self.prev.take() {
416                    Some(v) => std::env::set_var(CACHE_OVERRIDE_ENV, v),
417                    None => std::env::remove_var(CACHE_OVERRIDE_ENV),
418                }
419            }
420        }
421    }
422
423    #[test]
424    fn mem_cache_dir_honors_env_override() {
425        let custom = std::env::temp_dir().join("memstead-cache-override-test");
426        let _g = CacheGuard::install(&custom);
427        assert_eq!(mem_cache_dir(), custom);
428    }
429
430    #[test]
431    fn read_published_config_reads_whitelist_fields() {
432        let tmp = TempDir::new().unwrap();
433        // Published archive identity comes from the disk-path basename
434        // via the `published_config_from` fallback chain (the in-config
435        // `name` field is no longer authored).
436        let mem_src = tmp.path().join("sample");
437        let archive = tmp.path().join("sample.mem");
438        build_valid_archive(&mem_src, &archive, "sample");
439
440        let config = read_published_config(&archive).unwrap();
441        assert_eq!(config.format, memstead_schema::PUBLISHED_MEM_FORMAT);
442        assert_eq!(config.name, "sample");
443        assert_eq!(config.version.to_string(), "1.2.0");
444    }
445
446    #[test]
447    fn read_published_config_missing_file_is_archive_not_found() {
448        let err = read_published_config(&PathBuf::from("/nonexistent/nope.mem")).unwrap_err();
449        assert!(matches!(err, LoadError::ArchiveNotFound(_)));
450    }
451
452    #[test]
453    fn read_published_config_corrupt_archive_is_zip_error() {
454        let tmp = TempDir::new().unwrap();
455        let archive = tmp.path().join("corrupt.mem");
456        std::fs::write(&archive, b"definitely not a zip").unwrap();
457        let err = read_published_config(&archive).unwrap_err();
458        assert!(matches!(err, LoadError::Zip(_)));
459    }
460
461    #[test]
462    fn install_validates_and_canonicalizes() {
463        let tmp = TempDir::new().unwrap();
464        let cache = tmp.path().join("cache");
465        let project = tmp.path().join("project");
466        let src_dir = tmp.path().join("src");
467        let src = tmp.path().join("aws-patterns.mem");
468
469        std::fs::create_dir_all(&project).unwrap();
470        write_minimal_mem_config(&project, "specs");
471        build_valid_archive(&src_dir, &src, "aws-patterns");
472
473        let _g = CacheGuard::install(&cache);
474        let outcome = cache_install(&src).unwrap();
475
476        assert_eq!(outcome.mem_name, "aws-patterns");
477        assert!(outcome.copied_to_cache);
478        assert!(
479            outcome.warnings.is_empty(),
480            "current-format install must not warn: {:?}",
481            outcome.warnings
482        );
483
484        // The outcome carries the content-addressed cache reference the
485        // mount registration points at.
486        let key = outcome.cache_key.as_str();
487        let cached = cache.join(format!("aws-patterns-{key}.mem"));
488        assert_eq!(outcome.cache_path, cached);
489        assert!(cached.is_file(), "content-addressed cache file must exist");
490
491        // Cached bytes must equal the validator's canonical form, and the
492        // recorded key must be the digest of those bytes.
493        let cached_bytes = std::fs::read(&cached).unwrap();
494        let revalidated = validate_and_normalize_archive(&cached_bytes).unwrap();
495        assert_eq!(revalidated.canonical_bytes, cached_bytes);
496        assert_eq!(
497            key,
498            content_cache_key(&cached_bytes),
499            "cacheKey is the content digest"
500        );
501    }
502
503    #[test]
504    fn install_leaves_no_tmp_on_success() {
505        let tmp = TempDir::new().unwrap();
506        let cache = tmp.path().join("cache");
507        let project = tmp.path().join("project");
508        let src_dir = tmp.path().join("src");
509        let src = tmp.path().join("x.mem");
510        std::fs::create_dir_all(&project).unwrap();
511        write_minimal_mem_config(&project, "specs");
512        build_valid_archive(&src_dir, &src, "alpha");
513
514        let _g = CacheGuard::install(&cache);
515        cache_install(&src).unwrap();
516
517        // The temp-then-rename path must leave the content-addressed
518        // `<name>-<key>.mem` on disk and never the `.tmp` sibling. The
519        // filename is derived from the validator's approved `config.name`
520        // ("alpha") plus the content key, not from the submitted filename.
521        let entries: Vec<_> = std::fs::read_dir(&cache)
522            .unwrap()
523            .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
524            .collect();
525        assert_eq!(
526            entries.iter().filter(|n| n.ends_with(".mem")).count(),
527            1,
528            "exactly one cache file, no .tmp sibling: {entries:?}",
529        );
530        let cache_file = entries.iter().find(|n| n.ends_with(".mem")).unwrap();
531        assert!(
532            cache_file.starts_with("alpha-"),
533            "name-keyed prefix: {cache_file}"
534        );
535        assert!(!entries.iter().any(|n| n.ends_with(".tmp")));
536    }
537
538    #[test]
539    fn install_is_idempotent() {
540        let tmp = TempDir::new().unwrap();
541        let cache = tmp.path().join("cache");
542        let project = tmp.path().join("project");
543        let src_dir = tmp.path().join("src");
544        let src = tmp.path().join("x.mem");
545        std::fs::create_dir_all(&project).unwrap();
546        write_minimal_mem_config(&project, "specs");
547        build_valid_archive(&src_dir, &src, "alpha");
548
549        let _g = CacheGuard::install(&cache);
550        let first = cache_install(&src).unwrap();
551        assert!(first.copied_to_cache);
552
553        // Second run: the cache side effect reports `false`. The cache
554        // file survives untouched (existing-file guard fires before
555        // the canonical write) and the content key is stable.
556        let second = cache_install(&src).unwrap();
557        assert!(!second.copied_to_cache);
558        assert_eq!(first.cache_key, second.cache_key);
559    }
560
561    /// Two byte-distinct archives that share an internal mem name both
562    /// install successfully into distinct content-addressed cache files —
563    /// neither blocks nor silently shadows the other, and the registration
564    /// records each archive's own `cacheKey`. This replaces the prior
565    /// `CACHE_NAME_COLLISION` refusal, which was a dead end requiring
566    /// manual cache-file deletion.
567    #[test]
568    fn install_distinct_archives_same_name_coexist_via_content_address() {
569        let tmp = TempDir::new().unwrap();
570        let cache = tmp.path().join("cache");
571        let project = tmp.path().join("project");
572        let src_a_dir = tmp.path().join("src-a");
573        let src_a = tmp.path().join("a.mem");
574        std::fs::create_dir_all(&project).unwrap();
575        write_minimal_mem_config(&project, "specs");
576        build_valid_archive(&src_a_dir, &src_a, "alpha");
577
578        let _g = CacheGuard::install(&cache);
579        let first = cache_install(&src_a).unwrap();
580        assert!(first.copied_to_cache);
581        let key_a = first.cache_key.clone();
582
583        // Build a *different* archive that lands at the same canonical
584        // name (`alpha`) with distinct content.
585        let src_b_dir = tmp.path().join("src-b");
586        std::fs::create_dir_all(src_b_dir.join("alpha/.memstead")).unwrap();
587        std::fs::write(
588            src_b_dir.join("alpha/.memstead/config.json"),
589            r#"{"version":"1.2.0","schema":"default@1.0.0"}"#,
590        )
591        .unwrap();
592        std::fs::write(
593            src_b_dir.join("alpha/beta.md"),
594            "---\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",
595        ).unwrap();
596        let src_b = tmp.path().join("b.mem");
597        let cfg_b = memstead_schema::load_and_validate(&src_b_dir.join("alpha")).unwrap();
598        crate::ops::export::export_mem(&src_b_dir.join("alpha"), &cfg_b, &src_b, None, None)
599            .unwrap();
600        assert_ne!(
601            std::fs::read(&src_a).unwrap(),
602            std::fs::read(&src_b).unwrap(),
603            "fixture must produce two distinct archives sharing the name `alpha`"
604        );
605
606        // Second install (different bytes, same name): SUCCEEDS — no
607        // collision, no dead end.
608        let second = cache_install(&src_b).unwrap();
609        assert!(
610            second.copied_to_cache,
611            "distinct bytes must install, not collide"
612        );
613        let key_b = second.cache_key.clone();
614
615        // Distinct content ⇒ distinct keys ⇒ both cache files coexist.
616        assert_ne!(
617            key_a, key_b,
618            "distinct archives must get distinct content keys"
619        );
620        assert!(cache.join(format!("alpha-{key_a}.mem")).is_file());
621        assert!(cache.join(format!("alpha-{key_b}.mem")).is_file());
622    }
623
624    /// A re-install with byte-identical input is the idempotent success
625    /// path — no write, no commit, no churn, and
626    /// `copied_to_cache: false`. The pre-fix idempotency contract is
627    /// preserved; what's gone is the silent third state where
628    /// `copied_to_cache: false` admitted unrelated bytes.
629    #[test]
630    fn install_idempotent_path_returns_false_without_refusal() {
631        let tmp = TempDir::new().unwrap();
632        let cache = tmp.path().join("cache");
633        let project = tmp.path().join("project");
634        let src_dir = tmp.path().join("src");
635        let src = tmp.path().join("x.mem");
636        std::fs::create_dir_all(&project).unwrap();
637        write_minimal_mem_config(&project, "specs");
638        build_valid_archive(&src_dir, &src, "alpha");
639
640        let _g = CacheGuard::install(&cache);
641        let first = cache_install(&src).unwrap();
642        assert!(first.copied_to_cache);
643
644        // Re-install with the SAME archive bytes — canonical(input)
645        // matches the cache file → idempotent success.
646        let second = cache_install(&src).unwrap();
647        assert!(
648            !second.copied_to_cache,
649            "idempotent re-install must report copied_to_cache: false"
650        );
651    }
652
653    /// Rewrite a current-layout archive so its meta members live under a
654    /// non-whitelisted dir (`.other/` instead of `.memstead/`). Test-only.
655    fn repack_with_foreign_meta_dir(src: &Path, dest: &Path) {
656        use std::io::{Read as _, Write as _};
657        let file = std::fs::File::open(src).unwrap();
658        let mut archive = zip::ZipArchive::new(file).unwrap();
659        let out = std::fs::File::create(dest).unwrap();
660        let mut writer = zip::ZipWriter::new(out);
661        let opts = zip::write::SimpleFileOptions::default();
662        for i in 0..archive.len() {
663            let mut entry = archive.by_index(i).unwrap();
664            let name = entry.name().to_string();
665            let name = match name.strip_prefix(".memstead/") {
666                Some(rest) => format!(".other/{rest}"),
667                None => name,
668            };
669            let mut bytes = Vec::new();
670            entry.read_to_end(&mut bytes).unwrap();
671            writer.start_file(name, opts).unwrap();
672            writer.write_all(&bytes).unwrap();
673        }
674        writer.finish().unwrap();
675    }
676
677    /// Only the `.memstead/` meta layout is tolerated: an archive whose
678    /// meta members live under any other dir fails at validation — its
679    /// members fall outside the `.memstead/` whitelist.
680    #[test]
681    fn install_foreign_meta_layout_is_rejected() {
682        let tmp = TempDir::new().unwrap();
683        let cache = tmp.path().join("cache");
684        let project = tmp.path().join("project");
685        let src_dir = tmp.path().join("src");
686        let modern = tmp.path().join("modern.mem");
687        std::fs::create_dir_all(&project).unwrap();
688        write_minimal_mem_config(&project, "specs");
689        build_valid_archive(&src_dir, &modern, "foreign-mem");
690
691        let foreign = tmp.path().join("foreign-mem.mem");
692        repack_with_foreign_meta_dir(&modern, &foreign);
693
694        let _g = CacheGuard::install(&cache);
695        let err =
696            cache_install(&foreign).expect_err("a foreign meta-layout archive must not install");
697        assert!(matches!(err, InstallError::Validation(_)), "got {err:?}");
698    }
699
700    #[test]
701    fn install_rejects_non_archive_bytes() {
702        let tmp = TempDir::new().unwrap();
703        let cache = tmp.path().join("cache");
704        let project = tmp.path().join("project");
705        std::fs::create_dir_all(&project).unwrap();
706        write_minimal_mem_config(&project, "specs");
707        let src = tmp.path().join("bad.mem");
708        std::fs::write(&src, b"not a zip").unwrap();
709
710        let _g = CacheGuard::install(&cache);
711        let err = cache_install(&src).unwrap_err();
712        assert!(matches!(err, InstallError::Validation(_)));
713        // Validation failed up front → neither cache file nor temp
714        // sibling was written.
715        assert!(!cache.join("bad.mem").exists());
716        assert!(!cache.join("bad.mem.tmp").exists());
717    }
718}