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};
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#[derive(Debug, Clone, Copy)]
46pub enum TargetMem<'a> {
47 Disk(&'a Path),
50 MemRepo {
53 workspace_root: &'a Path,
54 mem_name: &'a str,
55 },
56}
57
58pub const CACHE_OVERRIDE_ENV: &str = "MEMSTEAD_MEM_CACHE";
60
61pub 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
83pub 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 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#[derive(Debug, Clone)]
129pub struct InstallOutcome {
130 pub mem_name: String,
132 pub copied_to_cache: bool,
136 pub registered_in_config: bool,
139 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 #[error("mem-repo tree write failed: {0}")]
156 MemRepo(#[from] MemRepoWriteError),
157 #[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 }
184
185fn 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
197pub 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 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 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 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 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 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
331fn 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 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
386fn 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 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#[derive(Debug, Clone, PartialEq, Eq)]
438pub enum SchemaExtractionOutcome {
439 AlreadyRegistered,
444 NoEmbeddedSchema,
449 CacheAlreadyPopulated,
454 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
482pub fn extract_archive_schema_if_needed(
499 archive_path: &Path,
500 workspace_root: &Path,
501 registry: &SchemaRegistry,
502) -> Result<SchemaExtractionOutcome, SchemaExtractionError> {
503 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 return Ok(SchemaExtractionOutcome::CacheAlreadyPopulated);
525 }
526
527 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
561fn 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 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 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 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 fn build_valid_archive(mem_dir: &Path, archive_path: &Path, name: &str) {
634 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 export_mem(&mem_dir, &config, archive_path, None, None).unwrap();
656 }
657
658 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 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 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
689
690 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 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 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 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 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 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 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 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 #[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 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 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 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 #[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 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 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 #[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 assert!(!cache.join("bad.mem").exists());
1088 assert!(!cache.join("bad.mem.tmp").exists());
1089 }
1090}