1use 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
30pub const CACHE_OVERRIDE_ENV: &str = "MEMSTEAD_MEM_CACHE";
32
33pub 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
55pub 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 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 #[error("mem-repo tree write failed: {0}")]
111 MemRepo(#[from] MemRepoWriteError),
112 #[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 }
134
135fn 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#[derive(Debug, Clone)]
183pub struct CacheInstallOutcome {
184 pub mem_name: String,
187 pub schema: memstead_schema::SchemaRef,
189 pub cache_path: PathBuf,
192 pub cache_key: String,
194 pub copied_to_cache: bool,
197 pub schema_files: Vec<(String, Vec<u8>)>,
204 pub warnings: Vec<WarningHint>,
206}
207
208pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
260pub enum MountRegistration {
261 Registered,
263 AlreadyRegistered,
265 Refreshed,
268}
269
270pub 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 _ => 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 fn build_valid_archive(mem_dir: &Path, archive_path: &Path, name: &str) {
344 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 export_mem(&mem_dir, &config, archive_path, None, None).unwrap();
366 }
367
368 fn cache_install(archive: &Path) -> Result<CacheInstallOutcome, InstallError> {
372 install_to_cache(archive, &[])
373 }
374
375 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 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
392
393 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 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 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 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 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 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 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 let second = cache_install(&src).unwrap();
557 assert!(!second.copied_to_cache);
558 assert_eq!(first.cache_key, second.cache_key);
559 }
560
561 #[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 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 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 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 #[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 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 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 #[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 assert!(!cache.join("bad.mem").exists());
716 assert!(!cache.join("bad.mem.tmp").exists());
717 }
718}