use std::io::Read as _;
use std::path::{Path, PathBuf};
use memstead_base::ops::WarningHint;
use memstead_schema::{
ARCHIVE_CONFIG_PATH, ARCHIVE_EXTENSION, ARCHIVE_SCHEMA_PREFIX, PublishedMemConfig, SchemaRef,
SchemaRegistry,
};
use serde_json::{Map, Value, json};
use crate::entity::loader::LoadError;
use crate::mem_repo_config::{self, MemRepoWriteError};
use crate::validator::{ValidationError, validate_and_normalize_archive};
use crate::vcs::CommitContext;
#[derive(Debug, Clone, Copy)]
pub enum TargetMem<'a> {
Disk(&'a Path),
MemRepo {
workspace_root: &'a Path,
mem_name: &'a str,
},
}
pub const CACHE_OVERRIDE_ENV: &str = "MEMSTEAD_MEM_CACHE";
pub fn mem_cache_dir() -> PathBuf {
if let Ok(override_path) = std::env::var(CACHE_OVERRIDE_ENV)
&& !override_path.is_empty()
{
return PathBuf::from(override_path);
}
dirs::data_dir()
.expect("platform provides a data directory")
.join("memstead")
.join("mems")
}
pub fn read_published_config(archive_path: &Path) -> Result<PublishedMemConfig, LoadError> {
if !archive_path.is_file() {
return Err(LoadError::ArchiveNotFound(
archive_path.display().to_string(),
));
}
let file = std::fs::File::open(archive_path)?;
let mut archive = zip::ZipArchive::new(file)?;
let config_name = ARCHIVE_CONFIG_PATH;
if archive.index_for_name(config_name).is_none() {
return Err(LoadError::InvalidArchive(format!(
"missing {ARCHIVE_CONFIG_PATH} in {}",
archive_path.display()
)));
}
let mut entry = archive.by_name(config_name).map_err(|e| {
LoadError::InvalidArchive(format!(
"reading {config_name} in {}: {e}",
archive_path.display()
))
})?;
let mut bytes = Vec::new();
entry.read_to_end(&mut bytes)?;
crate::validator::config::parse_config_bytes(&bytes).map_err(|e| {
LoadError::InvalidArchive(format!(
"invalid {ARCHIVE_CONFIG_PATH} in {}: {e}",
archive_path.display()
))
})
}
#[derive(Debug, Clone)]
pub struct InstallOutcome {
pub mem_name: String,
pub copied_to_cache: bool,
pub registered_in_config: bool,
pub warnings: Vec<WarningHint>,
}
#[derive(Debug, thiserror::Error)]
pub enum InstallError {
#[error("could not read mem archive: {0}")]
Archive(#[from] LoadError),
#[error("io error while installing mem: {0}")]
Io(#[from] std::io::Error),
#[error("config error while registering mem: {0}")]
Config(#[from] memstead_schema::config::ConfigError),
#[error("archive failed strict validation: {0}")]
Validation(ValidationError),
#[error("mem-repo tree write failed: {0}")]
MemRepo(#[from] MemRepoWriteError),
#[error(
"archive's mem name `{archive_name}` already exists as a writable mount in this workspace; \
unregister or rename the writable mount first (the `--mem` flag selects which writable \
host mem to register *into* — it does not rename the archive's internal mem)"
)]
ShadowsWritable {
archive_name: String,
shadows_writable: String,
},
}
fn content_cache_key(canonical_bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
let digest = Sha256::digest(canonical_bytes);
digest[..8].iter().map(|b| format!("{b:02x}")).collect()
}
pub fn install_read_mem(
archive_path: &Path,
target: TargetMem<'_>,
ctx: &CommitContext<'_>,
commit_message: &str,
writable_mem_names: &[&str],
) -> Result<InstallOutcome, InstallError> {
let bytes = std::fs::read(archive_path)?;
let validated = validate_and_normalize_archive(&bytes).map_err(InstallError::Validation)?;
let warnings: Vec<WarningHint> = Vec::new();
if let Some(shadowed) = writable_mem_names
.iter()
.find(|n| **n == validated.config.name.as_str())
{
return Err(InstallError::ShadowsWritable {
archive_name: validated.config.name.clone(),
shadows_writable: (*shadowed).to_string(),
});
}
let cache_dir = mem_cache_dir();
std::fs::create_dir_all(&cache_dir)?;
let cache_key = content_cache_key(&validated.canonical_bytes);
let dest = cache_dir.join(format!(
"{}-{}.{ARCHIVE_EXTENSION}",
validated.config.name, cache_key
));
let copied_to_cache = if dest.exists() {
false
} else {
let tmp = dest.with_extension(format!("{ARCHIVE_EXTENSION}.tmp"));
std::fs::write(&tmp, &validated.canonical_bytes)?;
std::fs::rename(&tmp, &dest)?;
true
};
let registered_in_config = match target {
TargetMem::Disk(mem_dir) => {
let (mut config, config_path) = memstead_schema::config::load_config(mem_dir)?;
register_read_mem_in_config(
&config_path,
&mut config,
&validated.config.name,
&cache_key,
)?
}
TargetMem::MemRepo {
workspace_root,
mem_name,
} => register_read_mem_in_mem_repo(
workspace_root,
mem_name,
&validated.config.name,
&cache_key,
ctx,
commit_message,
)?,
};
Ok(InstallOutcome {
mem_name: validated.config.name,
copied_to_cache,
registered_in_config,
warnings,
})
}
fn register_read_mem_in_mem_repo(
workspace_root: &Path,
mem_name: &str,
read_mem_name: &str,
cache_key: &str,
ctx: &CommitContext<'_>,
commit_message: &str,
) -> Result<bool, InstallError> {
use memstead_schema::config::ConfigError;
let config = mem_repo_config::read_config(workspace_root, mem_name)
.map_err(|e| ConfigError::Other(format!("read configs/{mem_name}.json: {e}")))?;
let mut value = serde_json::to_value(&config)
.map_err(|e| ConfigError::Other(format!("re-serialize MemConfig: {e}")))?;
let obj = value
.as_object_mut()
.ok_or_else(|| ConfigError::Other("config root must be a JSON object".into()))?;
let entry = obj
.entry("readMems")
.or_insert_with(|| Value::Object(Map::new()));
let map = entry
.as_object_mut()
.ok_or_else(|| ConfigError::Other("readMems must be a JSON object".into()))?;
if map.contains_key(read_mem_name) {
return Ok(false);
}
map.insert(
read_mem_name.to_string(),
json!({ "source": { "type": "local" }, "cacheKey": cache_key }),
);
let updated_bytes = serde_json::to_vec_pretty(&value)
.map_err(|e| ConfigError::Other(format!("serialize updated config: {e}")))?;
mem_repo_config::commit_config(
workspace_root,
mem_name,
&updated_bytes,
ctx,
commit_message,
)?;
Ok(true)
}
fn register_read_mem_in_config(
config_path: &Path,
config: &mut Value,
mem_name: &str,
cache_key: &str,
) -> Result<bool, memstead_schema::config::ConfigError> {
let obj = config.as_object_mut().ok_or_else(|| {
memstead_schema::config::ConfigError::Other("config root must be a JSON object".into())
})?;
let entry = obj
.entry("readMems")
.or_insert_with(|| Value::Object(Map::new()));
let map = entry.as_object_mut().ok_or_else(|| {
memstead_schema::config::ConfigError::Other("readMems must be a JSON object".into())
})?;
if map.contains_key(mem_name) {
return Ok(false);
}
map.insert(
mem_name.to_string(),
json!({ "source": { "type": "local" }, "cacheKey": cache_key }),
);
let new_read_mems = Value::Object(map.clone());
memstead_schema::config::update_config_field(
config_path,
config,
"readMems",
new_read_mems,
false,
)?;
Ok(true)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SchemaExtractionOutcome {
AlreadyRegistered,
NoEmbeddedSchema,
CacheAlreadyPopulated,
Extracted { schema: SchemaRef, path: PathBuf },
}
#[derive(Debug, thiserror::Error)]
pub enum SchemaExtractionError {
#[error("could not read mem archive {}: {source}", .archive_path.display())]
Archive {
archive_path: PathBuf,
#[source]
source: LoadError,
},
#[error("archive {} failed strict validation: {source}", .archive_path.display())]
Validation {
archive_path: PathBuf,
#[source]
source: ValidationError,
},
#[error("i/o error extracting schema to {}: {source}", .path.display())]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
}
pub fn extract_archive_schema_if_needed(
archive_path: &Path,
workspace_root: &Path,
registry: &SchemaRegistry,
) -> Result<SchemaExtractionOutcome, SchemaExtractionError> {
let config =
read_published_config(archive_path).map_err(|source| SchemaExtractionError::Archive {
archive_path: archive_path.to_path_buf(),
source,
})?;
if registry
.get(&config.schema.name, &config.schema.version)
.is_some()
{
return Ok(SchemaExtractionOutcome::AlreadyRegistered);
}
let dest = workspace_root
.join(".memstead.cache/schemas")
.join(format!("{}-{}", config.schema.name, config.schema.version));
if dest.is_dir() {
return Ok(SchemaExtractionOutcome::CacheAlreadyPopulated);
}
let bytes = std::fs::read(archive_path).map_err(|source| SchemaExtractionError::Io {
path: archive_path.to_path_buf(),
source,
})?;
let validated = validate_and_normalize_archive(&bytes).map_err(|source| {
SchemaExtractionError::Validation {
archive_path: archive_path.to_path_buf(),
source,
}
})?;
if validated.schema_files.is_empty() {
return Ok(SchemaExtractionOutcome::NoEmbeddedSchema);
}
extract_schema_files_atomic(&validated.schema_files, &dest).map_err(|source| {
SchemaExtractionError::Io {
path: dest.clone(),
source,
}
})?;
Ok(SchemaExtractionOutcome::Extracted {
schema: config.schema,
path: dest,
})
}
fn extract_schema_files_atomic(
schema_files: &[crate::validator::archive::SchemaFile],
dest: &Path,
) -> std::io::Result<()> {
let parent = dest
.parent()
.ok_or_else(|| std::io::Error::other("schema cache destination has no parent directory"))?;
std::fs::create_dir_all(parent)?;
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let tmp = parent.join(format!(
".memstead-schema-extract-{}-{}",
std::process::id(),
ts,
));
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp)?;
for sf in schema_files {
let rel = sf
.archive_path
.strip_prefix(ARCHIVE_SCHEMA_PREFIX)
.unwrap_or(sf.archive_path.as_str());
let file_path = tmp.join(rel);
if let Some(file_parent) = file_path.parent() {
std::fs::create_dir_all(file_parent)?;
}
std::fs::write(&file_path, sf.content.as_bytes())?;
}
match std::fs::rename(&tmp, dest) {
Ok(()) => Ok(()),
Err(e) => {
let _ = std::fs::remove_dir_all(&tmp);
Err(e)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ops::export::export_mem;
use tempfile::TempDir;
fn build_valid_archive(mem_dir: &Path, archive_path: &Path, name: &str) {
let mem_dir = mem_dir.parent().unwrap_or(mem_dir).join(name);
std::fs::create_dir_all(mem_dir.join(".memstead")).unwrap();
std::fs::write(
mem_dir.join(".memstead/config.json"),
r#"{"version":"1.2.0","schema":"default@1.0.0"}"#,
)
.unwrap();
std::fs::write(
mem_dir.join("alpha.md"),
"---\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",
).unwrap();
let config = memstead_schema::load_and_validate(&mem_dir).unwrap();
export_mem(&mem_dir, &config, archive_path, None, None).unwrap();
}
fn install_to_disk(archive: &Path, project: &Path) -> Result<InstallOutcome, InstallError> {
install_read_mem(
archive,
TargetMem::Disk(project),
&CommitContext::internal(),
"memstead: install (test)",
&[],
)
}
fn write_minimal_mem_config(dir: &Path, _name: &str) {
std::fs::create_dir_all(dir.join(".memstead")).unwrap();
std::fs::write(
dir.join(".memstead/config.json"),
r#"{"version":"1.0.0","schema":"default@1.0.0"}"#,
)
.unwrap();
}
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
struct CacheGuard {
_lock: std::sync::MutexGuard<'static, ()>,
prev: Option<String>,
}
impl CacheGuard {
fn install(cache_dir: &Path) -> Self {
let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let prev = std::env::var(CACHE_OVERRIDE_ENV).ok();
unsafe {
std::env::set_var(CACHE_OVERRIDE_ENV, cache_dir);
}
Self { _lock: lock, prev }
}
}
impl Drop for CacheGuard {
fn drop(&mut self) {
unsafe {
match self.prev.take() {
Some(v) => std::env::set_var(CACHE_OVERRIDE_ENV, v),
None => std::env::remove_var(CACHE_OVERRIDE_ENV),
}
}
}
}
#[test]
fn mem_cache_dir_honors_env_override() {
let custom = std::env::temp_dir().join("memstead-cache-override-test");
let _g = CacheGuard::install(&custom);
assert_eq!(mem_cache_dir(), custom);
}
#[test]
fn read_published_config_reads_whitelist_fields() {
let tmp = TempDir::new().unwrap();
let mem_src = tmp.path().join("sample");
let archive = tmp.path().join("sample.mem");
build_valid_archive(&mem_src, &archive, "sample");
let config = read_published_config(&archive).unwrap();
assert_eq!(config.format, memstead_schema::PUBLISHED_MEM_FORMAT);
assert_eq!(config.name, "sample");
assert_eq!(config.version.to_string(), "1.2.0");
}
#[test]
fn read_published_config_missing_file_is_archive_not_found() {
let err = read_published_config(&PathBuf::from("/nonexistent/nope.mem")).unwrap_err();
assert!(matches!(err, LoadError::ArchiveNotFound(_)));
}
#[test]
fn read_published_config_corrupt_archive_is_zip_error() {
let tmp = TempDir::new().unwrap();
let archive = tmp.path().join("corrupt.mem");
std::fs::write(&archive, b"definitely not a zip").unwrap();
let err = read_published_config(&archive).unwrap_err();
assert!(matches!(err, LoadError::Zip(_)));
}
#[test]
fn install_validates_and_canonicalizes() {
let tmp = TempDir::new().unwrap();
let cache = tmp.path().join("cache");
let project = tmp.path().join("project");
let src_dir = tmp.path().join("src");
let src = tmp.path().join("aws-patterns.mem");
std::fs::create_dir_all(&project).unwrap();
write_minimal_mem_config(&project, "specs");
build_valid_archive(&src_dir, &src, "aws-patterns");
let _g = CacheGuard::install(&cache);
let outcome = install_to_disk(&src, &project).unwrap();
assert_eq!(outcome.mem_name, "aws-patterns");
assert!(outcome.copied_to_cache);
assert!(outcome.registered_in_config);
assert!(
outcome.warnings.is_empty(),
"current-format install must not warn: {:?}",
outcome.warnings
);
let cfg_raw = std::fs::read_to_string(project.join(".memstead/config.json")).unwrap();
let cfg: serde_json::Value = serde_json::from_str(&cfg_raw).unwrap();
let rv = cfg["readMems"]["aws-patterns"]["source"]["type"].as_str();
assert_eq!(rv, Some("local"));
let key = cfg["readMems"]["aws-patterns"]["cacheKey"]
.as_str()
.expect("registration must record the content cacheKey");
let cached = cache.join(format!("aws-patterns-{key}.mem"));
assert!(cached.is_file(), "content-addressed cache file must exist");
let cached_bytes = std::fs::read(&cached).unwrap();
let revalidated = validate_and_normalize_archive(&cached_bytes).unwrap();
assert_eq!(revalidated.canonical_bytes, cached_bytes);
assert_eq!(
key,
content_cache_key(&cached_bytes),
"cacheKey is the content digest"
);
}
#[test]
fn install_leaves_no_tmp_on_success() {
let tmp = TempDir::new().unwrap();
let cache = tmp.path().join("cache");
let project = tmp.path().join("project");
let src_dir = tmp.path().join("src");
let src = tmp.path().join("x.mem");
std::fs::create_dir_all(&project).unwrap();
write_minimal_mem_config(&project, "specs");
build_valid_archive(&src_dir, &src, "alpha");
let _g = CacheGuard::install(&cache);
install_to_disk(&src, &project).unwrap();
let entries: Vec<_> = std::fs::read_dir(&cache)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
assert_eq!(
entries.iter().filter(|n| n.ends_with(".mem")).count(),
1,
"exactly one cache file, no .tmp sibling: {entries:?}",
);
let cache_file = entries.iter().find(|n| n.ends_with(".mem")).unwrap();
assert!(
cache_file.starts_with("alpha-"),
"name-keyed prefix: {cache_file}"
);
assert!(!entries.iter().any(|n| n.ends_with(".tmp")));
}
#[test]
fn install_is_idempotent() {
let tmp = TempDir::new().unwrap();
let cache = tmp.path().join("cache");
let project = tmp.path().join("project");
let src_dir = tmp.path().join("src");
let src = tmp.path().join("x.mem");
std::fs::create_dir_all(&project).unwrap();
write_minimal_mem_config(&project, "specs");
build_valid_archive(&src_dir, &src, "alpha");
let _g = CacheGuard::install(&cache);
let first = install_to_disk(&src, &project).unwrap();
assert!(first.copied_to_cache);
assert!(first.registered_in_config);
let second = install_to_disk(&src, &project).unwrap();
assert!(!second.copied_to_cache);
assert!(!second.registered_in_config);
}
#[test]
fn install_preserves_existing_non_local_source() {
let tmp = TempDir::new().unwrap();
let cache = tmp.path().join("cache");
let project = tmp.path().join("project");
let src_dir = tmp.path().join("src");
let src = tmp.path().join("x.mem");
std::fs::create_dir_all(project.join(".memstead")).unwrap();
std::fs::write(
project.join(".memstead/config.json"),
r#"{
"version":"1.0.0",
"schema":"default@1.0.0",
"readMems": {
"alpha": {"source":{"type":"url","url":"https://example.com/x.mem"}}
}
}"#,
)
.unwrap();
build_valid_archive(&src_dir, &src, "alpha");
let _g = CacheGuard::install(&cache);
let outcome = install_to_disk(&src, &project).unwrap();
assert!(outcome.copied_to_cache);
assert!(
!outcome.registered_in_config,
"existing entry must not be overwritten"
);
let cfg_raw = std::fs::read_to_string(project.join(".memstead/config.json")).unwrap();
let cfg: serde_json::Value = serde_json::from_str(&cfg_raw).unwrap();
assert_eq!(
cfg["readMems"]["alpha"]["source"]["type"].as_str(),
Some("url")
);
}
#[test]
fn install_distinct_archives_same_name_coexist_via_content_address() {
let tmp = TempDir::new().unwrap();
let cache = tmp.path().join("cache");
let project = tmp.path().join("project");
let src_a_dir = tmp.path().join("src-a");
let src_a = tmp.path().join("a.mem");
std::fs::create_dir_all(&project).unwrap();
write_minimal_mem_config(&project, "specs");
build_valid_archive(&src_a_dir, &src_a, "alpha");
let _g = CacheGuard::install(&cache);
let first = install_to_disk(&src_a, &project).unwrap();
assert!(first.copied_to_cache);
let key_a = std::fs::read_to_string(project.join(".memstead/config.json"))
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.and_then(|c| {
c["readMems"]["alpha"]["cacheKey"]
.as_str()
.map(String::from)
})
.expect("first install records a cacheKey");
let src_b_dir = tmp.path().join("src-b");
std::fs::create_dir_all(src_b_dir.join("alpha/.memstead")).unwrap();
std::fs::write(
src_b_dir.join("alpha/.memstead/config.json"),
r#"{"version":"1.2.0","schema":"default@1.0.0"}"#,
)
.unwrap();
std::fs::write(
src_b_dir.join("alpha/beta.md"),
"---\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",
).unwrap();
let src_b = tmp.path().join("b.mem");
let cfg_b = memstead_schema::load_and_validate(&src_b_dir.join("alpha")).unwrap();
crate::ops::export::export_mem(&src_b_dir.join("alpha"), &cfg_b, &src_b, None, None)
.unwrap();
assert_ne!(
std::fs::read(&src_a).unwrap(),
std::fs::read(&src_b).unwrap(),
"fixture must produce two distinct archives sharing the name `alpha`"
);
let project_b = tmp.path().join("project-b");
std::fs::create_dir_all(&project_b).unwrap();
write_minimal_mem_config(&project_b, "specs");
let second = install_read_mem(
&src_b,
TargetMem::Disk(&project_b),
&CommitContext::internal(),
"memstead: install (test)",
&[],
)
.unwrap();
assert!(
second.copied_to_cache,
"distinct bytes must install, not collide"
);
let key_b = std::fs::read_to_string(project_b.join(".memstead/config.json"))
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.and_then(|c| {
c["readMems"]["alpha"]["cacheKey"]
.as_str()
.map(String::from)
})
.expect("second install records a cacheKey");
assert_ne!(
key_a, key_b,
"distinct archives must get distinct content keys"
);
assert!(cache.join(format!("alpha-{key_a}.mem")).is_file());
assert!(cache.join(format!("alpha-{key_b}.mem")).is_file());
}
#[test]
fn install_idempotent_path_returns_false_without_refusal() {
let tmp = TempDir::new().unwrap();
let cache = tmp.path().join("cache");
let project = tmp.path().join("project");
let src_dir = tmp.path().join("src");
let src = tmp.path().join("x.mem");
std::fs::create_dir_all(&project).unwrap();
write_minimal_mem_config(&project, "specs");
build_valid_archive(&src_dir, &src, "alpha");
let _g = CacheGuard::install(&cache);
let first = install_to_disk(&src, &project).unwrap();
assert!(first.copied_to_cache);
let second = install_to_disk(&src, &project).unwrap();
assert!(
!second.copied_to_cache,
"idempotent re-install must report copied_to_cache: false"
);
assert!(
!second.registered_in_config,
"idempotent re-install must not re-register"
);
}
fn repack_with_foreign_meta_dir(src: &Path, dest: &Path) {
use std::io::{Read as _, Write as _};
let file = std::fs::File::open(src).unwrap();
let mut archive = zip::ZipArchive::new(file).unwrap();
let out = std::fs::File::create(dest).unwrap();
let mut writer = zip::ZipWriter::new(out);
let opts = zip::write::SimpleFileOptions::default();
for i in 0..archive.len() {
let mut entry = archive.by_index(i).unwrap();
let name = entry.name().to_string();
let name = match name.strip_prefix(".memstead/") {
Some(rest) => format!(".other/{rest}"),
None => name,
};
let mut bytes = Vec::new();
entry.read_to_end(&mut bytes).unwrap();
writer.start_file(name, opts).unwrap();
writer.write_all(&bytes).unwrap();
}
writer.finish().unwrap();
}
#[test]
fn install_foreign_meta_layout_is_rejected() {
let tmp = TempDir::new().unwrap();
let cache = tmp.path().join("cache");
let project = tmp.path().join("project");
let src_dir = tmp.path().join("src");
let modern = tmp.path().join("modern.mem");
std::fs::create_dir_all(&project).unwrap();
write_minimal_mem_config(&project, "specs");
build_valid_archive(&src_dir, &modern, "foreign-mem");
let foreign = tmp.path().join("foreign-mem.mem");
repack_with_foreign_meta_dir(&modern, &foreign);
let _g = CacheGuard::install(&cache);
let err = install_to_disk(&foreign, &project)
.expect_err("a foreign meta-layout archive must not install");
assert!(matches!(err, InstallError::Validation(_)), "got {err:?}");
}
#[test]
fn install_rejects_non_archive_bytes() {
let tmp = TempDir::new().unwrap();
let cache = tmp.path().join("cache");
let project = tmp.path().join("project");
std::fs::create_dir_all(&project).unwrap();
write_minimal_mem_config(&project, "specs");
let src = tmp.path().join("bad.mem");
std::fs::write(&src, b"not a zip").unwrap();
let _g = CacheGuard::install(&cache);
let err = install_to_disk(&src, &project).unwrap_err();
assert!(matches!(err, InstallError::Validation(_)));
assert!(!cache.join("bad.mem").exists());
assert!(!cache.join("bad.mem.tmp").exists());
}
}