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    /// The archive file exists on disk (an in-memory archive is always
130    /// present).
131    fn storage_present(&self) -> Result<bool, crate::backend::BackendError> {
132        Ok(match &self.source {
133            ArchiveSource::Path(p) => p.is_file(),
134            ArchiveSource::Bytes(_) => true,
135        })
136    }
137
138    fn list_entities(&self) -> Result<Vec<PathBuf>, BackendError> {
139        let mut out = Vec::new();
140        self.with_archive_reader(|reader| {
141            for_each_md_entry(reader, |relative_path, _bytes| {
142                out.push(PathBuf::from(relative_path));
143                Ok(())
144            })
145        })?;
146        Ok(out)
147    }
148
149    fn read_entity(&self, rel_path: &Path) -> Result<Option<Vec<u8>>, BackendError> {
150        let want = rel_path.to_string_lossy().replace('\\', "/");
151        let mut found: Option<Vec<u8>> = None;
152        self.with_archive_reader(|reader| {
153            for_each_md_entry(reader, |relative_path, bytes| {
154                if relative_path == want {
155                    found = Some(bytes.to_vec());
156                }
157                Ok(())
158            })
159        })?;
160        Ok(found)
161    }
162
163    fn write_entity(&self, _rel_path: &Path, _content: &[u8]) -> Result<(), BackendError> {
164        Err(BackendError::Sealed)
165    }
166
167    fn delete_entity(&self, _rel_path: &Path) -> Result<(), BackendError> {
168        Err(BackendError::Sealed)
169    }
170
171    fn move_entity(&self, _from: &Path, _to: &Path) -> Result<(), BackendError> {
172        Err(BackendError::Sealed)
173    }
174
175    fn commit(&self, _message: &str, _ctx: &CommitContext<'_>) -> Result<CommitId, BackendError> {
176        Err(BackendError::Sealed)
177    }
178
179    fn append_provenance(&self, _record: &Provenance) -> Result<(), BackendError> {
180        Err(BackendError::Sealed)
181    }
182
183    fn read_provenance(&self, _cursor: Option<&str>) -> Result<Vec<Provenance>, BackendError> {
184        // Archives are history-free at the engine seam.
185        Ok(Vec::new())
186    }
187
188    fn read_mem_config(&self) -> Result<Option<Vec<u8>>, BackendError> {
189        // Archives bundle the per-mem config inside the zip at
190        // `.memstead/config.json`. Return the raw bytes on hit,
191        // Ok(None) on miss.
192        // Path-backed archives that are absent return Ok(None) to
193        // match the previous (non-existent-file) behaviour rather
194        // than surfacing the missing file as an error.
195        if let ArchiveSource::Path(p) = &self.source
196            && !p.is_file()
197        {
198            return Ok(None);
199        }
200        self.with_archive_reader(|reader| {
201            let mut archive = zip::ZipArchive::new(reader)
202                .map_err(|e| BackendError::Other(format!("zip open: {e}")))?;
203            // Take the mutable entry borrow only if the config member is
204            // present (`by_name` holds `&mut archive`).
205            let config_name = memstead_schema::ARCHIVE_CONFIG_PATH;
206            if archive.index_for_name(config_name).is_none() {
207                return Ok(None);
208            }
209            let mut entry = archive
210                .by_name(config_name)
211                .map_err(|e| BackendError::Other(format!("zip lookup: {e}")))?;
212            let cap = ValidatorLimits::DEFAULT.max_config_file;
213            match read_zip_entry_bounded(&mut entry, cap).map_err(BackendError::Io)? {
214                BoundedZipRead::Within(bytes) => Ok(Some(bytes)),
215                BoundedZipRead::ExceedsCap => Err(BackendError::Other(format!(
216                    "archive config '{config_name}' exceeds the {cap}-byte cap"
217                ))),
218            }
219        })
220    }
221
222    fn read_archive_provenance(&self) -> Result<Option<Vec<u8>>, BackendError> {
223        // The optional provenance payload lives at
224        // `.memstead/provenance.json` inside the zip. Same shape as
225        // `read_mem_config`: raw bytes on hit, Ok(None) on miss (a
226        // pre-provenance archive simply omits the member).
227        if let ArchiveSource::Path(p) = &self.source
228            && !p.is_file()
229        {
230            return Ok(None);
231        }
232        self.with_archive_reader(|reader| {
233            let mut archive = zip::ZipArchive::new(reader)
234                .map_err(|e| BackendError::Other(format!("zip open: {e}")))?;
235            let prov_name = memstead_schema::ARCHIVE_PROVENANCE_PATH;
236            if archive.index_for_name(prov_name).is_none() {
237                return Ok(None);
238            }
239            let mut entry = archive
240                .by_name(prov_name)
241                .map_err(|e| BackendError::Other(format!("zip lookup: {e}")))?;
242            let cap = ValidatorLimits::DEFAULT.max_uncompressed_entry;
243            match read_zip_entry_bounded(&mut entry, cap).map_err(BackendError::Io)? {
244                BoundedZipRead::Within(bytes) => Ok(Some(bytes)),
245                BoundedZipRead::ExceedsCap => Err(BackendError::Other(format!(
246                    "archive provenance '{prov_name}' exceeds the {cap}-byte cap"
247                ))),
248            }
249        })
250    }
251
252    fn read_anchors_sidecar(&self) -> Result<Option<Vec<u8>>, BackendError> {
253        // The optional engine-owned anchors sidecar (E3a) lives at
254        // `.memstead/anchors.json` inside the zip. Same shape as
255        // `read_archive_provenance`: raw bytes on hit, Ok(None) on miss (an
256        // anchor-free archive omits the member). Read-only mounts serve
257        // anchors for resolution but never write them.
258        if let ArchiveSource::Path(p) = &self.source
259            && !p.is_file()
260        {
261            return Ok(None);
262        }
263        self.with_archive_reader(|reader| {
264            let mut archive = zip::ZipArchive::new(reader)
265                .map_err(|e| BackendError::Other(format!("zip open: {e}")))?;
266            let anchors_name = memstead_schema::ARCHIVE_ANCHORS_PATH;
267            if archive.index_for_name(anchors_name).is_none() {
268                return Ok(None);
269            }
270            let mut entry = archive
271                .by_name(anchors_name)
272                .map_err(|e| BackendError::Other(format!("zip lookup: {e}")))?;
273            let cap = ValidatorLimits::DEFAULT.max_uncompressed_entry;
274            match read_zip_entry_bounded(&mut entry, cap).map_err(BackendError::Io)? {
275                BoundedZipRead::Within(bytes) => Ok(Some(bytes)),
276                BoundedZipRead::ExceedsCap => Err(BackendError::Other(format!(
277                    "archive anchors '{anchors_name}' exceeds the {cap}-byte cap"
278                ))),
279            }
280        })
281    }
282}
283
284/// Walk every `.md` entry in the archive, calling `visit` with the
285/// POSIX-relative path and the entry's bytes. Centralises the
286/// symlink / zip-slip / extension checks so list and read paths
287/// cannot diverge.
288fn for_each_md_entry<R, F>(reader: &mut R, mut visit: F) -> Result<(), BackendError>
289where
290    R: std::io::Read + Seek + ?Sized,
291    F: FnMut(&str, &[u8]) -> Result<(), BackendError>,
292{
293    let mut archive = zip::ZipArchive::new(reader)
294        .map_err(|e| BackendError::Other(format!("open archive: {e}")))?;
295    let limits = ValidatorLimits::DEFAULT;
296    if archive.len() as u32 > limits.max_file_count {
297        return Err(BackendError::Other(format!(
298            "archive contains {} entries, exceeding the {}-entry cap",
299            archive.len(),
300            limits.max_file_count
301        )));
302    }
303    let mut uncompressed_total: u64 = 0;
304    for i in 0..archive.len() {
305        let mut entry = archive
306            .by_index(i)
307            .map_err(|e| BackendError::Other(format!("archive entry {i}: {e}")))?;
308        let raw_name = entry.name().to_string();
309        if entry.is_symlink() {
310            return Err(BackendError::Other(format!(
311                "entry '{raw_name}': symlinks are not allowed in sealed mem archives"
312            )));
313        }
314        let safe_path = match entry.enclosed_name() {
315            Some(p) => p,
316            None => {
317                return Err(BackendError::Other(format!(
318                    "entry '{raw_name}': path escapes archive root \
319                     (absolute, '..'-components, or otherwise unsafe)"
320                )));
321            }
322        };
323        if entry.is_dir() {
324            continue;
325        }
326        let relative_path = safe_path.to_string_lossy().replace('\\', "/");
327        if !relative_path.ends_with(".md") {
328            continue;
329        }
330        // Skip the archive's `.memstead/` meta umbrella so config /
331        // schema files don't surface as entity content. (They have
332        // separate read paths.) Matches the folder backend's meta-dir
333        // skip.
334        if relative_path.starts_with(".memstead/") {
335            continue;
336        }
337        let bytes = match read_zip_entry_bounded(&mut entry, limits.max_uncompressed_entry)
338            .map_err(BackendError::Io)?
339        {
340            BoundedZipRead::Within(bytes) => bytes,
341            BoundedZipRead::ExceedsCap => {
342                return Err(BackendError::Other(format!(
343                    "entry '{relative_path}' exceeds the {}-byte uncompressed cap",
344                    limits.max_uncompressed_entry
345                )));
346            }
347        };
348        uncompressed_total = uncompressed_total.saturating_add(bytes.len() as u64);
349        if uncompressed_total > limits.max_uncompressed_archive {
350            return Err(BackendError::Other(format!(
351                "archive exceeds the {}-byte total uncompressed cap",
352                limits.max_uncompressed_archive
353            )));
354        }
355        visit(&relative_path, &bytes)?;
356    }
357    Ok(())
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363    use crate::backend::MemBackend;
364    use std::io::Write as _;
365    use tempfile::TempDir;
366    use zip::write::SimpleFileOptions;
367
368    /// Build a sealed archive at `tmp/<name>.mem` from
369    /// `(relative_path, bytes)` pairs. Returns the archive path.
370    fn build_archive(tmp: &Path, name: &str, entries: &[(&str, &[u8])]) -> PathBuf {
371        let path = tmp.join(format!("{name}.mem"));
372        let file = std::fs::File::create(&path).unwrap();
373        let mut writer = zip::ZipWriter::new(file);
374        let opts: SimpleFileOptions = SimpleFileOptions::default();
375        for (rel, bytes) in entries {
376            writer.start_file(*rel, opts).unwrap();
377            writer.write_all(bytes).unwrap();
378        }
379        writer.finish().unwrap();
380        path
381    }
382
383    fn ctx_for_test<'a>() -> CommitContext<'a> {
384        CommitContext::internal()
385    }
386
387    #[test]
388    fn list_returns_only_md_outside_memstead_namespace() {
389        let tmp = TempDir::new().unwrap();
390        let archive = build_archive(
391            tmp.path(),
392            "pkg",
393            &[
394                ("a.md", b"# a"),
395                ("nested/b.md", b"# b"),
396                ("notes.json", b"{}"),
397                (".memstead/config.json", b"{}"),
398                (".memstead/notes.md", b"# skip me"),
399            ],
400        );
401        let backend = ArchiveBackend::new(archive);
402        let mut paths: Vec<String> = backend
403            .list_entities()
404            .unwrap()
405            .into_iter()
406            .map(|p| p.to_string_lossy().into_owned())
407            .collect();
408        paths.sort();
409        assert_eq!(paths, vec!["a.md".to_string(), "nested/b.md".to_string()]);
410    }
411
412    /// Only the `.memstead/` meta layout is read: a config under any
413    /// other dir (`.other/config.json`) is not served — the sole config
414    /// member path is `.memstead/config.json`.
415    #[test]
416    fn foreign_layout_config_is_not_read() {
417        let tmp = TempDir::new().unwrap();
418        let archive = build_archive(
419            tmp.path(),
420            "foreign",
421            &[
422                ("a.md", b"# a"),
423                (".other/config.json", b"{\"foreign\":true}"),
424            ],
425        );
426        let backend = ArchiveBackend::new(archive);
427        assert_eq!(
428            backend.read_mem_config().unwrap(),
429            None,
430            "a `.other/config.json` archive must not serve config"
431        );
432    }
433
434    #[test]
435    fn read_entity_returns_bytes_for_known_path() {
436        let tmp = TempDir::new().unwrap();
437        let archive = build_archive(
438            tmp.path(),
439            "pkg",
440            &[("a.md", b"# alpha"), ("b/c.md", b"# nested")],
441        );
442        let backend = ArchiveBackend::new(archive);
443        assert_eq!(
444            backend.read_entity(Path::new("a.md")).unwrap(),
445            Some(b"# alpha".to_vec())
446        );
447        assert_eq!(
448            backend.read_entity(Path::new("b/c.md")).unwrap(),
449            Some(b"# nested".to_vec())
450        );
451    }
452
453    #[test]
454    fn read_entity_refuses_oversized_entry() {
455        // Deflate bomb one byte past the per-entry uncompressed cap:
456        // the read stops at the cap and refuses with a typed error
457        // instead of decompressing the whole entry into memory.
458        let tmp = TempDir::new().unwrap();
459        let big = vec![b'a'; (ValidatorLimits::DEFAULT.max_uncompressed_entry + 1) as usize];
460        let archive = build_archive(tmp.path(), "bomb", &[("bomb.md", big.as_slice())]);
461        let backend = ArchiveBackend::new(archive);
462        let err = backend.read_entity(Path::new("bomb.md")).unwrap_err();
463        let msg = format!("{err}");
464        assert!(msg.contains("cap"), "error should name the cap: {msg}");
465    }
466
467    #[test]
468    fn read_entity_returns_none_for_unknown_path() {
469        let tmp = TempDir::new().unwrap();
470        let archive = build_archive(tmp.path(), "pkg", &[("a.md", b"# a")]);
471        let backend = ArchiveBackend::new(archive);
472        assert_eq!(backend.read_entity(Path::new("missing.md")).unwrap(), None);
473    }
474
475    #[test]
476    fn writes_return_sealed() {
477        let tmp = TempDir::new().unwrap();
478        let archive = build_archive(tmp.path(), "pkg", &[("a.md", b"# a")]);
479        let backend = ArchiveBackend::new(archive);
480        assert!(matches!(
481            backend.write_entity(Path::new("x.md"), b"x"),
482            Err(BackendError::Sealed)
483        ));
484        assert!(matches!(
485            backend.delete_entity(Path::new("x.md")),
486            Err(BackendError::Sealed)
487        ));
488        assert!(matches!(
489            backend.move_entity(Path::new("a.md"), Path::new("b.md")),
490            Err(BackendError::Sealed)
491        ));
492        assert!(matches!(
493            backend.commit("msg", &ctx_for_test()),
494            Err(BackendError::Sealed)
495        ));
496    }
497
498    #[test]
499    fn provenance_append_is_sealed_read_is_empty() {
500        let tmp = TempDir::new().unwrap();
501        let archive = build_archive(tmp.path(), "pkg", &[("a.md", b"# a")]);
502        let backend = ArchiveBackend::new(archive);
503        let record = Provenance::new(
504            std::time::UNIX_EPOCH,
505            crate::provenance::ProvenanceKind::Create,
506            Some("v:e".into()),
507            crate::vcs::Actor::Unknown,
508            None,
509            None,
510        );
511        assert!(matches!(
512            backend.append_provenance(&record),
513            Err(BackendError::Sealed)
514        ));
515        assert!(backend.read_provenance(None).unwrap().is_empty());
516        // Cursor parameter is accepted but ignored — same empty result.
517        assert!(
518            backend
519                .read_provenance(Some("anything"))
520                .unwrap()
521                .is_empty()
522        );
523    }
524
525    #[test]
526    fn missing_archive_returns_typed_error_not_panic() {
527        let backend = ArchiveBackend::new(PathBuf::from("/nonexistent/missing.mem"));
528        match backend.list_entities() {
529            Err(BackendError::Other(msg)) => assert!(msg.contains("archive not found")),
530            other => panic!("expected archive-not-found Other error, got {other:?}"),
531        }
532    }
533
534    #[test]
535    fn from_bytes_lists_and_reads_same_as_path() {
536        // Same archive content via path vs in-memory bytes must
537        // produce identical list/read results — the source dispatch is
538        // transparent to the trait surface.
539        let tmp = TempDir::new().unwrap();
540        let archive = build_archive(
541            tmp.path(),
542            "pkg",
543            &[("a.md", b"# alpha"), ("dir/b.md", b"# nested")],
544        );
545        let bytes = std::fs::read(&archive).unwrap();
546        let from_path = ArchiveBackend::new(archive);
547        let from_bytes = ArchiveBackend::from_bytes(bytes);
548
549        let mut path_list: Vec<String> = from_path
550            .list_entities()
551            .unwrap()
552            .into_iter()
553            .map(|p| p.to_string_lossy().into_owned())
554            .collect();
555        let mut bytes_list: Vec<String> = from_bytes
556            .list_entities()
557            .unwrap()
558            .into_iter()
559            .map(|p| p.to_string_lossy().into_owned())
560            .collect();
561        path_list.sort();
562        bytes_list.sort();
563        assert_eq!(path_list, bytes_list);
564
565        for rel in &path_list {
566            let p_bytes = from_path.read_entity(Path::new(rel)).unwrap();
567            let b_bytes = from_bytes.read_entity(Path::new(rel)).unwrap();
568            assert_eq!(p_bytes, b_bytes, "mismatch reading {rel}");
569        }
570    }
571
572    #[test]
573    fn from_bytes_writes_return_sealed() {
574        let backend = ArchiveBackend::from_bytes(
575            build_archive(TempDir::new().unwrap().path(), "pkg", &[("a.md", b"# a")])
576                .as_os_str()
577                .to_string_lossy()
578                .as_bytes()
579                .to_vec(),
580        );
581        // Even with bogus bytes, the write methods short-circuit on
582        // Sealed before parsing the archive — covers the symmetry
583        // contract that byte-backed archives are also read-only.
584        assert!(matches!(
585            backend.write_entity(Path::new("x.md"), b"x"),
586            Err(BackendError::Sealed)
587        ));
588        assert!(matches!(
589            backend.commit("msg", &ctx_for_test()),
590            Err(BackendError::Sealed)
591        ));
592    }
593
594    #[test]
595    fn from_bytes_archive_path_is_none() {
596        let backend = ArchiveBackend::from_bytes(Vec::new());
597        assert!(backend.archive_path().is_none());
598    }
599
600    #[test]
601    fn list_then_read_for_every_listed_path() {
602        // The two read paths must agree on what's in the archive: every
603        // path returned by `list_entities` must be readable via
604        // `read_entity` and yield non-empty bytes.
605        let tmp = TempDir::new().unwrap();
606        let archive = build_archive(
607            tmp.path(),
608            "pkg",
609            &[
610                ("alpha.md", b"# a"),
611                ("dir/beta.md", b"# b"),
612                ("dir/sub/gamma.md", b"# g"),
613            ],
614        );
615        let backend = ArchiveBackend::new(archive);
616        for path in backend.list_entities().unwrap() {
617            let bytes = backend
618                .read_entity(&path)
619                .unwrap()
620                .unwrap_or_else(|| panic!("listed but unread: {path:?}"));
621            assert!(!bytes.is_empty(), "empty entry: {path:?}");
622        }
623    }
624}