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
244/// Walk every `.md` entry in the archive, calling `visit` with the
245/// POSIX-relative path and the entry's bytes. Centralises the
246/// symlink / zip-slip / extension checks so list and read paths
247/// cannot diverge.
248fn for_each_md_entry<R, F>(reader: &mut R, mut visit: F) -> Result<(), BackendError>
249where
250    R: std::io::Read + Seek + ?Sized,
251    F: FnMut(&str, &[u8]) -> Result<(), BackendError>,
252{
253    let mut archive = zip::ZipArchive::new(reader)
254        .map_err(|e| BackendError::Other(format!("open archive: {e}")))?;
255    let limits = ValidatorLimits::DEFAULT;
256    if archive.len() as u32 > limits.max_file_count {
257        return Err(BackendError::Other(format!(
258            "archive contains {} entries, exceeding the {}-entry cap",
259            archive.len(),
260            limits.max_file_count
261        )));
262    }
263    let mut uncompressed_total: u64 = 0;
264    for i in 0..archive.len() {
265        let mut entry = archive
266            .by_index(i)
267            .map_err(|e| BackendError::Other(format!("archive entry {i}: {e}")))?;
268        let raw_name = entry.name().to_string();
269        if entry.is_symlink() {
270            return Err(BackendError::Other(format!(
271                "entry '{raw_name}': symlinks are not allowed in sealed mem archives"
272            )));
273        }
274        let safe_path = match entry.enclosed_name() {
275            Some(p) => p,
276            None => {
277                return Err(BackendError::Other(format!(
278                    "entry '{raw_name}': path escapes archive root \
279                     (absolute, '..'-components, or otherwise unsafe)"
280                )));
281            }
282        };
283        if entry.is_dir() {
284            continue;
285        }
286        let relative_path = safe_path.to_string_lossy().replace('\\', "/");
287        if !relative_path.ends_with(".md") {
288            continue;
289        }
290        // Skip the archive's `.memstead/` meta umbrella so config /
291        // schema files don't surface as entity content. (They have
292        // separate read paths.) Matches the folder backend's meta-dir
293        // skip.
294        if relative_path.starts_with(".memstead/") {
295            continue;
296        }
297        let bytes = match read_zip_entry_bounded(&mut entry, limits.max_uncompressed_entry)
298            .map_err(BackendError::Io)?
299        {
300            BoundedZipRead::Within(bytes) => bytes,
301            BoundedZipRead::ExceedsCap => {
302                return Err(BackendError::Other(format!(
303                    "entry '{relative_path}' exceeds the {}-byte uncompressed cap",
304                    limits.max_uncompressed_entry
305                )));
306            }
307        };
308        uncompressed_total = uncompressed_total.saturating_add(bytes.len() as u64);
309        if uncompressed_total > limits.max_uncompressed_archive {
310            return Err(BackendError::Other(format!(
311                "archive exceeds the {}-byte total uncompressed cap",
312                limits.max_uncompressed_archive
313            )));
314        }
315        visit(&relative_path, &bytes)?;
316    }
317    Ok(())
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323    use crate::backend::MemBackend;
324    use std::io::Write as _;
325    use tempfile::TempDir;
326    use zip::write::SimpleFileOptions;
327
328    /// Build a sealed archive at `tmp/<name>.mem` from
329    /// `(relative_path, bytes)` pairs. Returns the archive path.
330    fn build_archive(tmp: &Path, name: &str, entries: &[(&str, &[u8])]) -> PathBuf {
331        let path = tmp.join(format!("{name}.mem"));
332        let file = std::fs::File::create(&path).unwrap();
333        let mut writer = zip::ZipWriter::new(file);
334        let opts: SimpleFileOptions = SimpleFileOptions::default();
335        for (rel, bytes) in entries {
336            writer.start_file(*rel, opts).unwrap();
337            writer.write_all(bytes).unwrap();
338        }
339        writer.finish().unwrap();
340        path
341    }
342
343    fn ctx_for_test<'a>() -> CommitContext<'a> {
344        CommitContext::internal()
345    }
346
347    #[test]
348    fn list_returns_only_md_outside_memstead_namespace() {
349        let tmp = TempDir::new().unwrap();
350        let archive = build_archive(
351            tmp.path(),
352            "pkg",
353            &[
354                ("a.md", b"# a"),
355                ("nested/b.md", b"# b"),
356                ("notes.json", b"{}"),
357                (".memstead/config.json", b"{}"),
358                (".memstead/notes.md", b"# skip me"),
359            ],
360        );
361        let backend = ArchiveBackend::new(archive);
362        let mut paths: Vec<String> = backend
363            .list_entities()
364            .unwrap()
365            .into_iter()
366            .map(|p| p.to_string_lossy().into_owned())
367            .collect();
368        paths.sort();
369        assert_eq!(paths, vec!["a.md".to_string(), "nested/b.md".to_string()]);
370    }
371
372    /// Only the `.memstead/` meta layout is read: a config under any
373    /// other dir (`.other/config.json`) is not served — the sole config
374    /// member path is `.memstead/config.json`.
375    #[test]
376    fn foreign_layout_config_is_not_read() {
377        let tmp = TempDir::new().unwrap();
378        let archive = build_archive(
379            tmp.path(),
380            "foreign",
381            &[
382                ("a.md", b"# a"),
383                (".other/config.json", b"{\"foreign\":true}"),
384            ],
385        );
386        let backend = ArchiveBackend::new(archive);
387        assert_eq!(
388            backend.read_mem_config().unwrap(),
389            None,
390            "a `.other/config.json` archive must not serve config"
391        );
392    }
393
394    #[test]
395    fn read_entity_returns_bytes_for_known_path() {
396        let tmp = TempDir::new().unwrap();
397        let archive = build_archive(
398            tmp.path(),
399            "pkg",
400            &[("a.md", b"# alpha"), ("b/c.md", b"# nested")],
401        );
402        let backend = ArchiveBackend::new(archive);
403        assert_eq!(
404            backend.read_entity(Path::new("a.md")).unwrap(),
405            Some(b"# alpha".to_vec())
406        );
407        assert_eq!(
408            backend.read_entity(Path::new("b/c.md")).unwrap(),
409            Some(b"# nested".to_vec())
410        );
411    }
412
413    #[test]
414    fn read_entity_refuses_oversized_entry() {
415        // Deflate bomb one byte past the per-entry uncompressed cap:
416        // the read stops at the cap and refuses with a typed error
417        // instead of decompressing the whole entry into memory.
418        let tmp = TempDir::new().unwrap();
419        let big = vec![b'a'; (ValidatorLimits::DEFAULT.max_uncompressed_entry + 1) as usize];
420        let archive = build_archive(tmp.path(), "bomb", &[("bomb.md", big.as_slice())]);
421        let backend = ArchiveBackend::new(archive);
422        let err = backend.read_entity(Path::new("bomb.md")).unwrap_err();
423        let msg = format!("{err}");
424        assert!(msg.contains("cap"), "error should name the cap: {msg}");
425    }
426
427    #[test]
428    fn read_entity_returns_none_for_unknown_path() {
429        let tmp = TempDir::new().unwrap();
430        let archive = build_archive(tmp.path(), "pkg", &[("a.md", b"# a")]);
431        let backend = ArchiveBackend::new(archive);
432        assert_eq!(backend.read_entity(Path::new("missing.md")).unwrap(), None);
433    }
434
435    #[test]
436    fn writes_return_sealed() {
437        let tmp = TempDir::new().unwrap();
438        let archive = build_archive(tmp.path(), "pkg", &[("a.md", b"# a")]);
439        let backend = ArchiveBackend::new(archive);
440        assert!(matches!(
441            backend.write_entity(Path::new("x.md"), b"x"),
442            Err(BackendError::Sealed)
443        ));
444        assert!(matches!(
445            backend.delete_entity(Path::new("x.md")),
446            Err(BackendError::Sealed)
447        ));
448        assert!(matches!(
449            backend.move_entity(Path::new("a.md"), Path::new("b.md")),
450            Err(BackendError::Sealed)
451        ));
452        assert!(matches!(
453            backend.commit("msg", &ctx_for_test()),
454            Err(BackendError::Sealed)
455        ));
456    }
457
458    #[test]
459    fn provenance_append_is_sealed_read_is_empty() {
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        let record = Provenance::new(
464            std::time::UNIX_EPOCH,
465            crate::provenance::ProvenanceKind::Create,
466            Some("v:e".into()),
467            crate::vcs::Actor::Unknown,
468            None,
469            None,
470        );
471        assert!(matches!(
472            backend.append_provenance(&record),
473            Err(BackendError::Sealed)
474        ));
475        assert!(backend.read_provenance(None).unwrap().is_empty());
476        // Cursor parameter is accepted but ignored — same empty result.
477        assert!(
478            backend
479                .read_provenance(Some("anything"))
480                .unwrap()
481                .is_empty()
482        );
483    }
484
485    #[test]
486    fn missing_archive_returns_typed_error_not_panic() {
487        let backend = ArchiveBackend::new(PathBuf::from("/nonexistent/missing.mem"));
488        match backend.list_entities() {
489            Err(BackendError::Other(msg)) => assert!(msg.contains("archive not found")),
490            other => panic!("expected archive-not-found Other error, got {other:?}"),
491        }
492    }
493
494    #[test]
495    fn from_bytes_lists_and_reads_same_as_path() {
496        // Same archive content via path vs in-memory bytes must
497        // produce identical list/read results — the source dispatch is
498        // transparent to the trait surface.
499        let tmp = TempDir::new().unwrap();
500        let archive = build_archive(
501            tmp.path(),
502            "pkg",
503            &[("a.md", b"# alpha"), ("dir/b.md", b"# nested")],
504        );
505        let bytes = std::fs::read(&archive).unwrap();
506        let from_path = ArchiveBackend::new(archive);
507        let from_bytes = ArchiveBackend::from_bytes(bytes);
508
509        let mut path_list: Vec<String> = from_path
510            .list_entities()
511            .unwrap()
512            .into_iter()
513            .map(|p| p.to_string_lossy().into_owned())
514            .collect();
515        let mut bytes_list: Vec<String> = from_bytes
516            .list_entities()
517            .unwrap()
518            .into_iter()
519            .map(|p| p.to_string_lossy().into_owned())
520            .collect();
521        path_list.sort();
522        bytes_list.sort();
523        assert_eq!(path_list, bytes_list);
524
525        for rel in &path_list {
526            let p_bytes = from_path.read_entity(Path::new(rel)).unwrap();
527            let b_bytes = from_bytes.read_entity(Path::new(rel)).unwrap();
528            assert_eq!(p_bytes, b_bytes, "mismatch reading {rel}");
529        }
530    }
531
532    #[test]
533    fn from_bytes_writes_return_sealed() {
534        let backend = ArchiveBackend::from_bytes(
535            build_archive(TempDir::new().unwrap().path(), "pkg", &[("a.md", b"# a")])
536                .as_os_str()
537                .to_string_lossy()
538                .as_bytes()
539                .to_vec(),
540        );
541        // Even with bogus bytes, the write methods short-circuit on
542        // Sealed before parsing the archive — covers the symmetry
543        // contract that byte-backed archives are also read-only.
544        assert!(matches!(
545            backend.write_entity(Path::new("x.md"), b"x"),
546            Err(BackendError::Sealed)
547        ));
548        assert!(matches!(
549            backend.commit("msg", &ctx_for_test()),
550            Err(BackendError::Sealed)
551        ));
552    }
553
554    #[test]
555    fn from_bytes_archive_path_is_none() {
556        let backend = ArchiveBackend::from_bytes(Vec::new());
557        assert!(backend.archive_path().is_none());
558    }
559
560    #[test]
561    fn list_then_read_for_every_listed_path() {
562        // The two read paths must agree on what's in the archive: every
563        // path returned by `list_entities` must be readable via
564        // `read_entity` and yield non-empty bytes.
565        let tmp = TempDir::new().unwrap();
566        let archive = build_archive(
567            tmp.path(),
568            "pkg",
569            &[
570                ("alpha.md", b"# a"),
571                ("dir/beta.md", b"# b"),
572                ("dir/sub/gamma.md", b"# g"),
573            ],
574        );
575        let backend = ArchiveBackend::new(archive);
576        for path in backend.list_entities().unwrap() {
577            let bytes = backend
578                .read_entity(&path)
579                .unwrap()
580                .unwrap_or_else(|| panic!("listed but unread: {path:?}"));
581            assert!(!bytes.is_empty(), "empty entry: {path:?}");
582        }
583    }
584}