Skip to main content

memstead_base/entity/
source.rs

1//! Entity source abstraction — where markdown comes from.
2//!
3//! Two backend-agnostic shapes live here: a directory on disk (for
4//! live, writable workspaces) and a sealed `.mem` zip archive (for read-only
5//! attached mems). The git-tree shape lives in
6//! `memstead-git-branch::entity::git_tree_source`. All shapes feed the
7//! same parse pipeline via the helper [`crate::entity::loader::parse_entries`].
8
9use std::path::{Path, PathBuf};
10
11use super::loader::LoadError;
12use crate::validator::{BoundedZipRead, ValidatorLimits, read_zip_entry_bounded};
13
14/// A backend-agnostic source of markdown entities.
15pub enum EntitySource {
16    /// A directory on disk. Walked recursively; engine-internal
17    /// directories (`.git/`, `.memstead/`) are always skipped.
18    Directory { root: PathBuf },
19    /// A sealed `.mem` mem archive: a zip containing the same markdown
20    /// tree a `Directory` would. Read-only; loaded as a dep alongside
21    /// the primary mem. Zip-slip protected via `enclosed_name`.
22    ZipArchive(PathBuf),
23}
24
25/// One successfully-read markdown entry produced by an `EntitySource`.
26#[derive(Debug, Clone)]
27pub struct SourceEntry {
28    /// Path relative to the source root. Used as `Entity.file_path`.
29    /// Uses the platform's native separator for `Directory`; will use
30    /// POSIX separators for `ZipArchive` when that variant lands.
31    pub relative_path: String,
32    /// Source-specific path for error reporting — absolute on disk,
33    /// archive-relative for zips. Opaque to the parser; kept so callers
34    /// can surface a human-useful location in error messages without
35    /// re-joining paths.
36    pub source_path: PathBuf,
37    /// Raw file contents.
38    pub content: String,
39}
40
41/// A per-file read failure. Non-fatal: the walker continues past these
42/// and hands them back to the caller alongside the successful entries,
43/// preserving the loader's "collect errors, don't stop" behavior.
44#[derive(Debug)]
45pub struct SourceReadError {
46    pub source_path: PathBuf,
47    pub error: std::io::Error,
48}
49
50impl EntitySource {
51    /// Read every `.md` entry from this source. Returns the successful
52    /// entries and any per-file read errors.
53    ///
54    /// Source-level failures (missing directory, unreadable directory
55    /// listing) surface as `Err(LoadError::…)`. Individual file read
56    /// failures go into the `SourceReadError` bucket.
57    pub fn read_all(&self) -> Result<(Vec<SourceEntry>, Vec<SourceReadError>), LoadError> {
58        match self {
59            EntitySource::Directory { root } => read_directory(root),
60            EntitySource::ZipArchive(archive) => read_zip_archive(archive),
61        }
62    }
63}
64
65fn read_directory(root: &Path) -> Result<(Vec<SourceEntry>, Vec<SourceReadError>), LoadError> {
66    if !root.exists() {
67        return Err(LoadError::DirNotFound(root.display().to_string()));
68    }
69
70    let mut files = Vec::new();
71    find_markdown_files(root, &mut files)?;
72    files.sort();
73
74    let mut entries = Vec::with_capacity(files.len());
75    let mut errors = Vec::new();
76
77    for file in &files {
78        match std::fs::read_to_string(file) {
79            Ok(content) => {
80                let relative_path = file
81                    .strip_prefix(root)
82                    .unwrap_or(file)
83                    .to_string_lossy()
84                    .to_string();
85                entries.push(SourceEntry {
86                    relative_path,
87                    source_path: file.clone(),
88                    content,
89                });
90            }
91            Err(error) => errors.push(SourceReadError {
92                source_path: file.clone(),
93                error,
94            }),
95        }
96    }
97
98    Ok((entries, errors))
99}
100
101fn read_zip_archive(
102    archive_path: &Path,
103) -> Result<(Vec<SourceEntry>, Vec<SourceReadError>), LoadError> {
104    if !archive_path.is_file() {
105        return Err(LoadError::ArchiveNotFound(
106            archive_path.display().to_string(),
107        ));
108    }
109
110    let file = std::fs::File::open(archive_path)?;
111    let mut archive = zip::ZipArchive::new(file)?;
112
113    let limits = ValidatorLimits::DEFAULT;
114    if archive.len() as u32 > limits.max_file_count {
115        return Err(LoadError::InvalidArchive(format!(
116            "archive contains {} entries, exceeding the {}-entry cap",
117            archive.len(),
118            limits.max_file_count
119        )));
120    }
121
122    let mut entries = Vec::new();
123    let mut errors = Vec::new();
124    let mut uncompressed_total: u64 = 0;
125
126    for i in 0..archive.len() {
127        let mut entry = archive.by_index(i)?;
128        let raw_name = entry.name().to_string();
129
130        // Symlinks are rejected outright — a malicious archive could
131        // point one at /etc/passwd or any other absolute path. We never
132        // extract, but we also never want to surface their content.
133        if entry.is_symlink() {
134            return Err(LoadError::InvalidArchive(format!(
135                "entry '{raw_name}': symlinks are not allowed in sealed mem archives"
136            )));
137        }
138
139        // `enclosed_name` is the zip crate's blessed zip-slip guard:
140        // returns `Some(path)` only if the entry name is safe (relative,
141        // no `..` escape, no absolute prefix, no drive letters). `None`
142        // means the archive is trying to write outside its own root.
143        let safe_path = match entry.enclosed_name() {
144            Some(p) => p,
145            None => {
146                return Err(LoadError::InvalidArchive(format!(
147                    "entry '{raw_name}': path escapes archive root \
148                     (absolute, '..'-components, or otherwise unsafe)"
149                )));
150            }
151        };
152
153        if entry.is_dir() {
154            continue;
155        }
156
157        let relative_path = safe_path.to_string_lossy().to_string();
158        if !relative_path.ends_with(".md") {
159            // Non-markdown entries (including the meta-dir config) are
160            // silently skipped here. The entity source only yields
161            // entity content; mem metadata is read by
162            // `mem_cache::read_published_config` on a separate pass.
163            continue;
164        }
165
166        // Bounded read: decompression is never sized by the entry's
167        // declared header, and a bomb refuses with a typed error —
168        // the same caps the ingress validator enforces.
169        let bytes = match read_zip_entry_bounded(&mut entry, limits.max_uncompressed_entry)? {
170            BoundedZipRead::Within(bytes) => bytes,
171            BoundedZipRead::ExceedsCap => {
172                return Err(LoadError::InvalidArchive(format!(
173                    "entry '{relative_path}' exceeds the {}-byte uncompressed cap",
174                    limits.max_uncompressed_entry
175                )));
176            }
177        };
178        uncompressed_total = uncompressed_total.saturating_add(bytes.len() as u64);
179        if uncompressed_total > limits.max_uncompressed_archive {
180            return Err(LoadError::InvalidArchive(format!(
181                "archive exceeds the {}-byte total uncompressed cap",
182                limits.max_uncompressed_archive
183            )));
184        }
185        match String::from_utf8(bytes) {
186            Ok(content) => entries.push(SourceEntry {
187                relative_path: relative_path.clone(),
188                source_path: PathBuf::from(&relative_path),
189                content,
190            }),
191            Err(error) => errors.push(SourceReadError {
192                source_path: PathBuf::from(&relative_path),
193                error: std::io::Error::new(std::io::ErrorKind::InvalidData, error),
194            }),
195        }
196    }
197
198    // Sort for deterministic ordering — the zip crate yields entries in
199    // archive order, which our deterministic-export contract already
200    // sorts by path, but external archives may not.
201    entries.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
202
203    Ok((entries, errors))
204}
205
206/// Recursively find all .md files under a directory.
207///
208/// Skip rules:
209/// - `.git/` — external git metadata, never entity territory.
210/// - `.memstead/` — engine-internal (config, schemas cache). Always
211///   hidden at every depth.
212/// - `README.md` — repository documentation, never an entity. A
213///   folder mem living visibly in a repo tree carries a human-facing
214///   README beside its entity files (quickstart already tolerates
215///   README-grade files at init; the load side matches). Entities are
216///   slug-named after their titles, so no legitimate entity file
217///   carries this name.
218///
219/// All other directories (including unrelated dot-prefixed dirs like
220/// `.obsidian/`, `.idea/`) are walked.
221fn find_markdown_files(dir: &Path, files: &mut Vec<PathBuf>) -> Result<(), LoadError> {
222    let entries = std::fs::read_dir(dir)?;
223
224    for entry in entries {
225        let entry = entry?;
226        let path = entry.path();
227        let file_name = entry.file_name();
228        let name = file_name.to_string_lossy();
229
230        if path.is_dir() {
231            if name.as_ref() == ".git" || name.as_ref() == crate::mem::MEM_META_DIR {
232                continue;
233            }
234            find_markdown_files(&path, files)?;
235        } else if name.ends_with(".md") && name.as_ref() != "README.md" {
236            files.push(path);
237        }
238    }
239
240    Ok(())
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    use std::fs;
247    use tempfile::TempDir;
248
249    #[test]
250    fn directory_reads_markdown_in_sorted_order() {
251        let dir = TempDir::new().unwrap();
252        fs::write(dir.path().join("b.md"), "b").unwrap();
253        fs::write(dir.path().join("a.md"), "a").unwrap();
254
255        let (entries, errors) = EntitySource::Directory {
256            root: dir.path().to_path_buf(),
257        }
258        .read_all()
259        .unwrap();
260        assert!(errors.is_empty());
261        let paths: Vec<_> = entries.iter().map(|e| e.relative_path.as_str()).collect();
262        assert_eq!(paths, vec!["a.md", "b.md"]);
263    }
264
265    #[test]
266    fn directory_skips_engine_internal_dirs_and_non_md() {
267        // `.git/` and `.memstead/` are always skipped (engine-internal).
268        // Other dot-prefixed dirs (e.g. `.obsidian/`) walk normally.
269        let dir = TempDir::new().unwrap();
270        fs::write(dir.path().join("keep.md"), "k").unwrap();
271        fs::write(dir.path().join("ignore.txt"), "i").unwrap();
272        // README.md is repo documentation beside the entity files —
273        // never loaded as an entity.
274        fs::write(dir.path().join("README.md"), "docs").unwrap();
275        fs::create_dir_all(dir.path().join(".git")).unwrap();
276        fs::write(dir.path().join(".git/secret.md"), "s").unwrap();
277        fs::create_dir_all(dir.path().join(".memstead")).unwrap();
278        fs::write(dir.path().join(".memstead/note.md"), "n").unwrap();
279        fs::create_dir_all(dir.path().join(".obsidian")).unwrap();
280        fs::write(dir.path().join(".obsidian/vis.md"), "v").unwrap();
281
282        let (entries, _) = EntitySource::Directory {
283            root: dir.path().to_path_buf(),
284        }
285        .read_all()
286        .unwrap();
287        let paths: Vec<_> = entries.iter().map(|e| e.relative_path.as_str()).collect();
288        assert!(paths.contains(&"keep.md"), "keep.md must load: {paths:?}");
289        assert!(
290            paths.iter().any(|p| p.ends_with("vis.md")),
291            ".obsidian/vis.md must load: {paths:?}"
292        );
293        assert!(
294            !paths.iter().any(|p| p.contains(".git")),
295            ".git/* must be skipped: {paths:?}"
296        );
297        assert!(
298            !paths.iter().any(|p| p.contains(".memstead")),
299            ".memstead/* must be skipped: {paths:?}"
300        );
301    }
302
303    #[test]
304    fn directory_missing_root_returns_error() {
305        let err = EntitySource::Directory {
306            root: PathBuf::from("/nonexistent/path/xyz"),
307        }
308        .read_all()
309        .unwrap_err();
310        assert!(matches!(err, LoadError::DirNotFound(_)));
311    }
312
313    // --- zip archive ---
314
315    use std::io::Write;
316    use zip::CompressionMethod;
317    use zip::write::SimpleFileOptions;
318
319    /// Build a minimal zip with the given `(name, content)` entries.
320    /// Caller controls entry names exactly — used to test zip-slip.
321    fn write_zip(path: &Path, entries: &[(&str, &str)]) {
322        let file = fs::File::create(path).unwrap();
323        let mut zip = zip::ZipWriter::new(file);
324        let opts = SimpleFileOptions::default().compression_method(CompressionMethod::Stored);
325        for (name, content) in entries {
326            zip.start_file(*name, opts).unwrap();
327            zip.write_all(content.as_bytes()).unwrap();
328        }
329        zip.finish().unwrap();
330    }
331
332    #[test]
333    fn zip_archive_reads_markdown_in_sorted_order() {
334        let dir = TempDir::new().unwrap();
335        let archive = dir.path().join("pkg.mem");
336        write_zip(
337            &archive,
338            &[
339                ("b.md", "b"),
340                ("a.md", "a"),
341                ("meta.json", "{\"name\":\"pkg\"}"),
342                (".memstead/config.json", "{}"),
343                ("readme.txt", "ignored"),
344                ("nested/c.md", "c"),
345            ],
346        );
347
348        let (entries, errors) = EntitySource::ZipArchive(archive).read_all().unwrap();
349        assert!(errors.is_empty());
350        let paths: Vec<_> = entries.iter().map(|e| e.relative_path.as_str()).collect();
351        assert_eq!(paths, vec!["a.md", "b.md", "nested/c.md"]);
352        let contents: Vec<_> = entries.iter().map(|e| e.content.as_str()).collect();
353        assert_eq!(contents, vec!["a", "b", "c"]);
354    }
355
356    #[test]
357    fn zip_archive_missing_file_returns_error() {
358        let err = EntitySource::ZipArchive(PathBuf::from("/nonexistent/pkg.mem"))
359            .read_all()
360            .unwrap_err();
361        assert!(matches!(err, LoadError::ArchiveNotFound(_)));
362    }
363
364    #[test]
365    fn zip_archive_corrupt_file_returns_zip_error() {
366        let dir = TempDir::new().unwrap();
367        let archive = dir.path().join("bad.mem");
368        fs::write(&archive, b"not a zip file at all, just bytes").unwrap();
369
370        let err = EntitySource::ZipArchive(archive).read_all().unwrap_err();
371        assert!(
372            matches!(err, LoadError::Zip(_)),
373            "corrupt archive should surface as LoadError::Zip, got {err:?}"
374        );
375    }
376
377    #[test]
378    fn zip_archive_rejects_parent_dir_escape() {
379        let dir = TempDir::new().unwrap();
380        let archive = dir.path().join("evil.mem");
381        write_zip(&archive, &[("../escape.md", "bad")]);
382
383        let err = EntitySource::ZipArchive(archive).read_all().unwrap_err();
384        let msg = format!("{err}");
385        assert!(matches!(err, LoadError::InvalidArchive(_)));
386        assert!(
387            msg.contains("escape") || msg.contains("..") || msg.contains("unsafe"),
388            "zip-slip error should explain the rejection: {msg}"
389        );
390    }
391
392    #[test]
393    fn zip_archive_rejects_nested_parent_dir_escape() {
394        // `subdir/../../outside.md` has `..`-components that normalize to
395        // "one level above the archive root". `enclosed_name` must reject
396        // this — a single `..` check that only looks at the first path
397        // segment would miss it.
398        let dir = TempDir::new().unwrap();
399        let archive = dir.path().join("evil.mem");
400        write_zip(&archive, &[("subdir/../../outside.md", "bad")]);
401
402        let err = EntitySource::ZipArchive(archive).read_all().unwrap_err();
403        assert!(matches!(err, LoadError::InvalidArchive(_)));
404    }
405
406    #[test]
407    fn zip_archive_rejects_oversized_entry() {
408        // A deflate bomb: highly compressible content one byte past the
409        // per-entry uncompressed cap. Must refuse with a typed error —
410        // and the read must stop at the cap, not decompress it all.
411        let dir = TempDir::new().unwrap();
412        let archive = dir.path().join("bomb.mem");
413        let big = "a".repeat((ValidatorLimits::DEFAULT.max_uncompressed_entry + 1) as usize);
414        let file = fs::File::create(&archive).unwrap();
415        let mut zip = zip::ZipWriter::new(file);
416        let opts = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
417        zip.start_file("bomb.md", opts).unwrap();
418        zip.write_all(big.as_bytes()).unwrap();
419        zip.finish().unwrap();
420
421        let err = EntitySource::ZipArchive(archive).read_all().unwrap_err();
422        let msg = format!("{err}");
423        assert!(matches!(err, LoadError::InvalidArchive(_)), "got {err:?}");
424        assert!(msg.contains("cap"), "error should name the cap: {msg}");
425    }
426
427    // Note on absolute entry paths: the standard `zip::ZipWriter::start_file`
428    // normalizes leading separators away, so crafting a `/etc/evil.md` entry
429    // via the writer API is impossible — the writer stores it as
430    // `etc/evil.md` (relative). `enclosed_name` covers the hand-crafted
431    // malicious-archive case by rejecting anything that doesn't resolve to
432    // a relative path, including Windows drive letters. The two `..`-based
433    // tests above verify the guard is actually wired up; we trust
434    // `enclosed_name` for the rest.
435
436    // Git-tree adapter parity tests live alongside the GitTreeSource
437    // implementation in `memstead-git-branch::entity::git_tree_source`.
438}