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};
28use serde_json::{Map, Value, json};
29
30use crate::entity::loader::LoadError;
31use crate::mem_repo_config::{self, MemRepoWriteError};
32use crate::validator::{ValidationError, validate_and_normalize_archive};
33use crate::vcs::CommitContext;
34
35/// Where the per-mem `readMems` registration should land.
36///
37/// `Disk` mirrors the legacy disk-shaped workspace: `install_read_mem`
38/// reads `<mem_dir>/.memstead/config.json`, mutates `readMems`, and
39/// writes the updated bytes back. `MemRepo` targets the post-cutover
40/// mem-repo-backed workspace: the same mutation lands as a tree commit
41/// on `mem-repo-git:__MEMSTEAD:mems/<mem_name>/config.json` instead.
42///
43/// One enum keeps the validator + cache-copy logic shared across both
44/// shapes — the config-registration step is the only branching point.
45#[derive(Debug, Clone, Copy)]
46pub enum TargetMem<'a> {
47    /// Legacy disk-shaped mem. `path` is the directory containing
48    /// `.memstead/config.json`.
49    Disk(&'a Path),
50    /// Post-cutover mem-repo-backed mem. The config blob lives in
51    /// `<workspace_root>/mem-repo/.git/` at `__MEMSTEAD:mems/<mem_name>/config.json`.
52    MemRepo {
53        workspace_root: &'a Path,
54        mem_name: &'a str,
55    },
56}
57
58/// Env var that overrides `<data_dir>/memstead/mems` for tests.
59pub const CACHE_OVERRIDE_ENV: &str = "MEMSTEAD_MEM_CACHE";
60
61/// Resolve the global mem-cache directory.
62///
63/// Respects `MEMSTEAD_MEM_CACHE` if set — tests use this to point at a
64/// tempdir without touching the real user-data directory. Otherwise
65/// returns `<data_dir>/memstead/mems` on every platform (macOS / Linux /
66/// Windows), so the CLI and the Memstead app resolve to the same path
67/// without per-platform branching.
68///
69/// `dirs::data_dir()` is infallible on Tier-1 platforms; `expect` is
70/// fine for an engine that only runs on systems with a resolvable home.
71pub fn mem_cache_dir() -> PathBuf {
72    if let Ok(override_path) = std::env::var(CACHE_OVERRIDE_ENV)
73        && !override_path.is_empty()
74    {
75        return PathBuf::from(override_path);
76    }
77    dirs::data_dir()
78        .expect("platform provides a data directory")
79        .join("memstead")
80        .join("mems")
81}
82
83/// Read the whitelisted `.memstead/config.json` from a cached archive.
84///
85/// Does **not** re-run full archive validation — the cache only
86/// contains bytes the validator already approved, so entity parse and
87/// graph construction can be deferred to the caller. Configs are
88/// re-parsed with `parse_config_bytes` so the strict-ingress shape is
89/// enforced here as defense-in-depth against a tampered cache file.
90pub fn read_published_config(archive_path: &Path) -> Result<PublishedMemConfig, LoadError> {
91    if !archive_path.is_file() {
92        return Err(LoadError::ArchiveNotFound(
93            archive_path.display().to_string(),
94        ));
95    }
96    let file = std::fs::File::open(archive_path)?;
97    let mut archive = zip::ZipArchive::new(file)?;
98
99    // Take the mutable entry borrow only if the config member is
100    // present (`by_name` holds `&mut archive`).
101    let config_name = ARCHIVE_CONFIG_PATH;
102    if archive.index_for_name(config_name).is_none() {
103        return Err(LoadError::InvalidArchive(format!(
104            "missing {ARCHIVE_CONFIG_PATH} in {}",
105            archive_path.display()
106        )));
107    }
108    let mut entry = archive.by_name(config_name).map_err(|e| {
109        LoadError::InvalidArchive(format!(
110            "reading {config_name} in {}: {e}",
111            archive_path.display()
112        ))
113    })?;
114
115    let mut bytes = Vec::new();
116    entry.read_to_end(&mut bytes)?;
117
118    crate::validator::config::parse_config_bytes(&bytes).map_err(|e| {
119        LoadError::InvalidArchive(format!(
120            "invalid {ARCHIVE_CONFIG_PATH} in {}: {e}",
121            archive_path.display()
122        ))
123    })
124}
125
126/// Outcome of an `install_read_mem` call — captured so callers can log
127/// what actually happened without re-deriving it from side effects.
128#[derive(Debug, Clone)]
129pub struct InstallOutcome {
130    /// Mem name, taken from the validator's approved config.
131    pub mem_name: String,
132    /// `true` if canonical bytes were written into the cache on this
133    /// call; `false` if the content-addressed cache file already
134    /// existed and was left alone.
135    pub copied_to_cache: bool,
136    /// `true` if a new `readMems` entry was added to the mem config
137    /// on this call; `false` if the name was already declared.
138    pub registered_in_config: bool,
139    /// Typed non-fatal issues surfaced by the install.
140    pub warnings: Vec<WarningHint>,
141}
142
143#[derive(Debug, thiserror::Error)]
144pub enum InstallError {
145    #[error("could not read mem archive: {0}")]
146    Archive(#[from] LoadError),
147    #[error("io error while installing mem: {0}")]
148    Io(#[from] std::io::Error),
149    #[error("config error while registering mem: {0}")]
150    Config(#[from] memstead_schema::config::ConfigError),
151    #[error("archive failed strict validation: {0}")]
152    Validation(ValidationError),
153    /// Mem-db tree write failed. Carries the underlying gix error
154    /// message so callers can surface it without wrapping the variant.
155    #[error("mem-repo tree write failed: {0}")]
156    MemRepo(#[from] MemRepoWriteError),
157    /// The archive's
158    /// authoritative mem name (carried in its canonical config)
159    /// matches a writable mount that already exists in this
160    /// workspace. Registering the read-mem would silently shadow
161    /// (the engine's boot-time `hydrate_read_mems` skips read-mem
162    /// names that collide with writable mounts), so the install
163    /// surface refuses up-front rather than registering a no-op.
164    /// An earlier message advised `install to a different
165    /// `--mem-name` target` — but `--mem-name` selects the
166    /// *host* writable mem to register the read-mem into, not
167    /// the read-mem's internal name. The flag cannot rename the
168    /// archive. The genuine recovery is to unregister or rename
169    /// the writable mount that shadows the archive's internal name.
170    #[error(
171        "archive's mem name `{archive_name}` already exists as a writable mount in this workspace; \
172         unregister or rename the writable mount first (the `--mem` flag selects which writable \
173         host mem to register *into* — it does not rename the archive's internal mem)"
174    )]
175    ShadowsWritable {
176        archive_name: String,
177        shadows_writable: String,
178    },
179    // `CacheNameCollision` was retired once the cache became
180    // content-addressed (`<name>-<content_key>.mem`): distinct bytes
181    // under the same mem name land in distinct files and the collision
182    // class it guarded no longer exists. No engine surface can produce it.
183}
184
185/// Short content-address for an installed archive: the first 16 hex chars
186/// of `sha256(canonical_bytes)`. Used as the cache-file key
187/// (`<name>-<key>.mem`) and recorded in the `readMems` registration so
188/// the loader resolves the right file. 64 bits is ample collision
189/// resistance for a per-user cache; the same convention (truncated SHA-256
190/// hex) the entity content-hash uses.
191fn content_cache_key(canonical_bytes: &[u8]) -> String {
192    use sha2::{Digest, Sha256};
193    let digest = Sha256::digest(canonical_bytes);
194    digest[..8].iter().map(|b| format!("{b:02x}")).collect()
195}
196
197/// Install a sealed mem archive into the global cache and register
198/// it in a writable mem's config. Accepts the `.mem` archive format.
199///
200/// Two independent side effects, both idempotent:
201///
202/// 1. If the content-addressed cache file does not exist: run the submitted bytes
203///    through `validate_and_normalize_archive` and write the
204///    validator's `canonical_bytes` via a `.tmp` sibling + atomic
205///    rename. A mid-write crash leaves the temp file behind, never a
206///    partial cache file. Existing cache files are left untouched —
207///    overwrite-on-newer-version is an app-level update flow, not a
208///    CLI install semantic. Users who want to force-replace can delete
209///    the cache file first.
210/// 2. If the target mem's config does not already list this mem
211///    under `readMems`, add an entry with `source: { type: "local" }`.
212///    Existing entries are left untouched so re-running install never
213///    clobbers a `type: "url"` (etc.) source the user configured by hand.
214///
215/// The `target` parameter selects where the registration lands:
216/// - `TargetMem::Disk(mem_dir)` writes the updated config back to
217///   `<mem_dir>/.memstead/config.json` (legacy disk shape).
218/// - `TargetMem::MemRepo { workspace_root, mem_name }` commits the
219///   updated `configs/<mem_name>.json` to `mem-repo-git:main` (post-
220///   cutover shape).
221///
222/// `ctx` and `commit_message` are used only by the `MemRepo` arm —
223/// the disk arm rewrites the file via the existing config-update path
224/// which has its own (file-mtime-based) provenance trail.
225///
226/// Returns an `InstallOutcome` describing which effects fired. The
227/// authoritative mem name comes from the validator's approved
228/// config, not from the submitted filename or caller argument.
229pub fn install_read_mem(
230    archive_path: &Path,
231    target: TargetMem<'_>,
232    ctx: &CommitContext<'_>,
233    commit_message: &str,
234    writable_mem_names: &[&str],
235) -> Result<InstallOutcome, InstallError> {
236    // 1. Validate + canonicalize. Never install bytes the validator
237    //    rejected; never install the caller's original bytes — what
238    //    lands in the cache is always the validator's canonical form.
239    let bytes = std::fs::read(archive_path)?;
240    let validated = validate_and_normalize_archive(&bytes).map_err(InstallError::Validation)?;
241
242    let warnings: Vec<WarningHint> = Vec::new();
243
244    // Refuse up-front
245    // when the archive's authoritative name shadows a writable mount
246    // in the caller's workspace. The boot-time
247    // `hydrate_read_mems` silently skips a read-mem registration
248    // that collides with a writable mount — without this gate the
249    // install reports success but the subsequent reload produces no
250    // observable effect. The check is opt-in via the caller-supplied
251    // `writable_mem_names` slice; passing an empty slice (no
252    // workspace context available) skips the gate, preserving the
253    // engine-helper's testability in non-workspace contexts.
254    if let Some(shadowed) = writable_mem_names
255        .iter()
256        .find(|n| **n == validated.config.name.as_str())
257    {
258        return Err(InstallError::ShadowsWritable {
259            archive_name: validated.config.name.clone(),
260            shadows_writable: (*shadowed).to_string(),
261        });
262    }
263
264    // 2. Content-addressed atomic-rename write. The cache file is keyed
265    //    by `<name>-<content_key>.mem`, where `content_key` is a short
266    //    digest of the validator's canonical bytes. `name` passed the
267    //    strict slug regex and the key is hex, so the path is provably
268    //    safe on every platform.
269    //
270    //    Content-addressing removes the
271    //    name-collision class entirely. Two distinct archives sharing an
272    //    internal mem name produce distinct keys → distinct files, so
273    //    they coexist in the global cache without one shadowing the other
274    //    (the per-registration `cacheKey` resolves each workspace to the
275    //    right file). Re-installing byte-identical content resolves to the
276    //    same key → the file already exists → idempotent dedup no-op. The
277    //    prior `CACHE_NAME_COLLISION` dead end (distinct bytes, same name,
278    //    no engine-reachable remedy) can no longer occur.
279    let cache_dir = mem_cache_dir();
280    std::fs::create_dir_all(&cache_dir)?;
281    let cache_key = content_cache_key(&validated.canonical_bytes);
282    let dest = cache_dir.join(format!(
283        "{}-{}.{ARCHIVE_EXTENSION}",
284        validated.config.name, cache_key
285    ));
286    let copied_to_cache = if dest.exists() {
287        // The key IS the content digest, so an existing file at this path
288        // is byte-identical by construction — dedup, skip the write.
289        false
290    } else {
291        let tmp = dest.with_extension(format!("{ARCHIVE_EXTENSION}.tmp"));
292        std::fs::write(&tmp, &validated.canonical_bytes)?;
293        std::fs::rename(&tmp, &dest)?;
294        true
295    };
296
297    // 3. Config-registration side effect — branches on target shape. The
298    //    `cache_key` is recorded in the `readMems` entry so the loader
299    //    resolves the content-addressed file.
300    let registered_in_config = match target {
301        TargetMem::Disk(mem_dir) => {
302            let (mut config, config_path) = memstead_schema::config::load_config(mem_dir)?;
303            register_read_mem_in_config(
304                &config_path,
305                &mut config,
306                &validated.config.name,
307                &cache_key,
308            )?
309        }
310        TargetMem::MemRepo {
311            workspace_root,
312            mem_name,
313        } => register_read_mem_in_mem_repo(
314            workspace_root,
315            mem_name,
316            &validated.config.name,
317            &cache_key,
318            ctx,
319            commit_message,
320        )?,
321    };
322
323    Ok(InstallOutcome {
324        mem_name: validated.config.name,
325        copied_to_cache,
326        registered_in_config,
327        warnings,
328    })
329}
330
331/// Register `read_mem_name` in the workspace mem `mem_name`'s
332/// `configs/<mem_name>.json` blob on `mem-repo-git:main`. Read-modify-
333/// write: parse the existing blob, insert the `readMems` entry if
334/// missing, serialize, commit on top of `main`. Returns `true` if the
335/// entry was added, `false` if it was already declared (no commit lands).
336///
337/// Race window: non-atomic against concurrent writers on `main`. See
338/// `mem_repo_config::commit_config`'s docstring.
339fn register_read_mem_in_mem_repo(
340    workspace_root: &Path,
341    mem_name: &str,
342    read_mem_name: &str,
343    cache_key: &str,
344    ctx: &CommitContext<'_>,
345    commit_message: &str,
346) -> Result<bool, InstallError> {
347    use memstead_schema::config::ConfigError;
348
349    // Read the current blob bytes from the tree, parse as JSON, mutate.
350    let config = mem_repo_config::read_config(workspace_root, mem_name)
351        .map_err(|e| ConfigError::Other(format!("read configs/{mem_name}.json: {e}")))?;
352    let mut value = serde_json::to_value(&config)
353        .map_err(|e| ConfigError::Other(format!("re-serialize MemConfig: {e}")))?;
354    let obj = value
355        .as_object_mut()
356        .ok_or_else(|| ConfigError::Other("config root must be a JSON object".into()))?;
357
358    let entry = obj
359        .entry("readMems")
360        .or_insert_with(|| Value::Object(Map::new()));
361    let map = entry
362        .as_object_mut()
363        .ok_or_else(|| ConfigError::Other("readMems must be a JSON object".into()))?;
364
365    if map.contains_key(read_mem_name) {
366        return Ok(false);
367    }
368
369    map.insert(
370        read_mem_name.to_string(),
371        json!({ "source": { "type": "local" }, "cacheKey": cache_key }),
372    );
373
374    let updated_bytes = serde_json::to_vec_pretty(&value)
375        .map_err(|e| ConfigError::Other(format!("serialize updated config: {e}")))?;
376    mem_repo_config::commit_config(
377        workspace_root,
378        mem_name,
379        &updated_bytes,
380        ctx,
381        commit_message,
382    )?;
383    Ok(true)
384}
385
386/// Add a `readMems` entry for `mem_name` with `source: { type: "local" }`
387/// to `config` and persist the change. Returns `true` if the map changed,
388/// `false` if the name was already declared (any source) so the config was
389/// left untouched and no write happened.
390///
391/// Kept private because the only valid caller today is `install_read_mem`;
392/// hand-editing read mems from inside the engine would bypass the
393/// archive-validation step up front.
394fn register_read_mem_in_config(
395    config_path: &Path,
396    config: &mut Value,
397    mem_name: &str,
398    cache_key: &str,
399) -> Result<bool, memstead_schema::config::ConfigError> {
400    let obj = config.as_object_mut().ok_or_else(|| {
401        memstead_schema::config::ConfigError::Other("config root must be a JSON object".into())
402    })?;
403
404    let entry = obj
405        .entry("readMems")
406        .or_insert_with(|| Value::Object(Map::new()));
407    let map = entry.as_object_mut().ok_or_else(|| {
408        memstead_schema::config::ConfigError::Other("readMems must be a JSON object".into())
409    })?;
410
411    if map.contains_key(mem_name) {
412        return Ok(false);
413    }
414
415    map.insert(
416        mem_name.to_string(),
417        json!({ "source": { "type": "local" }, "cacheKey": cache_key }),
418    );
419
420    // Route through update_config_field so the commit path (validation +
421    // pretty-print + trailing newline) stays in one place. We pass the
422    // already-mutated map back in so the writer just serializes it.
423    let new_read_mems = Value::Object(map.clone());
424    memstead_schema::config::update_config_field(
425        config_path,
426        config,
427        "readMems",
428        new_read_mems,
429        false,
430    )?;
431    Ok(true)
432}
433
434/// Outcome of `extract_archive_schema_if_needed` — so callers can log
435/// the specific reason a no-op happened, or know whether the mem's
436/// schema registry needs to be rebuilt.
437#[derive(Debug, Clone, PartialEq, Eq)]
438pub enum SchemaExtractionOutcome {
439    /// The archive's pinned schema is already registered — extraction
440    /// skipped. Author-layer schemas always shadow cache entries, so
441    /// skipping when the registry already knows the pin preserves the
442    /// documented precedence order.
443    AlreadyRegistered,
444    /// The archive carries no `.memstead/schema/` tree. Loading still works
445    /// if the pin happens to be in the registry; otherwise the normal
446    /// `resolve_mem_schema` path reports the missing schema with its
447    /// actionable error.
448    NoEmbeddedSchema,
449    /// A cache entry at
450    /// `<workspace_root>/.memstead.cache/schemas/<name>-<version>/`
451    /// already existed on disk — extraction skipped, but the registry
452    /// may still need a rebuild if the caller hadn't picked it up yet.
453    CacheAlreadyPopulated,
454    /// Fresh extraction wrote files into
455    /// `<workspace_root>/.memstead.cache/schemas/<name>-<version>/`. Caller
456    /// must rebuild the `SchemaRegistry` to pick it up.
457    Extracted { schema: SchemaRef, path: PathBuf },
458}
459
460#[derive(Debug, thiserror::Error)]
461pub enum SchemaExtractionError {
462    #[error("could not read mem archive {}: {source}", .archive_path.display())]
463    Archive {
464        archive_path: PathBuf,
465        #[source]
466        source: LoadError,
467    },
468    #[error("archive {} failed strict validation: {source}", .archive_path.display())]
469    Validation {
470        archive_path: PathBuf,
471        #[source]
472        source: ValidationError,
473    },
474    #[error("i/o error extracting schema to {}: {source}", .path.display())]
475    Io {
476        path: PathBuf,
477        #[source]
478        source: std::io::Error,
479    },
480}
481
482/// Extract the schema embedded in `archive_path` into the writable
483/// mem's cache, but only when the pinned `(name, version)` is not
484/// already registered.
485///
486/// Idempotent: repeated calls on the same archive are safe. Runs the
487/// archive through `validate_and_normalize_archive` — which enforces
488/// embedded-schema integrity (the loader-based manifest check + name/
489/// version match against `.memstead/config.json`), so a corrupt schema
490/// surfaces here as a `Validation` error instead of silently polluting
491/// the cache.
492///
493/// The extraction path is atomic: files are written into a sibling
494/// `.tmp` directory and renamed into place only after every byte has
495/// landed. A mid-write crash leaves the `.tmp` sibling behind, never
496/// a half-populated `<name>-<version>/` that a subsequent
497/// `SchemaRegistry::load_for_mem` might try to load.
498pub fn extract_archive_schema_if_needed(
499    archive_path: &Path,
500    workspace_root: &Path,
501    registry: &SchemaRegistry,
502) -> Result<SchemaExtractionOutcome, SchemaExtractionError> {
503    // Cheap prefix pass: read only the archive's published config so we can skip
504    // the full validation for archives whose pin is already in the
505    // registry (the common case for repeat loads).
506    let config =
507        read_published_config(archive_path).map_err(|source| SchemaExtractionError::Archive {
508            archive_path: archive_path.to_path_buf(),
509            source,
510        })?;
511    if registry
512        .get(&config.schema.name, &config.schema.version)
513        .is_some()
514    {
515        return Ok(SchemaExtractionOutcome::AlreadyRegistered);
516    }
517
518    let dest = workspace_root
519        .join(".memstead.cache/schemas")
520        .join(format!("{}-{}", config.schema.name, config.schema.version));
521    if dest.is_dir() {
522        // Someone already extracted; the registry just hasn't rebuilt
523        // with the cache pass yet. Caller rebuilds.
524        return Ok(SchemaExtractionOutcome::CacheAlreadyPopulated);
525    }
526
527    // Full validation — loads the archive, validates the embedded schema
528    // via `check_embedded_schema`, produces canonical bytes. We only
529    // need the schema files, but paying for the full pipeline once on
530    // cache-miss is correct: an attacker who drops a tampered archive
531    // into the global cache doesn't get to seed the workspace from an
532    // unvalidated payload.
533    let bytes = std::fs::read(archive_path).map_err(|source| SchemaExtractionError::Io {
534        path: archive_path.to_path_buf(),
535        source,
536    })?;
537    let validated = validate_and_normalize_archive(&bytes).map_err(|source| {
538        SchemaExtractionError::Validation {
539            archive_path: archive_path.to_path_buf(),
540            source,
541        }
542    })?;
543
544    if validated.schema_files.is_empty() {
545        return Ok(SchemaExtractionOutcome::NoEmbeddedSchema);
546    }
547
548    extract_schema_files_atomic(&validated.schema_files, &dest).map_err(|source| {
549        SchemaExtractionError::Io {
550            path: dest.clone(),
551            source,
552        }
553    })?;
554
555    Ok(SchemaExtractionOutcome::Extracted {
556        schema: config.schema,
557        path: dest,
558    })
559}
560
561/// Write `schema_files` to `dest` via a sibling `.tmp` directory that
562/// is renamed into place once every file has been written. Rename
563/// atomicity varies by FS but every supported target (ext4, HFS+, APFS,
564/// NTFS) gives us "dest contains every file or nothing," which is the
565/// invariant the load path relies on. The incoming paths always start
566/// with `.memstead/schema/` (legacy archives are normalized at extract
567/// time) — we strip that prefix so the on-disk layout matches the
568/// schema-cache shape `schema.yaml` + `types/<t>.yaml` exactly.
569fn extract_schema_files_atomic(
570    schema_files: &[crate::validator::archive::SchemaFile],
571    dest: &Path,
572) -> std::io::Result<()> {
573    let parent = dest
574        .parent()
575        .ok_or_else(|| std::io::Error::other("schema cache destination has no parent directory"))?;
576    std::fs::create_dir_all(parent)?;
577
578    // Sibling tmp dir name is dot-prefixed so `list_schema_subdirs`
579    // ignores it if a crash between write and rename leaves a straggler.
580    // PID + monotonic counter guarantees uniqueness across concurrent
581    // extractions of the same pin; the atomic rename serializes the
582    // winner and the loser's tmp dir gets best-effort cleanup on the
583    // returned error.
584    let ts = std::time::SystemTime::now()
585        .duration_since(std::time::UNIX_EPOCH)
586        .map(|d| d.as_nanos())
587        .unwrap_or(0);
588    let tmp = parent.join(format!(
589        ".memstead-schema-extract-{}-{}",
590        std::process::id(),
591        ts,
592    ));
593
594    // Wipe any leftover from a previous failed extract with the same
595    // PID+time — the path is ours by construction.
596    let _ = std::fs::remove_dir_all(&tmp);
597    std::fs::create_dir_all(&tmp)?;
598
599    for sf in schema_files {
600        let rel = sf
601            .archive_path
602            .strip_prefix(ARCHIVE_SCHEMA_PREFIX)
603            .unwrap_or(sf.archive_path.as_str());
604        let file_path = tmp.join(rel);
605        if let Some(file_parent) = file_path.parent() {
606            std::fs::create_dir_all(file_parent)?;
607        }
608        std::fs::write(&file_path, sf.content.as_bytes())?;
609    }
610
611    match std::fs::rename(&tmp, dest) {
612        Ok(()) => Ok(()),
613        Err(e) => {
614            // Rename lost (dest appeared from a racer, or some other
615            // filesystem error). Clean up our tmp so we don't leave a
616            // stray `.memstead-schema-extract-*` sibling behind.
617            let _ = std::fs::remove_dir_all(&tmp);
618            Err(e)
619        }
620    }
621}
622
623#[cfg(test)]
624mod tests {
625    use super::*;
626    use crate::ops::export::export_mem;
627    use tempfile::TempDir;
628
629    /// Write a minimal valid mem directory to `mem_dir` and export it
630    /// to `archive_path`. The resulting archive passes
631    /// `validate_and_normalize_archive` — the fixture exists precisely so
632    /// install tests don't have to hand-build validator-compliant bytes.
633    fn build_valid_archive(mem_dir: &Path, archive_path: &Path, name: &str) {
634        // Configs no longer carry an in-config `name` field. The
635        // archive's identity comes from the disk-path basename via the
636        // `published_config_from` fallback chain. Build the mem
637        // directory under `<mem_dir.parent>/<name>/` so the basename
638        // matches the requested name; tests can pass any throwaway
639        // `mem_dir` path and trust the helper to align them.
640        let mem_dir = mem_dir.parent().unwrap_or(mem_dir).join(name);
641        std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
642        std::fs::write(
643            mem_dir.join(".memstead/config.json"),
644            r#"{"version":"1.2.0","schema":"default@1.0.0"}"#,
645        )
646        .unwrap();
647        std::fs::write(
648            mem_dir.join("alpha.md"),
649            "---\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",
650        ).unwrap();
651
652        let config = memstead_schema::load_and_validate(&mem_dir).unwrap();
653        // No workspace context — the schema-source resolver falls through
654        // to the embedded builtin.
655        export_mem(&mem_dir, &config, archive_path, None, None).unwrap();
656    }
657
658    /// Disk-shape install convenience for the existing test fixtures.
659    /// Wraps `install_read_mem(archive, TargetMem::Disk(project), ...)`
660    /// with a deterministic dummy commit context so the call shape stays
661    /// minimal at every test site.
662    fn install_to_disk(archive: &Path, project: &Path) -> Result<InstallOutcome, InstallError> {
663        install_read_mem(
664            archive,
665            TargetMem::Disk(project),
666            &CommitContext::internal(),
667            "memstead: install (test)",
668            &[],
669        )
670    }
671
672    /// Build a writable-mem config directory for install tests. Adds the
673    /// minimal fields the config writer expects on load.
674    fn write_minimal_mem_config(dir: &Path, _name: &str) {
675        std::fs::create_dir_all(dir.join(".memstead")).unwrap();
676        std::fs::write(
677            dir.join(".memstead/config.json"),
678            r#"{"version":"1.0.0","schema":"default@1.0.0"}"#,
679        )
680        .unwrap();
681    }
682
683    /// Process-global env lock. All install-helper tests take this before
684    /// touching `MEMSTEAD_MEM_CACHE` so parallel runs inside the same
685    /// cargo-test binary don't race on the shared process env. Rust 2024
686    /// makes `env::set_var` unsafe precisely because concurrent reads can
687    /// tear — the lock is the safety contract.
688    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
689
690    /// RAII guard for `MEMSTEAD_MEM_CACHE`: holds the global lock, installs
691    /// the override, restores the previous value on drop.
692    struct CacheGuard {
693        _lock: std::sync::MutexGuard<'static, ()>,
694        prev: Option<String>,
695    }
696    impl CacheGuard {
697        fn install(cache_dir: &Path) -> Self {
698            let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
699            let prev = std::env::var(CACHE_OVERRIDE_ENV).ok();
700            // SAFETY: the global mutex above serializes env access for
701            // every test in this module; no other reader runs concurrently.
702            unsafe {
703                std::env::set_var(CACHE_OVERRIDE_ENV, cache_dir);
704            }
705            Self { _lock: lock, prev }
706        }
707    }
708    impl Drop for CacheGuard {
709        fn drop(&mut self) {
710            // SAFETY: we still hold the lock acquired in `install`.
711            unsafe {
712                match self.prev.take() {
713                    Some(v) => std::env::set_var(CACHE_OVERRIDE_ENV, v),
714                    None => std::env::remove_var(CACHE_OVERRIDE_ENV),
715                }
716            }
717        }
718    }
719
720    #[test]
721    fn mem_cache_dir_honors_env_override() {
722        let custom = std::env::temp_dir().join("memstead-cache-override-test");
723        let _g = CacheGuard::install(&custom);
724        assert_eq!(mem_cache_dir(), custom);
725    }
726
727    #[test]
728    fn read_published_config_reads_whitelist_fields() {
729        let tmp = TempDir::new().unwrap();
730        // Published archive identity comes from the disk-path basename
731        // via the `published_config_from` fallback chain (the in-config
732        // `name` field is no longer authored).
733        let mem_src = tmp.path().join("sample");
734        let archive = tmp.path().join("sample.mem");
735        build_valid_archive(&mem_src, &archive, "sample");
736
737        let config = read_published_config(&archive).unwrap();
738        assert_eq!(config.format, memstead_schema::PUBLISHED_MEM_FORMAT);
739        assert_eq!(config.name, "sample");
740        assert_eq!(config.version.to_string(), "1.2.0");
741    }
742
743    #[test]
744    fn read_published_config_missing_file_is_archive_not_found() {
745        let err = read_published_config(&PathBuf::from("/nonexistent/nope.mem")).unwrap_err();
746        assert!(matches!(err, LoadError::ArchiveNotFound(_)));
747    }
748
749    #[test]
750    fn read_published_config_corrupt_archive_is_zip_error() {
751        let tmp = TempDir::new().unwrap();
752        let archive = tmp.path().join("corrupt.mem");
753        std::fs::write(&archive, b"definitely not a zip").unwrap();
754        let err = read_published_config(&archive).unwrap_err();
755        assert!(matches!(err, LoadError::Zip(_)));
756    }
757
758    #[test]
759    fn install_validates_and_canonicalizes() {
760        let tmp = TempDir::new().unwrap();
761        let cache = tmp.path().join("cache");
762        let project = tmp.path().join("project");
763        let src_dir = tmp.path().join("src");
764        let src = tmp.path().join("aws-patterns.mem");
765
766        std::fs::create_dir_all(&project).unwrap();
767        write_minimal_mem_config(&project, "specs");
768        build_valid_archive(&src_dir, &src, "aws-patterns");
769
770        let _g = CacheGuard::install(&cache);
771        let outcome = install_to_disk(&src, &project).unwrap();
772
773        assert_eq!(outcome.mem_name, "aws-patterns");
774        assert!(outcome.copied_to_cache);
775        assert!(outcome.registered_in_config);
776        assert!(
777            outcome.warnings.is_empty(),
778            "current-format install must not warn: {:?}",
779            outcome.warnings
780        );
781
782        // Project config lists the mem with a local source and the
783        // content-addressed cache key the loader resolves against.
784        let cfg_raw = std::fs::read_to_string(project.join(".memstead/config.json")).unwrap();
785        let cfg: serde_json::Value = serde_json::from_str(&cfg_raw).unwrap();
786        let rv = cfg["readMems"]["aws-patterns"]["source"]["type"].as_str();
787        assert_eq!(rv, Some("local"));
788        let key = cfg["readMems"]["aws-patterns"]["cacheKey"]
789            .as_str()
790            .expect("registration must record the content cacheKey");
791
792        let cached = cache.join(format!("aws-patterns-{key}.mem"));
793        assert!(cached.is_file(), "content-addressed cache file must exist");
794
795        // Cached bytes must equal the validator's canonical form, and the
796        // recorded key must be the digest of those bytes.
797        let cached_bytes = std::fs::read(&cached).unwrap();
798        let revalidated = validate_and_normalize_archive(&cached_bytes).unwrap();
799        assert_eq!(revalidated.canonical_bytes, cached_bytes);
800        assert_eq!(
801            key,
802            content_cache_key(&cached_bytes),
803            "cacheKey is the content digest"
804        );
805    }
806
807    #[test]
808    fn install_leaves_no_tmp_on_success() {
809        let tmp = TempDir::new().unwrap();
810        let cache = tmp.path().join("cache");
811        let project = tmp.path().join("project");
812        let src_dir = tmp.path().join("src");
813        let src = tmp.path().join("x.mem");
814        std::fs::create_dir_all(&project).unwrap();
815        write_minimal_mem_config(&project, "specs");
816        build_valid_archive(&src_dir, &src, "alpha");
817
818        let _g = CacheGuard::install(&cache);
819        install_to_disk(&src, &project).unwrap();
820
821        // The temp-then-rename path must leave the content-addressed
822        // `<name>-<key>.mem` on disk and never the `.tmp` sibling. The
823        // filename is derived from the validator's approved `config.name`
824        // ("alpha") plus the content key, not from the submitted filename.
825        let entries: Vec<_> = std::fs::read_dir(&cache)
826            .unwrap()
827            .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
828            .collect();
829        assert_eq!(
830            entries.iter().filter(|n| n.ends_with(".mem")).count(),
831            1,
832            "exactly one cache file, no .tmp sibling: {entries:?}",
833        );
834        let cache_file = entries.iter().find(|n| n.ends_with(".mem")).unwrap();
835        assert!(
836            cache_file.starts_with("alpha-"),
837            "name-keyed prefix: {cache_file}"
838        );
839        assert!(!entries.iter().any(|n| n.ends_with(".tmp")));
840    }
841
842    #[test]
843    fn install_is_idempotent() {
844        let tmp = TempDir::new().unwrap();
845        let cache = tmp.path().join("cache");
846        let project = tmp.path().join("project");
847        let src_dir = tmp.path().join("src");
848        let src = tmp.path().join("x.mem");
849        std::fs::create_dir_all(&project).unwrap();
850        write_minimal_mem_config(&project, "specs");
851        build_valid_archive(&src_dir, &src, "alpha");
852
853        let _g = CacheGuard::install(&cache);
854        let first = install_to_disk(&src, &project).unwrap();
855        assert!(first.copied_to_cache);
856        assert!(first.registered_in_config);
857
858        // Second run: both side effects report `false`. The cache file
859        // survives untouched (existing-file guard fires before the
860        // canonical write).
861        let second = install_to_disk(&src, &project).unwrap();
862        assert!(!second.copied_to_cache);
863        assert!(!second.registered_in_config);
864    }
865
866    #[test]
867    fn install_preserves_existing_non_local_source() {
868        let tmp = TempDir::new().unwrap();
869        let cache = tmp.path().join("cache");
870        let project = tmp.path().join("project");
871        let src_dir = tmp.path().join("src");
872        let src = tmp.path().join("x.mem");
873        std::fs::create_dir_all(project.join(".memstead")).unwrap();
874        std::fs::write(
875            project.join(".memstead/config.json"),
876            r#"{
877                "version":"1.0.0",
878                "schema":"default@1.0.0",
879                "readMems": {
880                    "alpha": {"source":{"type":"url","url":"https://example.com/x.mem"}}
881                }
882            }"#,
883        )
884        .unwrap();
885        build_valid_archive(&src_dir, &src, "alpha");
886
887        let _g = CacheGuard::install(&cache);
888        let outcome = install_to_disk(&src, &project).unwrap();
889        assert!(outcome.copied_to_cache);
890        assert!(
891            !outcome.registered_in_config,
892            "existing entry must not be overwritten"
893        );
894
895        let cfg_raw = std::fs::read_to_string(project.join(".memstead/config.json")).unwrap();
896        let cfg: serde_json::Value = serde_json::from_str(&cfg_raw).unwrap();
897        assert_eq!(
898            cfg["readMems"]["alpha"]["source"]["type"].as_str(),
899            Some("url")
900        );
901    }
902
903    /// Two byte-distinct archives that share an internal mem name both
904    /// install successfully into distinct content-addressed cache files —
905    /// neither blocks nor silently shadows the other, and the registration
906    /// records each archive's own `cacheKey`. This replaces the prior
907    /// `CACHE_NAME_COLLISION` refusal, which was a dead end requiring
908    /// manual cache-file deletion.
909    #[test]
910    fn install_distinct_archives_same_name_coexist_via_content_address() {
911        let tmp = TempDir::new().unwrap();
912        let cache = tmp.path().join("cache");
913        let project = tmp.path().join("project");
914        let src_a_dir = tmp.path().join("src-a");
915        let src_a = tmp.path().join("a.mem");
916        std::fs::create_dir_all(&project).unwrap();
917        write_minimal_mem_config(&project, "specs");
918        build_valid_archive(&src_a_dir, &src_a, "alpha");
919
920        let _g = CacheGuard::install(&cache);
921        let first = install_to_disk(&src_a, &project).unwrap();
922        assert!(first.copied_to_cache);
923        let key_a = std::fs::read_to_string(project.join(".memstead/config.json"))
924            .ok()
925            .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
926            .and_then(|c| {
927                c["readMems"]["alpha"]["cacheKey"]
928                    .as_str()
929                    .map(String::from)
930            })
931            .expect("first install records a cacheKey");
932
933        // Build a *different* archive that lands at the same canonical
934        // name (`alpha`) with distinct content.
935        let src_b_dir = tmp.path().join("src-b");
936        std::fs::create_dir_all(src_b_dir.join("alpha/.memstead")).unwrap();
937        std::fs::write(
938            src_b_dir.join("alpha/.memstead/config.json"),
939            r#"{"version":"1.2.0","schema":"default@1.0.0"}"#,
940        )
941        .unwrap();
942        std::fs::write(
943            src_b_dir.join("alpha/beta.md"),
944            "---\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",
945        ).unwrap();
946        let src_b = tmp.path().join("b.mem");
947        let cfg_b = memstead_schema::load_and_validate(&src_b_dir.join("alpha")).unwrap();
948        crate::ops::export::export_mem(&src_b_dir.join("alpha"), &cfg_b, &src_b, None, None)
949            .unwrap();
950        assert_ne!(
951            std::fs::read(&src_a).unwrap(),
952            std::fs::read(&src_b).unwrap(),
953            "fixture must produce two distinct archives sharing the name `alpha`"
954        );
955
956        // Second install (different bytes, same name): SUCCEEDS — no
957        // collision, no dead end. A second project registers it.
958        let project_b = tmp.path().join("project-b");
959        std::fs::create_dir_all(&project_b).unwrap();
960        write_minimal_mem_config(&project_b, "specs");
961        let second = install_read_mem(
962            &src_b,
963            TargetMem::Disk(&project_b),
964            &CommitContext::internal(),
965            "memstead: install (test)",
966            &[],
967        )
968        .unwrap();
969        assert!(
970            second.copied_to_cache,
971            "distinct bytes must install, not collide"
972        );
973        let key_b = std::fs::read_to_string(project_b.join(".memstead/config.json"))
974            .ok()
975            .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
976            .and_then(|c| {
977                c["readMems"]["alpha"]["cacheKey"]
978                    .as_str()
979                    .map(String::from)
980            })
981            .expect("second install records a cacheKey");
982
983        // Distinct content ⇒ distinct keys ⇒ both cache files coexist.
984        assert_ne!(
985            key_a, key_b,
986            "distinct archives must get distinct content keys"
987        );
988        assert!(cache.join(format!("alpha-{key_a}.mem")).is_file());
989        assert!(cache.join(format!("alpha-{key_b}.mem")).is_file());
990    }
991
992    /// A re-install with byte-identical input is the idempotent success
993    /// path — no write, no commit, no churn, and
994    /// `copied_to_cache: false`. The pre-fix idempotency contract is
995    /// preserved; what's gone is the silent third state where
996    /// `copied_to_cache: false` admitted unrelated bytes.
997    #[test]
998    fn install_idempotent_path_returns_false_without_refusal() {
999        let tmp = TempDir::new().unwrap();
1000        let cache = tmp.path().join("cache");
1001        let project = tmp.path().join("project");
1002        let src_dir = tmp.path().join("src");
1003        let src = tmp.path().join("x.mem");
1004        std::fs::create_dir_all(&project).unwrap();
1005        write_minimal_mem_config(&project, "specs");
1006        build_valid_archive(&src_dir, &src, "alpha");
1007
1008        let _g = CacheGuard::install(&cache);
1009        let first = install_to_disk(&src, &project).unwrap();
1010        assert!(first.copied_to_cache);
1011
1012        // Re-install with the SAME archive bytes — canonical(input)
1013        // matches the cache file → idempotent success.
1014        let second = install_to_disk(&src, &project).unwrap();
1015        assert!(
1016            !second.copied_to_cache,
1017            "idempotent re-install must report copied_to_cache: false"
1018        );
1019        assert!(
1020            !second.registered_in_config,
1021            "idempotent re-install must not re-register"
1022        );
1023    }
1024
1025    /// Rewrite a current-layout archive so its meta members live under a
1026    /// non-whitelisted dir (`.other/` instead of `.memstead/`). Test-only.
1027    fn repack_with_foreign_meta_dir(src: &Path, dest: &Path) {
1028        use std::io::{Read as _, Write as _};
1029        let file = std::fs::File::open(src).unwrap();
1030        let mut archive = zip::ZipArchive::new(file).unwrap();
1031        let out = std::fs::File::create(dest).unwrap();
1032        let mut writer = zip::ZipWriter::new(out);
1033        let opts = zip::write::SimpleFileOptions::default();
1034        for i in 0..archive.len() {
1035            let mut entry = archive.by_index(i).unwrap();
1036            let name = entry.name().to_string();
1037            let name = match name.strip_prefix(".memstead/") {
1038                Some(rest) => format!(".other/{rest}"),
1039                None => name,
1040            };
1041            let mut bytes = Vec::new();
1042            entry.read_to_end(&mut bytes).unwrap();
1043            writer.start_file(name, opts).unwrap();
1044            writer.write_all(&bytes).unwrap();
1045        }
1046        writer.finish().unwrap();
1047    }
1048
1049    /// Only the `.memstead/` meta layout is tolerated: an archive whose
1050    /// meta members live under any other dir fails at validation — its
1051    /// members fall outside the `.memstead/` whitelist.
1052    #[test]
1053    fn install_foreign_meta_layout_is_rejected() {
1054        let tmp = TempDir::new().unwrap();
1055        let cache = tmp.path().join("cache");
1056        let project = tmp.path().join("project");
1057        let src_dir = tmp.path().join("src");
1058        let modern = tmp.path().join("modern.mem");
1059        std::fs::create_dir_all(&project).unwrap();
1060        write_minimal_mem_config(&project, "specs");
1061        build_valid_archive(&src_dir, &modern, "foreign-mem");
1062
1063        let foreign = tmp.path().join("foreign-mem.mem");
1064        repack_with_foreign_meta_dir(&modern, &foreign);
1065
1066        let _g = CacheGuard::install(&cache);
1067        let err = install_to_disk(&foreign, &project)
1068            .expect_err("a foreign meta-layout archive must not install");
1069        assert!(matches!(err, InstallError::Validation(_)), "got {err:?}");
1070    }
1071
1072    #[test]
1073    fn install_rejects_non_archive_bytes() {
1074        let tmp = TempDir::new().unwrap();
1075        let cache = tmp.path().join("cache");
1076        let project = tmp.path().join("project");
1077        std::fs::create_dir_all(&project).unwrap();
1078        write_minimal_mem_config(&project, "specs");
1079        let src = tmp.path().join("bad.mem");
1080        std::fs::write(&src, b"not a zip").unwrap();
1081
1082        let _g = CacheGuard::install(&cache);
1083        let err = install_to_disk(&src, &project).unwrap_err();
1084        assert!(matches!(err, InstallError::Validation(_)));
1085        // Validation failed up front → neither cache file nor temp
1086        // sibling was written.
1087        assert!(!cache.join("bad.mem").exists());
1088        assert!(!cache.join("bad.mem.tmp").exists());
1089    }
1090}