1use std::path::{Path, PathBuf};
10
11use super::loader::LoadError;
12use crate::validator::{BoundedZipRead, ValidatorLimits, read_zip_entry_bounded};
13
14pub enum EntitySource {
16 Directory { root: PathBuf },
19 ZipArchive(PathBuf),
23}
24
25#[derive(Debug, Clone)]
27pub struct SourceEntry {
28 pub relative_path: String,
32 pub source_path: PathBuf,
37 pub content: String,
39}
40
41#[derive(Debug)]
45pub struct SourceReadError {
46 pub source_path: PathBuf,
47 pub error: std::io::Error,
48}
49
50impl EntitySource {
51 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 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 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 continue;
164 }
165
166 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 entries.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
202
203 Ok((entries, errors))
204}
205
206fn 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 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 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 use std::io::Write;
316 use zip::CompressionMethod;
317 use zip::write::SimpleFileOptions;
318
319 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 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 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 }