Skip to main content

memstead_base/storage/
archive.rs

1//! Archive [`MemBackend`] — a sealed `.mem` zip mem, read-only.
2//!
3//! The third sibling of folder and git-branch backends. Mem content
4//! lives compressed inside a zip; the engine reads markdown entries
5//! through this backend without unzipping to disk. Write methods
6//! return [`BackendError::Sealed`] — archive-mounted mems are
7//! distribution artifacts, not edit targets.
8//!
9//! ## Reading
10//!
11//! `list_entities` and `read_entity` open the archive on each call.
12//! No in-memory caching for V1: the read paths exist primarily for
13//! the engine's load-on-mount step, which calls `list_entities` once
14//! and `read_entity` once per entry. Hot-path callers (e.g. interactive
15//! `memstead_entity` tools against a mounted archive) re-pay the open cost
16//! per request — acceptable for V1, optimisable later behind the same
17//! trait surface.
18//!
19//! Mirrors the read semantics of [`crate::entity::source::EntitySource::ZipArchive`]:
20//! - Symlinks rejected (the archive backend refuses to surface them
21//!   even though it never extracts).
22//! - Zip-slip protected via `enclosed_name`.
23//! - Only `.md` files outside the `.memstead/` namespace surface as
24//!   entities; archive-internal config / schema files are skipped at
25//!   this layer (separate read paths consume them).
26//!
27//! ## Writes and provenance
28//!
29//! Every write method returns [`BackendError::Sealed`]. `read_provenance`
30//! returns an empty vector — archives carry no mutation log; the
31//! distribution artifact is by definition history-free at the engine
32//! seam. `append_provenance` returns `Sealed` rather than silently
33//! dropping; the engine's mutation pipeline branches on capability
34//! before reaching the backend, so a `Sealed` here signals an upstream
35//! bug.
36
37use std::io::{Cursor, Seek};
38use std::path::{Path, PathBuf};
39
40use crate::backend::BackendError;
41use crate::provenance::Provenance;
42use crate::storage::CommitId;
43use crate::validator::{BoundedZipRead, ValidatorLimits, read_zip_entry_bounded};
44use crate::vcs::CommitContext;
45
46/// Source of the archive bytes. Either an on-disk `.mem` file or an
47/// in-memory buffer. The in-memory variant powers the byte-based
48/// hydrate path on `Engine` (snapshot delivery to the bridge / WASM
49/// engine) without forcing the caller to materialise a temp file.
50enum ArchiveSource {
51    Path(PathBuf),
52    Bytes(Vec<u8>),
53}
54
55/// Archive-backed [`crate::backend::MemBackend`]. Holds either the
56/// on-disk path to the sealed zip or an in-memory byte buffer; opens
57/// the archive lazily on each read call.
58pub struct ArchiveBackend {
59    source: ArchiveSource,
60}
61
62impl ArchiveBackend {
63    /// Build a backend pointing at `archive_path`. The file is not
64    /// opened until the first read; constructor failure modes are
65    /// limited to argument validation done at the engine layer
66    /// (existence checks, extension checks).
67    pub fn new(archive_path: PathBuf) -> Self {
68        Self {
69            source: ArchiveSource::Path(archive_path),
70        }
71    }
72
73    /// Build a backend wrapping an in-memory archive byte buffer. The
74    /// engine's byte-based hydrate path constructs this variant after
75    /// validating the bytes through `extract_entries`. Reads parse the
76    /// zip lazily on each call — same lifecycle as the path-based
77    /// variant.
78    pub fn from_bytes(bytes: Vec<u8>) -> Self {
79        Self {
80            source: ArchiveSource::Bytes(bytes),
81        }
82    }
83
84    /// Path of the archive this backend reads from, when the backend
85    /// was built from an on-disk file. `None` for byte-backed
86    /// instances.
87    pub fn archive_path(&self) -> Option<&Path> {
88        match &self.source {
89            ArchiveSource::Path(p) => Some(p),
90            ArchiveSource::Bytes(_) => None,
91        }
92    }
93}
94
95impl ArchiveBackend {
96    /// Run `visit` against a reader for the archive contents. Picks
97    /// `File::open` for path-backed instances and a `Cursor` over the
98    /// stored bytes for byte-backed instances. Centralises the
99    /// source-dispatch so the trait impl below stays source-agnostic.
100    fn with_archive_reader<F, T>(&self, f: F) -> Result<T, BackendError>
101    where
102        F: FnOnce(&mut dyn ReadSeek) -> Result<T, BackendError>,
103    {
104        match &self.source {
105            ArchiveSource::Path(p) => {
106                if !p.is_file() {
107                    return Err(BackendError::Other(format!(
108                        "archive not found: {}",
109                        p.display()
110                    )));
111                }
112                let mut file = std::fs::File::open(p).map_err(BackendError::Io)?;
113                f(&mut file)
114            }
115            ArchiveSource::Bytes(bytes) => {
116                let mut cursor = Cursor::new(bytes.as_slice());
117                f(&mut cursor)
118            }
119        }
120    }
121}
122
123/// Combined `Read + Seek` trait object the zip reader needs and that
124/// both `File` and `Cursor<&[u8]>` satisfy.
125trait ReadSeek: std::io::Read + Seek {}
126impl<T: std::io::Read + Seek + ?Sized> ReadSeek for T {}
127
128impl crate::backend::MemBackend for ArchiveBackend {
129    fn list_entities(&self) -> Result<Vec<PathBuf>, BackendError> {
130        let mut out = Vec::new();
131        self.with_archive_reader(|reader| {
132            for_each_md_entry(reader, |relative_path, _bytes| {
133                out.push(PathBuf::from(relative_path));
134                Ok(())
135            })
136        })?;
137        Ok(out)
138    }
139
140    fn read_entity(&self, rel_path: &Path) -> Result<Option<Vec<u8>>, BackendError> {
141        let want = rel_path.to_string_lossy().replace('\\', "/");
142        let mut found: Option<Vec<u8>> = None;
143        self.with_archive_reader(|reader| {
144            for_each_md_entry(reader, |relative_path, bytes| {
145                if relative_path == want {
146                    found = Some(bytes.to_vec());
147                }
148                Ok(())
149            })
150        })?;
151        Ok(found)
152    }
153
154    fn write_entity(&self, _rel_path: &Path, _content: &[u8]) -> Result<(), BackendError> {
155        Err(BackendError::Sealed)
156    }
157
158    fn delete_entity(&self, _rel_path: &Path) -> Result<(), BackendError> {
159        Err(BackendError::Sealed)
160    }
161
162    fn move_entity(&self, _from: &Path, _to: &Path) -> Result<(), BackendError> {
163        Err(BackendError::Sealed)
164    }
165
166    fn commit(&self, _message: &str, _ctx: &CommitContext<'_>) -> Result<CommitId, BackendError> {
167        Err(BackendError::Sealed)
168    }
169
170    fn append_provenance(&self, _record: &Provenance) -> Result<(), BackendError> {
171        Err(BackendError::Sealed)
172    }
173
174    fn read_provenance(&self, _cursor: Option<&str>) -> Result<Vec<Provenance>, BackendError> {
175        // Archives are history-free at the engine seam.
176        Ok(Vec::new())
177    }
178
179    fn read_mem_config(&self) -> Result<Option<Vec<u8>>, BackendError> {
180        // Archives bundle the per-mem config inside the zip at
181        // `.memstead/config.json`. Return the raw bytes on hit,
182        // Ok(None) on miss.
183        // Path-backed archives that are absent return Ok(None) to
184        // match the previous (non-existent-file) behaviour rather
185        // than surfacing the missing file as an error.
186        if let ArchiveSource::Path(p) = &self.source
187            && !p.is_file()
188        {
189            return Ok(None);
190        }
191        self.with_archive_reader(|reader| {
192            let mut archive = zip::ZipArchive::new(reader)
193                .map_err(|e| BackendError::Other(format!("zip open: {e}")))?;
194            // Take the mutable entry borrow only if the config member is
195            // present (`by_name` holds `&mut archive`).
196            let config_name = memstead_schema::ARCHIVE_CONFIG_PATH;
197            if archive.index_for_name(config_name).is_none() {
198                return Ok(None);
199            }
200            let mut entry = archive
201                .by_name(config_name)
202                .map_err(|e| BackendError::Other(format!("zip lookup: {e}")))?;
203            let cap = ValidatorLimits::DEFAULT.max_config_file;
204            match read_zip_entry_bounded(&mut entry, cap).map_err(BackendError::Io)? {
205                BoundedZipRead::Within(bytes) => Ok(Some(bytes)),
206                BoundedZipRead::ExceedsCap => Err(BackendError::Other(format!(
207                    "archive config '{config_name}' exceeds the {cap}-byte cap"
208                ))),
209            }
210        })
211    }
212
213    fn read_archive_provenance(&self) -> Result<Option<Vec<u8>>, BackendError> {
214        // The optional provenance payload lives at
215        // `.memstead/provenance.json` inside the zip. Same shape as
216        // `read_mem_config`: raw bytes on hit, Ok(None) on miss (a
217        // pre-provenance archive simply omits the member).
218        if let ArchiveSource::Path(p) = &self.source
219            && !p.is_file()
220        {
221            return Ok(None);
222        }
223        self.with_archive_reader(|reader| {
224            let mut archive = zip::ZipArchive::new(reader)
225                .map_err(|e| BackendError::Other(format!("zip open: {e}")))?;
226            let prov_name = memstead_schema::ARCHIVE_PROVENANCE_PATH;
227            if archive.index_for_name(prov_name).is_none() {
228                return Ok(None);
229            }
230            let mut entry = archive
231                .by_name(prov_name)
232                .map_err(|e| BackendError::Other(format!("zip lookup: {e}")))?;
233            let cap = ValidatorLimits::DEFAULT.max_uncompressed_entry;
234            match read_zip_entry_bounded(&mut entry, cap).map_err(BackendError::Io)? {
235                BoundedZipRead::Within(bytes) => Ok(Some(bytes)),
236                BoundedZipRead::ExceedsCap => Err(BackendError::Other(format!(
237                    "archive provenance '{prov_name}' exceeds the {cap}-byte cap"
238                ))),
239            }
240        })
241    }
242
243    fn read_anchors_sidecar(&self) -> Result<Option<Vec<u8>>, BackendError> {
244        // The optional engine-owned anchors sidecar (E3a) lives at
245        // `.memstead/anchors.json` inside the zip. Same shape as
246        // `read_archive_provenance`: raw bytes on hit, Ok(None) on miss (an
247        // anchor-free archive omits the member). Read-only mounts serve
248        // anchors for resolution but never write them.
249        if let ArchiveSource::Path(p) = &self.source
250            && !p.is_file()
251        {
252            return Ok(None);
253        }
254        self.with_archive_reader(|reader| {
255            let mut archive = zip::ZipArchive::new(reader)
256                .map_err(|e| BackendError::Other(format!("zip open: {e}")))?;
257            let anchors_name = memstead_schema::ARCHIVE_ANCHORS_PATH;
258            if archive.index_for_name(anchors_name).is_none() {
259                return Ok(None);
260            }
261            let mut entry = archive
262                .by_name(anchors_name)
263                .map_err(|e| BackendError::Other(format!("zip lookup: {e}")))?;
264            let cap = ValidatorLimits::DEFAULT.max_uncompressed_entry;
265            match read_zip_entry_bounded(&mut entry, cap).map_err(BackendError::Io)? {
266                BoundedZipRead::Within(bytes) => Ok(Some(bytes)),
267                BoundedZipRead::ExceedsCap => Err(BackendError::Other(format!(
268                    "archive anchors '{anchors_name}' exceeds the {cap}-byte cap"
269                ))),
270            }
271        })
272    }
273}
274
275/// Walk every `.md` entry in the archive, calling `visit` with the
276/// POSIX-relative path and the entry's bytes. Centralises the
277/// symlink / zip-slip / extension checks so list and read paths
278/// cannot diverge.
279fn for_each_md_entry<R, F>(reader: &mut R, mut visit: F) -> Result<(), BackendError>
280where
281    R: std::io::Read + Seek + ?Sized,
282    F: FnMut(&str, &[u8]) -> Result<(), BackendError>,
283{
284    let mut archive = zip::ZipArchive::new(reader)
285        .map_err(|e| BackendError::Other(format!("open archive: {e}")))?;
286    let limits = ValidatorLimits::DEFAULT;
287    if archive.len() as u32 > limits.max_file_count {
288        return Err(BackendError::Other(format!(
289            "archive contains {} entries, exceeding the {}-entry cap",
290            archive.len(),
291            limits.max_file_count
292        )));
293    }
294    let mut uncompressed_total: u64 = 0;
295    for i in 0..archive.len() {
296        let mut entry = archive
297            .by_index(i)
298            .map_err(|e| BackendError::Other(format!("archive entry {i}: {e}")))?;
299        let raw_name = entry.name().to_string();
300        if entry.is_symlink() {
301            return Err(BackendError::Other(format!(
302                "entry '{raw_name}': symlinks are not allowed in sealed mem archives"
303            )));
304        }
305        let safe_path = match entry.enclosed_name() {
306            Some(p) => p,
307            None => {
308                return Err(BackendError::Other(format!(
309                    "entry '{raw_name}': path escapes archive root \
310                     (absolute, '..'-components, or otherwise unsafe)"
311                )));
312            }
313        };
314        if entry.is_dir() {
315            continue;
316        }
317        let relative_path = safe_path.to_string_lossy().replace('\\', "/");
318        if !relative_path.ends_with(".md") {
319            continue;
320        }
321        // Skip the archive's `.memstead/` meta umbrella so config /
322        // schema files don't surface as entity content. (They have
323        // separate read paths.) Matches the folder backend's meta-dir
324        // skip.
325        if relative_path.starts_with(".memstead/") {
326            continue;
327        }
328        let bytes = match read_zip_entry_bounded(&mut entry, limits.max_uncompressed_entry)
329            .map_err(BackendError::Io)?
330        {
331            BoundedZipRead::Within(bytes) => bytes,
332            BoundedZipRead::ExceedsCap => {
333                return Err(BackendError::Other(format!(
334                    "entry '{relative_path}' exceeds the {}-byte uncompressed cap",
335                    limits.max_uncompressed_entry
336                )));
337            }
338        };
339        uncompressed_total = uncompressed_total.saturating_add(bytes.len() as u64);
340        if uncompressed_total > limits.max_uncompressed_archive {
341            return Err(BackendError::Other(format!(
342                "archive exceeds the {}-byte total uncompressed cap",
343                limits.max_uncompressed_archive
344            )));
345        }
346        visit(&relative_path, &bytes)?;
347    }
348    Ok(())
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354    use crate::backend::MemBackend;
355    use std::io::Write as _;
356    use tempfile::TempDir;
357    use zip::write::SimpleFileOptions;
358
359    /// Build a sealed archive at `tmp/<name>.mem` from
360    /// `(relative_path, bytes)` pairs. Returns the archive path.
361    fn build_archive(tmp: &Path, name: &str, entries: &[(&str, &[u8])]) -> PathBuf {
362        let path = tmp.join(format!("{name}.mem"));
363        let file = std::fs::File::create(&path).unwrap();
364        let mut writer = zip::ZipWriter::new(file);
365        let opts: SimpleFileOptions = SimpleFileOptions::default();
366        for (rel, bytes) in entries {
367            writer.start_file(*rel, opts).unwrap();
368            writer.write_all(bytes).unwrap();
369        }
370        writer.finish().unwrap();
371        path
372    }
373
374    fn ctx_for_test<'a>() -> CommitContext<'a> {
375        CommitContext::internal()
376    }
377
378    #[test]
379    fn list_returns_only_md_outside_memstead_namespace() {
380        let tmp = TempDir::new().unwrap();
381        let archive = build_archive(
382            tmp.path(),
383            "pkg",
384            &[
385                ("a.md", b"# a"),
386                ("nested/b.md", b"# b"),
387                ("notes.json", b"{}"),
388                (".memstead/config.json", b"{}"),
389                (".memstead/notes.md", b"# skip me"),
390            ],
391        );
392        let backend = ArchiveBackend::new(archive);
393        let mut paths: Vec<String> = backend
394            .list_entities()
395            .unwrap()
396            .into_iter()
397            .map(|p| p.to_string_lossy().into_owned())
398            .collect();
399        paths.sort();
400        assert_eq!(paths, vec!["a.md".to_string(), "nested/b.md".to_string()]);
401    }
402
403    /// Only the `.memstead/` meta layout is read: a config under any
404    /// other dir (`.other/config.json`) is not served — the sole config
405    /// member path is `.memstead/config.json`.
406    #[test]
407    fn foreign_layout_config_is_not_read() {
408        let tmp = TempDir::new().unwrap();
409        let archive = build_archive(
410            tmp.path(),
411            "foreign",
412            &[
413                ("a.md", b"# a"),
414                (".other/config.json", b"{\"foreign\":true}"),
415            ],
416        );
417        let backend = ArchiveBackend::new(archive);
418        assert_eq!(
419            backend.read_mem_config().unwrap(),
420            None,
421            "a `.other/config.json` archive must not serve config"
422        );
423    }
424
425    #[test]
426    fn read_entity_returns_bytes_for_known_path() {
427        let tmp = TempDir::new().unwrap();
428        let archive = build_archive(
429            tmp.path(),
430            "pkg",
431            &[("a.md", b"# alpha"), ("b/c.md", b"# nested")],
432        );
433        let backend = ArchiveBackend::new(archive);
434        assert_eq!(
435            backend.read_entity(Path::new("a.md")).unwrap(),
436            Some(b"# alpha".to_vec())
437        );
438        assert_eq!(
439            backend.read_entity(Path::new("b/c.md")).unwrap(),
440            Some(b"# nested".to_vec())
441        );
442    }
443
444    #[test]
445    fn read_entity_refuses_oversized_entry() {
446        // Deflate bomb one byte past the per-entry uncompressed cap:
447        // the read stops at the cap and refuses with a typed error
448        // instead of decompressing the whole entry into memory.
449        let tmp = TempDir::new().unwrap();
450        let big = vec![b'a'; (ValidatorLimits::DEFAULT.max_uncompressed_entry + 1) as usize];
451        let archive = build_archive(tmp.path(), "bomb", &[("bomb.md", big.as_slice())]);
452        let backend = ArchiveBackend::new(archive);
453        let err = backend.read_entity(Path::new("bomb.md")).unwrap_err();
454        let msg = format!("{err}");
455        assert!(msg.contains("cap"), "error should name the cap: {msg}");
456    }
457
458    #[test]
459    fn read_entity_returns_none_for_unknown_path() {
460        let tmp = TempDir::new().unwrap();
461        let archive = build_archive(tmp.path(), "pkg", &[("a.md", b"# a")]);
462        let backend = ArchiveBackend::new(archive);
463        assert_eq!(backend.read_entity(Path::new("missing.md")).unwrap(), None);
464    }
465
466    #[test]
467    fn writes_return_sealed() {
468        let tmp = TempDir::new().unwrap();
469        let archive = build_archive(tmp.path(), "pkg", &[("a.md", b"# a")]);
470        let backend = ArchiveBackend::new(archive);
471        assert!(matches!(
472            backend.write_entity(Path::new("x.md"), b"x"),
473            Err(BackendError::Sealed)
474        ));
475        assert!(matches!(
476            backend.delete_entity(Path::new("x.md")),
477            Err(BackendError::Sealed)
478        ));
479        assert!(matches!(
480            backend.move_entity(Path::new("a.md"), Path::new("b.md")),
481            Err(BackendError::Sealed)
482        ));
483        assert!(matches!(
484            backend.commit("msg", &ctx_for_test()),
485            Err(BackendError::Sealed)
486        ));
487    }
488
489    #[test]
490    fn provenance_append_is_sealed_read_is_empty() {
491        let tmp = TempDir::new().unwrap();
492        let archive = build_archive(tmp.path(), "pkg", &[("a.md", b"# a")]);
493        let backend = ArchiveBackend::new(archive);
494        let record = Provenance::new(
495            std::time::UNIX_EPOCH,
496            crate::provenance::ProvenanceKind::Create,
497            Some("v:e".into()),
498            crate::vcs::Actor::Unknown,
499            None,
500            None,
501        );
502        assert!(matches!(
503            backend.append_provenance(&record),
504            Err(BackendError::Sealed)
505        ));
506        assert!(backend.read_provenance(None).unwrap().is_empty());
507        // Cursor parameter is accepted but ignored — same empty result.
508        assert!(
509            backend
510                .read_provenance(Some("anything"))
511                .unwrap()
512                .is_empty()
513        );
514    }
515
516    #[test]
517    fn missing_archive_returns_typed_error_not_panic() {
518        let backend = ArchiveBackend::new(PathBuf::from("/nonexistent/missing.mem"));
519        match backend.list_entities() {
520            Err(BackendError::Other(msg)) => assert!(msg.contains("archive not found")),
521            other => panic!("expected archive-not-found Other error, got {other:?}"),
522        }
523    }
524
525    #[test]
526    fn from_bytes_lists_and_reads_same_as_path() {
527        // Same archive content via path vs in-memory bytes must
528        // produce identical list/read results — the source dispatch is
529        // transparent to the trait surface.
530        let tmp = TempDir::new().unwrap();
531        let archive = build_archive(
532            tmp.path(),
533            "pkg",
534            &[("a.md", b"# alpha"), ("dir/b.md", b"# nested")],
535        );
536        let bytes = std::fs::read(&archive).unwrap();
537        let from_path = ArchiveBackend::new(archive);
538        let from_bytes = ArchiveBackend::from_bytes(bytes);
539
540        let mut path_list: Vec<String> = from_path
541            .list_entities()
542            .unwrap()
543            .into_iter()
544            .map(|p| p.to_string_lossy().into_owned())
545            .collect();
546        let mut bytes_list: Vec<String> = from_bytes
547            .list_entities()
548            .unwrap()
549            .into_iter()
550            .map(|p| p.to_string_lossy().into_owned())
551            .collect();
552        path_list.sort();
553        bytes_list.sort();
554        assert_eq!(path_list, bytes_list);
555
556        for rel in &path_list {
557            let p_bytes = from_path.read_entity(Path::new(rel)).unwrap();
558            let b_bytes = from_bytes.read_entity(Path::new(rel)).unwrap();
559            assert_eq!(p_bytes, b_bytes, "mismatch reading {rel}");
560        }
561    }
562
563    #[test]
564    fn from_bytes_writes_return_sealed() {
565        let backend = ArchiveBackend::from_bytes(
566            build_archive(TempDir::new().unwrap().path(), "pkg", &[("a.md", b"# a")])
567                .as_os_str()
568                .to_string_lossy()
569                .as_bytes()
570                .to_vec(),
571        );
572        // Even with bogus bytes, the write methods short-circuit on
573        // Sealed before parsing the archive — covers the symmetry
574        // contract that byte-backed archives are also read-only.
575        assert!(matches!(
576            backend.write_entity(Path::new("x.md"), b"x"),
577            Err(BackendError::Sealed)
578        ));
579        assert!(matches!(
580            backend.commit("msg", &ctx_for_test()),
581            Err(BackendError::Sealed)
582        ));
583    }
584
585    #[test]
586    fn from_bytes_archive_path_is_none() {
587        let backend = ArchiveBackend::from_bytes(Vec::new());
588        assert!(backend.archive_path().is_none());
589    }
590
591    #[test]
592    fn list_then_read_for_every_listed_path() {
593        // The two read paths must agree on what's in the archive: every
594        // path returned by `list_entities` must be readable via
595        // `read_entity` and yield non-empty bytes.
596        let tmp = TempDir::new().unwrap();
597        let archive = build_archive(
598            tmp.path(),
599            "pkg",
600            &[
601                ("alpha.md", b"# a"),
602                ("dir/beta.md", b"# b"),
603                ("dir/sub/gamma.md", b"# g"),
604            ],
605        );
606        let backend = ArchiveBackend::new(archive);
607        for path in backend.list_entities().unwrap() {
608            let bytes = backend
609                .read_entity(&path)
610                .unwrap()
611                .unwrap_or_else(|| panic!("listed but unread: {path:?}"));
612            assert!(!bytes.is_empty(), "empty entry: {path:?}");
613        }
614    }
615}