use std::io::{Cursor, Seek};
use std::path::{Path, PathBuf};
use crate::backend::BackendError;
use crate::provenance::Provenance;
use crate::storage::CommitId;
use crate::validator::{BoundedZipRead, ValidatorLimits, read_zip_entry_bounded};
use crate::vcs::CommitContext;
enum ArchiveSource {
Path(PathBuf),
Bytes(Vec<u8>),
}
pub struct ArchiveBackend {
source: ArchiveSource,
}
impl ArchiveBackend {
pub fn new(archive_path: PathBuf) -> Self {
Self {
source: ArchiveSource::Path(archive_path),
}
}
pub fn from_bytes(bytes: Vec<u8>) -> Self {
Self {
source: ArchiveSource::Bytes(bytes),
}
}
pub fn archive_path(&self) -> Option<&Path> {
match &self.source {
ArchiveSource::Path(p) => Some(p),
ArchiveSource::Bytes(_) => None,
}
}
}
impl ArchiveBackend {
fn with_archive_reader<F, T>(&self, f: F) -> Result<T, BackendError>
where
F: FnOnce(&mut dyn ReadSeek) -> Result<T, BackendError>,
{
match &self.source {
ArchiveSource::Path(p) => {
if !p.is_file() {
return Err(BackendError::Other(format!(
"archive not found: {}",
p.display()
)));
}
let mut file = std::fs::File::open(p).map_err(BackendError::Io)?;
f(&mut file)
}
ArchiveSource::Bytes(bytes) => {
let mut cursor = Cursor::new(bytes.as_slice());
f(&mut cursor)
}
}
}
}
trait ReadSeek: std::io::Read + Seek {}
impl<T: std::io::Read + Seek + ?Sized> ReadSeek for T {}
impl crate::backend::MemBackend for ArchiveBackend {
fn list_entities(&self) -> Result<Vec<PathBuf>, BackendError> {
let mut out = Vec::new();
self.with_archive_reader(|reader| {
for_each_md_entry(reader, |relative_path, _bytes| {
out.push(PathBuf::from(relative_path));
Ok(())
})
})?;
Ok(out)
}
fn read_entity(&self, rel_path: &Path) -> Result<Option<Vec<u8>>, BackendError> {
let want = rel_path.to_string_lossy().replace('\\', "/");
let mut found: Option<Vec<u8>> = None;
self.with_archive_reader(|reader| {
for_each_md_entry(reader, |relative_path, bytes| {
if relative_path == want {
found = Some(bytes.to_vec());
}
Ok(())
})
})?;
Ok(found)
}
fn write_entity(&self, _rel_path: &Path, _content: &[u8]) -> Result<(), BackendError> {
Err(BackendError::Sealed)
}
fn delete_entity(&self, _rel_path: &Path) -> Result<(), BackendError> {
Err(BackendError::Sealed)
}
fn move_entity(&self, _from: &Path, _to: &Path) -> Result<(), BackendError> {
Err(BackendError::Sealed)
}
fn commit(&self, _message: &str, _ctx: &CommitContext<'_>) -> Result<CommitId, BackendError> {
Err(BackendError::Sealed)
}
fn append_provenance(&self, _record: &Provenance) -> Result<(), BackendError> {
Err(BackendError::Sealed)
}
fn read_provenance(&self, _cursor: Option<&str>) -> Result<Vec<Provenance>, BackendError> {
Ok(Vec::new())
}
fn read_mem_config(&self) -> Result<Option<Vec<u8>>, BackendError> {
if let ArchiveSource::Path(p) = &self.source
&& !p.is_file()
{
return Ok(None);
}
self.with_archive_reader(|reader| {
let mut archive = zip::ZipArchive::new(reader)
.map_err(|e| BackendError::Other(format!("zip open: {e}")))?;
let config_name = memstead_schema::ARCHIVE_CONFIG_PATH;
if archive.index_for_name(config_name).is_none() {
return Ok(None);
}
let mut entry = archive
.by_name(config_name)
.map_err(|e| BackendError::Other(format!("zip lookup: {e}")))?;
let cap = ValidatorLimits::DEFAULT.max_config_file;
match read_zip_entry_bounded(&mut entry, cap).map_err(BackendError::Io)? {
BoundedZipRead::Within(bytes) => Ok(Some(bytes)),
BoundedZipRead::ExceedsCap => Err(BackendError::Other(format!(
"archive config '{config_name}' exceeds the {cap}-byte cap"
))),
}
})
}
fn read_archive_provenance(&self) -> Result<Option<Vec<u8>>, BackendError> {
if let ArchiveSource::Path(p) = &self.source
&& !p.is_file()
{
return Ok(None);
}
self.with_archive_reader(|reader| {
let mut archive = zip::ZipArchive::new(reader)
.map_err(|e| BackendError::Other(format!("zip open: {e}")))?;
let prov_name = memstead_schema::ARCHIVE_PROVENANCE_PATH;
if archive.index_for_name(prov_name).is_none() {
return Ok(None);
}
let mut entry = archive
.by_name(prov_name)
.map_err(|e| BackendError::Other(format!("zip lookup: {e}")))?;
let cap = ValidatorLimits::DEFAULT.max_uncompressed_entry;
match read_zip_entry_bounded(&mut entry, cap).map_err(BackendError::Io)? {
BoundedZipRead::Within(bytes) => Ok(Some(bytes)),
BoundedZipRead::ExceedsCap => Err(BackendError::Other(format!(
"archive provenance '{prov_name}' exceeds the {cap}-byte cap"
))),
}
})
}
fn read_anchors_sidecar(&self) -> Result<Option<Vec<u8>>, BackendError> {
if let ArchiveSource::Path(p) = &self.source
&& !p.is_file()
{
return Ok(None);
}
self.with_archive_reader(|reader| {
let mut archive = zip::ZipArchive::new(reader)
.map_err(|e| BackendError::Other(format!("zip open: {e}")))?;
let anchors_name = memstead_schema::ARCHIVE_ANCHORS_PATH;
if archive.index_for_name(anchors_name).is_none() {
return Ok(None);
}
let mut entry = archive
.by_name(anchors_name)
.map_err(|e| BackendError::Other(format!("zip lookup: {e}")))?;
let cap = ValidatorLimits::DEFAULT.max_uncompressed_entry;
match read_zip_entry_bounded(&mut entry, cap).map_err(BackendError::Io)? {
BoundedZipRead::Within(bytes) => Ok(Some(bytes)),
BoundedZipRead::ExceedsCap => Err(BackendError::Other(format!(
"archive anchors '{anchors_name}' exceeds the {cap}-byte cap"
))),
}
})
}
}
fn for_each_md_entry<R, F>(reader: &mut R, mut visit: F) -> Result<(), BackendError>
where
R: std::io::Read + Seek + ?Sized,
F: FnMut(&str, &[u8]) -> Result<(), BackendError>,
{
let mut archive = zip::ZipArchive::new(reader)
.map_err(|e| BackendError::Other(format!("open archive: {e}")))?;
let limits = ValidatorLimits::DEFAULT;
if archive.len() as u32 > limits.max_file_count {
return Err(BackendError::Other(format!(
"archive contains {} entries, exceeding the {}-entry cap",
archive.len(),
limits.max_file_count
)));
}
let mut uncompressed_total: u64 = 0;
for i in 0..archive.len() {
let mut entry = archive
.by_index(i)
.map_err(|e| BackendError::Other(format!("archive entry {i}: {e}")))?;
let raw_name = entry.name().to_string();
if entry.is_symlink() {
return Err(BackendError::Other(format!(
"entry '{raw_name}': symlinks are not allowed in sealed mem archives"
)));
}
let safe_path = match entry.enclosed_name() {
Some(p) => p,
None => {
return Err(BackendError::Other(format!(
"entry '{raw_name}': path escapes archive root \
(absolute, '..'-components, or otherwise unsafe)"
)));
}
};
if entry.is_dir() {
continue;
}
let relative_path = safe_path.to_string_lossy().replace('\\', "/");
if !relative_path.ends_with(".md") {
continue;
}
if relative_path.starts_with(".memstead/") {
continue;
}
let bytes = match read_zip_entry_bounded(&mut entry, limits.max_uncompressed_entry)
.map_err(BackendError::Io)?
{
BoundedZipRead::Within(bytes) => bytes,
BoundedZipRead::ExceedsCap => {
return Err(BackendError::Other(format!(
"entry '{relative_path}' exceeds the {}-byte uncompressed cap",
limits.max_uncompressed_entry
)));
}
};
uncompressed_total = uncompressed_total.saturating_add(bytes.len() as u64);
if uncompressed_total > limits.max_uncompressed_archive {
return Err(BackendError::Other(format!(
"archive exceeds the {}-byte total uncompressed cap",
limits.max_uncompressed_archive
)));
}
visit(&relative_path, &bytes)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::MemBackend;
use std::io::Write as _;
use tempfile::TempDir;
use zip::write::SimpleFileOptions;
fn build_archive(tmp: &Path, name: &str, entries: &[(&str, &[u8])]) -> PathBuf {
let path = tmp.join(format!("{name}.mem"));
let file = std::fs::File::create(&path).unwrap();
let mut writer = zip::ZipWriter::new(file);
let opts: SimpleFileOptions = SimpleFileOptions::default();
for (rel, bytes) in entries {
writer.start_file(*rel, opts).unwrap();
writer.write_all(bytes).unwrap();
}
writer.finish().unwrap();
path
}
fn ctx_for_test<'a>() -> CommitContext<'a> {
CommitContext::internal()
}
#[test]
fn list_returns_only_md_outside_memstead_namespace() {
let tmp = TempDir::new().unwrap();
let archive = build_archive(
tmp.path(),
"pkg",
&[
("a.md", b"# a"),
("nested/b.md", b"# b"),
("notes.json", b"{}"),
(".memstead/config.json", b"{}"),
(".memstead/notes.md", b"# skip me"),
],
);
let backend = ArchiveBackend::new(archive);
let mut paths: Vec<String> = backend
.list_entities()
.unwrap()
.into_iter()
.map(|p| p.to_string_lossy().into_owned())
.collect();
paths.sort();
assert_eq!(paths, vec!["a.md".to_string(), "nested/b.md".to_string()]);
}
#[test]
fn foreign_layout_config_is_not_read() {
let tmp = TempDir::new().unwrap();
let archive = build_archive(
tmp.path(),
"foreign",
&[
("a.md", b"# a"),
(".other/config.json", b"{\"foreign\":true}"),
],
);
let backend = ArchiveBackend::new(archive);
assert_eq!(
backend.read_mem_config().unwrap(),
None,
"a `.other/config.json` archive must not serve config"
);
}
#[test]
fn read_entity_returns_bytes_for_known_path() {
let tmp = TempDir::new().unwrap();
let archive = build_archive(
tmp.path(),
"pkg",
&[("a.md", b"# alpha"), ("b/c.md", b"# nested")],
);
let backend = ArchiveBackend::new(archive);
assert_eq!(
backend.read_entity(Path::new("a.md")).unwrap(),
Some(b"# alpha".to_vec())
);
assert_eq!(
backend.read_entity(Path::new("b/c.md")).unwrap(),
Some(b"# nested".to_vec())
);
}
#[test]
fn read_entity_refuses_oversized_entry() {
let tmp = TempDir::new().unwrap();
let big = vec![b'a'; (ValidatorLimits::DEFAULT.max_uncompressed_entry + 1) as usize];
let archive = build_archive(tmp.path(), "bomb", &[("bomb.md", big.as_slice())]);
let backend = ArchiveBackend::new(archive);
let err = backend.read_entity(Path::new("bomb.md")).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("cap"), "error should name the cap: {msg}");
}
#[test]
fn read_entity_returns_none_for_unknown_path() {
let tmp = TempDir::new().unwrap();
let archive = build_archive(tmp.path(), "pkg", &[("a.md", b"# a")]);
let backend = ArchiveBackend::new(archive);
assert_eq!(backend.read_entity(Path::new("missing.md")).unwrap(), None);
}
#[test]
fn writes_return_sealed() {
let tmp = TempDir::new().unwrap();
let archive = build_archive(tmp.path(), "pkg", &[("a.md", b"# a")]);
let backend = ArchiveBackend::new(archive);
assert!(matches!(
backend.write_entity(Path::new("x.md"), b"x"),
Err(BackendError::Sealed)
));
assert!(matches!(
backend.delete_entity(Path::new("x.md")),
Err(BackendError::Sealed)
));
assert!(matches!(
backend.move_entity(Path::new("a.md"), Path::new("b.md")),
Err(BackendError::Sealed)
));
assert!(matches!(
backend.commit("msg", &ctx_for_test()),
Err(BackendError::Sealed)
));
}
#[test]
fn provenance_append_is_sealed_read_is_empty() {
let tmp = TempDir::new().unwrap();
let archive = build_archive(tmp.path(), "pkg", &[("a.md", b"# a")]);
let backend = ArchiveBackend::new(archive);
let record = Provenance::new(
std::time::UNIX_EPOCH,
crate::provenance::ProvenanceKind::Create,
Some("v:e".into()),
crate::vcs::Actor::Unknown,
None,
None,
);
assert!(matches!(
backend.append_provenance(&record),
Err(BackendError::Sealed)
));
assert!(backend.read_provenance(None).unwrap().is_empty());
assert!(
backend
.read_provenance(Some("anything"))
.unwrap()
.is_empty()
);
}
#[test]
fn missing_archive_returns_typed_error_not_panic() {
let backend = ArchiveBackend::new(PathBuf::from("/nonexistent/missing.mem"));
match backend.list_entities() {
Err(BackendError::Other(msg)) => assert!(msg.contains("archive not found")),
other => panic!("expected archive-not-found Other error, got {other:?}"),
}
}
#[test]
fn from_bytes_lists_and_reads_same_as_path() {
let tmp = TempDir::new().unwrap();
let archive = build_archive(
tmp.path(),
"pkg",
&[("a.md", b"# alpha"), ("dir/b.md", b"# nested")],
);
let bytes = std::fs::read(&archive).unwrap();
let from_path = ArchiveBackend::new(archive);
let from_bytes = ArchiveBackend::from_bytes(bytes);
let mut path_list: Vec<String> = from_path
.list_entities()
.unwrap()
.into_iter()
.map(|p| p.to_string_lossy().into_owned())
.collect();
let mut bytes_list: Vec<String> = from_bytes
.list_entities()
.unwrap()
.into_iter()
.map(|p| p.to_string_lossy().into_owned())
.collect();
path_list.sort();
bytes_list.sort();
assert_eq!(path_list, bytes_list);
for rel in &path_list {
let p_bytes = from_path.read_entity(Path::new(rel)).unwrap();
let b_bytes = from_bytes.read_entity(Path::new(rel)).unwrap();
assert_eq!(p_bytes, b_bytes, "mismatch reading {rel}");
}
}
#[test]
fn from_bytes_writes_return_sealed() {
let backend = ArchiveBackend::from_bytes(
build_archive(TempDir::new().unwrap().path(), "pkg", &[("a.md", b"# a")])
.as_os_str()
.to_string_lossy()
.as_bytes()
.to_vec(),
);
assert!(matches!(
backend.write_entity(Path::new("x.md"), b"x"),
Err(BackendError::Sealed)
));
assert!(matches!(
backend.commit("msg", &ctx_for_test()),
Err(BackendError::Sealed)
));
}
#[test]
fn from_bytes_archive_path_is_none() {
let backend = ArchiveBackend::from_bytes(Vec::new());
assert!(backend.archive_path().is_none());
}
#[test]
fn list_then_read_for_every_listed_path() {
let tmp = TempDir::new().unwrap();
let archive = build_archive(
tmp.path(),
"pkg",
&[
("alpha.md", b"# a"),
("dir/beta.md", b"# b"),
("dir/sub/gamma.md", b"# g"),
],
);
let backend = ArchiveBackend::new(archive);
for path in backend.list_entities().unwrap() {
let bytes = backend
.read_entity(&path)
.unwrap()
.unwrap_or_else(|| panic!("listed but unread: {path:?}"));
assert!(!bytes.is_empty(), "empty entry: {path:?}");
}
}
}