1use std::io::Read as _;
21use std::path::{Path, PathBuf};
22
23use memstead_base::ops::WarningHint;
24use memstead_schema::{
25 ARCHIVE_CONFIG_PATH, ARCHIVE_EXTENSION, ARCHIVE_SCHEMA_PREFIX, PublishedMemConfig, SchemaRef,
26 SchemaRegistry,
27};
28
29use crate::entity::loader::LoadError;
30use crate::mem_repo_config::MemRepoWriteError;
31use crate::validator::{ValidationError, validate_and_normalize_archive};
32
33pub const CACHE_OVERRIDE_ENV: &str = "MEMSTEAD_MEM_CACHE";
35
36pub fn mem_cache_dir() -> PathBuf {
47 if let Ok(override_path) = std::env::var(CACHE_OVERRIDE_ENV)
48 && !override_path.is_empty()
49 {
50 return PathBuf::from(override_path);
51 }
52 dirs::data_dir()
53 .expect("platform provides a data directory")
54 .join("memstead")
55 .join("mems")
56}
57
58pub fn read_published_config(archive_path: &Path) -> Result<PublishedMemConfig, LoadError> {
66 if !archive_path.is_file() {
67 return Err(LoadError::ArchiveNotFound(
68 archive_path.display().to_string(),
69 ));
70 }
71 let file = std::fs::File::open(archive_path)?;
72 let mut archive = zip::ZipArchive::new(file)?;
73
74 let config_name = ARCHIVE_CONFIG_PATH;
77 if archive.index_for_name(config_name).is_none() {
78 return Err(LoadError::InvalidArchive(format!(
79 "missing {ARCHIVE_CONFIG_PATH} in {}",
80 archive_path.display()
81 )));
82 }
83 let mut entry = archive.by_name(config_name).map_err(|e| {
84 LoadError::InvalidArchive(format!(
85 "reading {config_name} in {}: {e}",
86 archive_path.display()
87 ))
88 })?;
89
90 let mut bytes = Vec::new();
91 entry.read_to_end(&mut bytes)?;
92
93 crate::validator::config::parse_config_bytes(&bytes).map_err(|e| {
94 LoadError::InvalidArchive(format!(
95 "invalid {ARCHIVE_CONFIG_PATH} in {}: {e}",
96 archive_path.display()
97 ))
98 })
99}
100
101#[derive(Debug, thiserror::Error)]
102pub enum InstallError {
103 #[error("could not read mem archive: {0}")]
104 Archive(#[from] LoadError),
105 #[error("io error while installing mem: {0}")]
106 Io(#[from] std::io::Error),
107 #[error("config error while registering mem: {0}")]
108 Config(#[from] memstead_schema::config::ConfigError),
109 #[error("archive failed strict validation: {0}")]
110 Validation(ValidationError),
111 #[error("mem-repo tree write failed: {0}")]
114 MemRepo(#[from] MemRepoWriteError),
115 #[error(
124 "archive's mem name `{archive_name}` already exists as a writable mount in this workspace; \
125 rename the writable mount (`memstead mem rename`) or unregister it first — the archive's \
126 internal name is its sole identity and cannot be changed at install time"
127 )]
128 ShadowsWritable {
129 archive_name: String,
130 shadows_writable: String,
131 },
132 }
137
138fn content_cache_key(canonical_bytes: &[u8]) -> String {
145 use sha2::{Digest, Sha256};
146 let digest = Sha256::digest(canonical_bytes);
147 digest[..8].iter().map(|b| format!("{b:02x}")).collect()
148}
149
150#[derive(Debug, Clone)]
186pub struct CacheInstallOutcome {
187 pub mem_name: String,
190 pub schema: memstead_schema::SchemaRef,
192 pub cache_path: PathBuf,
195 pub cache_key: String,
197 pub copied_to_cache: bool,
200 pub warnings: Vec<WarningHint>,
202}
203
204pub fn install_to_cache(
211 archive_path: &Path,
212 writable_mem_names: &[&str],
213) -> Result<CacheInstallOutcome, InstallError> {
214 let bytes = std::fs::read(archive_path)?;
215 let validated = validate_and_normalize_archive(&bytes).map_err(InstallError::Validation)?;
216
217 if let Some(shadowed) = writable_mem_names
218 .iter()
219 .find(|n| **n == validated.config.name.as_str())
220 {
221 return Err(InstallError::ShadowsWritable {
222 archive_name: validated.config.name.clone(),
223 shadows_writable: (*shadowed).to_string(),
224 });
225 }
226
227 let cache_dir = mem_cache_dir();
228 std::fs::create_dir_all(&cache_dir)?;
229 let cache_key = content_cache_key(&validated.canonical_bytes);
230 let dest = cache_dir.join(format!(
231 "{}-{}.{ARCHIVE_EXTENSION}",
232 validated.config.name, cache_key
233 ));
234 let copied_to_cache = if dest.exists() {
235 false
236 } else {
237 let tmp = dest.with_extension(format!("{ARCHIVE_EXTENSION}.tmp"));
238 std::fs::write(&tmp, &validated.canonical_bytes)?;
239 std::fs::rename(&tmp, &dest)?;
240 true
241 };
242
243 Ok(CacheInstallOutcome {
244 mem_name: validated.config.name,
245 schema: validated.config.schema.clone(),
246 cache_path: dest,
247 cache_key,
248 copied_to_cache,
249 warnings: Vec::new(),
250 })
251}
252
253#[derive(Debug, Clone, Copy, PartialEq, Eq)]
255pub enum MountRegistration {
256 Registered,
258 AlreadyRegistered,
260 Refreshed,
263}
264
265pub fn register_cached_archive(
275 engine: &mut memstead_base::Engine,
276 outcome: &CacheInstallOutcome,
277 by_tool: &'static str,
278) -> Result<MountRegistration, memstead_base::EngineError> {
279 let registration = match engine.mount(&outcome.mem_name) {
280 Some(existing) if existing.capability == memstead_base::MountCapability::ReadOnly => {
281 match &existing.storage {
282 memstead_base::MountStorage::Archive { path } if *path == outcome.cache_path => {
283 return Ok(MountRegistration::AlreadyRegistered);
284 }
285 _ => {
286 engine.unregister_read_mount(&outcome.mem_name)?;
287 MountRegistration::Refreshed
288 }
289 }
290 }
291 _ => MountRegistration::Registered,
294 };
295
296 let mount = memstead_base::Mount {
297 mem: outcome.mem_name.clone(),
298 schema: Some(outcome.schema.clone()),
299 storage: memstead_base::MountStorage::Archive {
300 path: outcome.cache_path.clone(),
301 },
302 capability: memstead_base::MountCapability::ReadOnly,
303 lifecycle: memstead_base::MountLifecycle::Eager,
304 cross_linkable: false,
305 migration_target: None,
306 };
307 let backend: Box<dyn memstead_base::MemBackend> = Box::new(
308 memstead_base::storage::ArchiveBackend::new(outcome.cache_path.clone()),
309 );
310 let origin = memstead_base::MemOrigin::RuntimeCreated {
311 at: std::time::SystemTime::now(),
312 by_tool,
313 };
314 engine.register_read_mount(mount, backend, origin)?;
315 Ok(registration)
316}
317
318#[derive(Debug, Clone, PartialEq, Eq)]
322pub enum SchemaExtractionOutcome {
323 AlreadyRegistered,
328 NoEmbeddedSchema,
333 CacheAlreadyPopulated,
338 Extracted { schema: SchemaRef, path: PathBuf },
342}
343
344#[derive(Debug, thiserror::Error)]
345pub enum SchemaExtractionError {
346 #[error("could not read mem archive {}: {source}", .archive_path.display())]
347 Archive {
348 archive_path: PathBuf,
349 #[source]
350 source: LoadError,
351 },
352 #[error("archive {} failed strict validation: {source}", .archive_path.display())]
353 Validation {
354 archive_path: PathBuf,
355 #[source]
356 source: ValidationError,
357 },
358 #[error("i/o error extracting schema to {}: {source}", .path.display())]
359 Io {
360 path: PathBuf,
361 #[source]
362 source: std::io::Error,
363 },
364}
365
366pub fn extract_archive_schema_if_needed(
383 archive_path: &Path,
384 workspace_root: &Path,
385 registry: &SchemaRegistry,
386) -> Result<SchemaExtractionOutcome, SchemaExtractionError> {
387 let config =
391 read_published_config(archive_path).map_err(|source| SchemaExtractionError::Archive {
392 archive_path: archive_path.to_path_buf(),
393 source,
394 })?;
395 if registry
396 .get(&config.schema.name, &config.schema.version)
397 .is_some()
398 {
399 return Ok(SchemaExtractionOutcome::AlreadyRegistered);
400 }
401
402 let dest = workspace_root
403 .join(".memstead.cache/schemas")
404 .join(format!("{}-{}", config.schema.name, config.schema.version));
405 if dest.is_dir() {
406 return Ok(SchemaExtractionOutcome::CacheAlreadyPopulated);
409 }
410
411 let bytes = std::fs::read(archive_path).map_err(|source| SchemaExtractionError::Io {
418 path: archive_path.to_path_buf(),
419 source,
420 })?;
421 let validated = validate_and_normalize_archive(&bytes).map_err(|source| {
422 SchemaExtractionError::Validation {
423 archive_path: archive_path.to_path_buf(),
424 source,
425 }
426 })?;
427
428 if validated.schema_files.is_empty() {
429 return Ok(SchemaExtractionOutcome::NoEmbeddedSchema);
430 }
431
432 extract_schema_files_atomic(&validated.schema_files, &dest).map_err(|source| {
433 SchemaExtractionError::Io {
434 path: dest.clone(),
435 source,
436 }
437 })?;
438
439 Ok(SchemaExtractionOutcome::Extracted {
440 schema: config.schema,
441 path: dest,
442 })
443}
444
445fn extract_schema_files_atomic(
454 schema_files: &[crate::validator::archive::SchemaFile],
455 dest: &Path,
456) -> std::io::Result<()> {
457 let parent = dest
458 .parent()
459 .ok_or_else(|| std::io::Error::other("schema cache destination has no parent directory"))?;
460 std::fs::create_dir_all(parent)?;
461
462 let ts = std::time::SystemTime::now()
469 .duration_since(std::time::UNIX_EPOCH)
470 .map(|d| d.as_nanos())
471 .unwrap_or(0);
472 let tmp = parent.join(format!(
473 ".memstead-schema-extract-{}-{}",
474 std::process::id(),
475 ts,
476 ));
477
478 let _ = std::fs::remove_dir_all(&tmp);
481 std::fs::create_dir_all(&tmp)?;
482
483 for sf in schema_files {
484 let rel = sf
485 .archive_path
486 .strip_prefix(ARCHIVE_SCHEMA_PREFIX)
487 .unwrap_or(sf.archive_path.as_str());
488 let file_path = tmp.join(rel);
489 if let Some(file_parent) = file_path.parent() {
490 std::fs::create_dir_all(file_parent)?;
491 }
492 std::fs::write(&file_path, sf.content.as_bytes())?;
493 }
494
495 match std::fs::rename(&tmp, dest) {
496 Ok(()) => Ok(()),
497 Err(e) => {
498 let _ = std::fs::remove_dir_all(&tmp);
502 Err(e)
503 }
504 }
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510 use crate::ops::export::export_mem;
511 use tempfile::TempDir;
512
513 fn build_valid_archive(mem_dir: &Path, archive_path: &Path, name: &str) {
518 let mem_dir = mem_dir.parent().unwrap_or(mem_dir).join(name);
525 std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
526 std::fs::write(
527 mem_dir.join(".memstead/config.json"),
528 r#"{"version":"1.2.0","schema":"default@1.0.0"}"#,
529 )
530 .unwrap();
531 std::fs::write(
532 mem_dir.join("alpha.md"),
533 "---\ntype: spec\ncreated_date: 2026-01-15\nlast_modified: 2026-01-15\nlevel: M0\n---\n# Alpha\n\n## Identity\n\nA.\n\n## Purpose\n\nB.\n\n## Specifies\n\nC.\n\n## Constraints\n\nD.\n\n## Rationale\n\nE.\n",
534 ).unwrap();
535
536 let config = memstead_schema::load_and_validate(&mem_dir).unwrap();
537 export_mem(&mem_dir, &config, archive_path, None, None).unwrap();
540 }
541
542 fn cache_install(archive: &Path) -> Result<CacheInstallOutcome, InstallError> {
546 install_to_cache(archive, &[])
547 }
548
549 fn write_minimal_mem_config(dir: &Path, _name: &str) {
552 std::fs::create_dir_all(dir.join(".memstead")).unwrap();
553 std::fs::write(
554 dir.join(".memstead/config.json"),
555 r#"{"version":"1.0.0","schema":"default@1.0.0"}"#,
556 )
557 .unwrap();
558 }
559
560 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
566
567 struct CacheGuard {
570 _lock: std::sync::MutexGuard<'static, ()>,
571 prev: Option<String>,
572 }
573 impl CacheGuard {
574 fn install(cache_dir: &Path) -> Self {
575 let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
576 let prev = std::env::var(CACHE_OVERRIDE_ENV).ok();
577 unsafe {
580 std::env::set_var(CACHE_OVERRIDE_ENV, cache_dir);
581 }
582 Self { _lock: lock, prev }
583 }
584 }
585 impl Drop for CacheGuard {
586 fn drop(&mut self) {
587 unsafe {
589 match self.prev.take() {
590 Some(v) => std::env::set_var(CACHE_OVERRIDE_ENV, v),
591 None => std::env::remove_var(CACHE_OVERRIDE_ENV),
592 }
593 }
594 }
595 }
596
597 #[test]
598 fn mem_cache_dir_honors_env_override() {
599 let custom = std::env::temp_dir().join("memstead-cache-override-test");
600 let _g = CacheGuard::install(&custom);
601 assert_eq!(mem_cache_dir(), custom);
602 }
603
604 #[test]
605 fn read_published_config_reads_whitelist_fields() {
606 let tmp = TempDir::new().unwrap();
607 let mem_src = tmp.path().join("sample");
611 let archive = tmp.path().join("sample.mem");
612 build_valid_archive(&mem_src, &archive, "sample");
613
614 let config = read_published_config(&archive).unwrap();
615 assert_eq!(config.format, memstead_schema::PUBLISHED_MEM_FORMAT);
616 assert_eq!(config.name, "sample");
617 assert_eq!(config.version.to_string(), "1.2.0");
618 }
619
620 #[test]
621 fn read_published_config_missing_file_is_archive_not_found() {
622 let err = read_published_config(&PathBuf::from("/nonexistent/nope.mem")).unwrap_err();
623 assert!(matches!(err, LoadError::ArchiveNotFound(_)));
624 }
625
626 #[test]
627 fn read_published_config_corrupt_archive_is_zip_error() {
628 let tmp = TempDir::new().unwrap();
629 let archive = tmp.path().join("corrupt.mem");
630 std::fs::write(&archive, b"definitely not a zip").unwrap();
631 let err = read_published_config(&archive).unwrap_err();
632 assert!(matches!(err, LoadError::Zip(_)));
633 }
634
635 #[test]
636 fn install_validates_and_canonicalizes() {
637 let tmp = TempDir::new().unwrap();
638 let cache = tmp.path().join("cache");
639 let project = tmp.path().join("project");
640 let src_dir = tmp.path().join("src");
641 let src = tmp.path().join("aws-patterns.mem");
642
643 std::fs::create_dir_all(&project).unwrap();
644 write_minimal_mem_config(&project, "specs");
645 build_valid_archive(&src_dir, &src, "aws-patterns");
646
647 let _g = CacheGuard::install(&cache);
648 let outcome = cache_install(&src).unwrap();
649
650 assert_eq!(outcome.mem_name, "aws-patterns");
651 assert!(outcome.copied_to_cache);
652 assert!(
653 outcome.warnings.is_empty(),
654 "current-format install must not warn: {:?}",
655 outcome.warnings
656 );
657
658 let key = outcome.cache_key.as_str();
661 let cached = cache.join(format!("aws-patterns-{key}.mem"));
662 assert_eq!(outcome.cache_path, cached);
663 assert!(cached.is_file(), "content-addressed cache file must exist");
664
665 let cached_bytes = std::fs::read(&cached).unwrap();
668 let revalidated = validate_and_normalize_archive(&cached_bytes).unwrap();
669 assert_eq!(revalidated.canonical_bytes, cached_bytes);
670 assert_eq!(
671 key,
672 content_cache_key(&cached_bytes),
673 "cacheKey is the content digest"
674 );
675 }
676
677 #[test]
678 fn install_leaves_no_tmp_on_success() {
679 let tmp = TempDir::new().unwrap();
680 let cache = tmp.path().join("cache");
681 let project = tmp.path().join("project");
682 let src_dir = tmp.path().join("src");
683 let src = tmp.path().join("x.mem");
684 std::fs::create_dir_all(&project).unwrap();
685 write_minimal_mem_config(&project, "specs");
686 build_valid_archive(&src_dir, &src, "alpha");
687
688 let _g = CacheGuard::install(&cache);
689 cache_install(&src).unwrap();
690
691 let entries: Vec<_> = std::fs::read_dir(&cache)
696 .unwrap()
697 .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
698 .collect();
699 assert_eq!(
700 entries.iter().filter(|n| n.ends_with(".mem")).count(),
701 1,
702 "exactly one cache file, no .tmp sibling: {entries:?}",
703 );
704 let cache_file = entries.iter().find(|n| n.ends_with(".mem")).unwrap();
705 assert!(
706 cache_file.starts_with("alpha-"),
707 "name-keyed prefix: {cache_file}"
708 );
709 assert!(!entries.iter().any(|n| n.ends_with(".tmp")));
710 }
711
712 #[test]
713 fn install_is_idempotent() {
714 let tmp = TempDir::new().unwrap();
715 let cache = tmp.path().join("cache");
716 let project = tmp.path().join("project");
717 let src_dir = tmp.path().join("src");
718 let src = tmp.path().join("x.mem");
719 std::fs::create_dir_all(&project).unwrap();
720 write_minimal_mem_config(&project, "specs");
721 build_valid_archive(&src_dir, &src, "alpha");
722
723 let _g = CacheGuard::install(&cache);
724 let first = cache_install(&src).unwrap();
725 assert!(first.copied_to_cache);
726
727 let second = cache_install(&src).unwrap();
731 assert!(!second.copied_to_cache);
732 assert_eq!(first.cache_key, second.cache_key);
733 }
734
735 #[test]
742 fn install_distinct_archives_same_name_coexist_via_content_address() {
743 let tmp = TempDir::new().unwrap();
744 let cache = tmp.path().join("cache");
745 let project = tmp.path().join("project");
746 let src_a_dir = tmp.path().join("src-a");
747 let src_a = tmp.path().join("a.mem");
748 std::fs::create_dir_all(&project).unwrap();
749 write_minimal_mem_config(&project, "specs");
750 build_valid_archive(&src_a_dir, &src_a, "alpha");
751
752 let _g = CacheGuard::install(&cache);
753 let first = cache_install(&src_a).unwrap();
754 assert!(first.copied_to_cache);
755 let key_a = first.cache_key.clone();
756
757 let src_b_dir = tmp.path().join("src-b");
760 std::fs::create_dir_all(src_b_dir.join("alpha/.memstead")).unwrap();
761 std::fs::write(
762 src_b_dir.join("alpha/.memstead/config.json"),
763 r#"{"version":"1.2.0","schema":"default@1.0.0"}"#,
764 )
765 .unwrap();
766 std::fs::write(
767 src_b_dir.join("alpha/beta.md"),
768 "---\ntype: spec\ncreated_date: 2026-01-15\nlast_modified: 2026-01-15\nlevel: M0\n---\n# Beta\n\n## Identity\n\nA different content.\n\n## Purpose\n\nB different content.\n\n## Specifies\n\nC different content.\n\n## Constraints\n\nD different content.\n\n## Rationale\n\nE different content.\n",
769 ).unwrap();
770 let src_b = tmp.path().join("b.mem");
771 let cfg_b = memstead_schema::load_and_validate(&src_b_dir.join("alpha")).unwrap();
772 crate::ops::export::export_mem(&src_b_dir.join("alpha"), &cfg_b, &src_b, None, None)
773 .unwrap();
774 assert_ne!(
775 std::fs::read(&src_a).unwrap(),
776 std::fs::read(&src_b).unwrap(),
777 "fixture must produce two distinct archives sharing the name `alpha`"
778 );
779
780 let second = cache_install(&src_b).unwrap();
783 assert!(
784 second.copied_to_cache,
785 "distinct bytes must install, not collide"
786 );
787 let key_b = second.cache_key.clone();
788
789 assert_ne!(
791 key_a, key_b,
792 "distinct archives must get distinct content keys"
793 );
794 assert!(cache.join(format!("alpha-{key_a}.mem")).is_file());
795 assert!(cache.join(format!("alpha-{key_b}.mem")).is_file());
796 }
797
798 #[test]
804 fn install_idempotent_path_returns_false_without_refusal() {
805 let tmp = TempDir::new().unwrap();
806 let cache = tmp.path().join("cache");
807 let project = tmp.path().join("project");
808 let src_dir = tmp.path().join("src");
809 let src = tmp.path().join("x.mem");
810 std::fs::create_dir_all(&project).unwrap();
811 write_minimal_mem_config(&project, "specs");
812 build_valid_archive(&src_dir, &src, "alpha");
813
814 let _g = CacheGuard::install(&cache);
815 let first = cache_install(&src).unwrap();
816 assert!(first.copied_to_cache);
817
818 let second = cache_install(&src).unwrap();
821 assert!(
822 !second.copied_to_cache,
823 "idempotent re-install must report copied_to_cache: false"
824 );
825 }
826
827 fn repack_with_foreign_meta_dir(src: &Path, dest: &Path) {
830 use std::io::{Read as _, Write as _};
831 let file = std::fs::File::open(src).unwrap();
832 let mut archive = zip::ZipArchive::new(file).unwrap();
833 let out = std::fs::File::create(dest).unwrap();
834 let mut writer = zip::ZipWriter::new(out);
835 let opts = zip::write::SimpleFileOptions::default();
836 for i in 0..archive.len() {
837 let mut entry = archive.by_index(i).unwrap();
838 let name = entry.name().to_string();
839 let name = match name.strip_prefix(".memstead/") {
840 Some(rest) => format!(".other/{rest}"),
841 None => name,
842 };
843 let mut bytes = Vec::new();
844 entry.read_to_end(&mut bytes).unwrap();
845 writer.start_file(name, opts).unwrap();
846 writer.write_all(&bytes).unwrap();
847 }
848 writer.finish().unwrap();
849 }
850
851 #[test]
855 fn install_foreign_meta_layout_is_rejected() {
856 let tmp = TempDir::new().unwrap();
857 let cache = tmp.path().join("cache");
858 let project = tmp.path().join("project");
859 let src_dir = tmp.path().join("src");
860 let modern = tmp.path().join("modern.mem");
861 std::fs::create_dir_all(&project).unwrap();
862 write_minimal_mem_config(&project, "specs");
863 build_valid_archive(&src_dir, &modern, "foreign-mem");
864
865 let foreign = tmp.path().join("foreign-mem.mem");
866 repack_with_foreign_meta_dir(&modern, &foreign);
867
868 let _g = CacheGuard::install(&cache);
869 let err =
870 cache_install(&foreign).expect_err("a foreign meta-layout archive must not install");
871 assert!(matches!(err, InstallError::Validation(_)), "got {err:?}");
872 }
873
874 #[test]
875 fn install_rejects_non_archive_bytes() {
876 let tmp = TempDir::new().unwrap();
877 let cache = tmp.path().join("cache");
878 let project = tmp.path().join("project");
879 std::fs::create_dir_all(&project).unwrap();
880 write_minimal_mem_config(&project, "specs");
881 let src = tmp.path().join("bad.mem");
882 std::fs::write(&src, b"not a zip").unwrap();
883
884 let _g = CacheGuard::install(&cache);
885 let err = cache_install(&src).unwrap_err();
886 assert!(matches!(err, InstallError::Validation(_)));
887 assert!(!cache.join("bad.mem").exists());
890 assert!(!cache.join("bad.mem.tmp").exists());
891 }
892}