1use 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
46enum ArchiveSource {
51 Path(PathBuf),
52 Bytes(Vec<u8>),
53}
54
55pub struct ArchiveBackend {
59 source: ArchiveSource,
60}
61
62impl ArchiveBackend {
63 pub fn new(archive_path: PathBuf) -> Self {
68 Self {
69 source: ArchiveSource::Path(archive_path),
70 }
71 }
72
73 pub fn from_bytes(bytes: Vec<u8>) -> Self {
79 Self {
80 source: ArchiveSource::Bytes(bytes),
81 }
82 }
83
84 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 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
123trait ReadSeek: std::io::Read + Seek {}
126impl<T: std::io::Read + Seek + ?Sized> ReadSeek for T {}
127
128impl crate::backend::MemBackend for ArchiveBackend {
129 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 Ok(Vec::new())
186 }
187
188 fn read_mem_config(&self) -> Result<Option<Vec<u8>>, BackendError> {
189 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 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 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 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
284fn 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 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 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 #[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 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 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 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 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 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}