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 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 Ok(Vec::new())
177 }
178
179 fn read_mem_config(&self) -> Result<Option<Vec<u8>>, BackendError> {
180 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 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 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 fn read_anchors_sidecar(&self) -> Result<Option<Vec<u8>>, BackendError> {
244 if let ArchiveSource::Path(p) = &self.source
250 && !p.is_file()
251 {
252 return Ok(None);
253 }
254 self.with_archive_reader(|reader| {
255 let mut archive = zip::ZipArchive::new(reader)
256 .map_err(|e| BackendError::Other(format!("zip open: {e}")))?;
257 let anchors_name = memstead_schema::ARCHIVE_ANCHORS_PATH;
258 if archive.index_for_name(anchors_name).is_none() {
259 return Ok(None);
260 }
261 let mut entry = archive
262 .by_name(anchors_name)
263 .map_err(|e| BackendError::Other(format!("zip lookup: {e}")))?;
264 let cap = ValidatorLimits::DEFAULT.max_uncompressed_entry;
265 match read_zip_entry_bounded(&mut entry, cap).map_err(BackendError::Io)? {
266 BoundedZipRead::Within(bytes) => Ok(Some(bytes)),
267 BoundedZipRead::ExceedsCap => Err(BackendError::Other(format!(
268 "archive anchors '{anchors_name}' exceeds the {cap}-byte cap"
269 ))),
270 }
271 })
272 }
273}
274
275fn for_each_md_entry<R, F>(reader: &mut R, mut visit: F) -> Result<(), BackendError>
280where
281 R: std::io::Read + Seek + ?Sized,
282 F: FnMut(&str, &[u8]) -> Result<(), BackendError>,
283{
284 let mut archive = zip::ZipArchive::new(reader)
285 .map_err(|e| BackendError::Other(format!("open archive: {e}")))?;
286 let limits = ValidatorLimits::DEFAULT;
287 if archive.len() as u32 > limits.max_file_count {
288 return Err(BackendError::Other(format!(
289 "archive contains {} entries, exceeding the {}-entry cap",
290 archive.len(),
291 limits.max_file_count
292 )));
293 }
294 let mut uncompressed_total: u64 = 0;
295 for i in 0..archive.len() {
296 let mut entry = archive
297 .by_index(i)
298 .map_err(|e| BackendError::Other(format!("archive entry {i}: {e}")))?;
299 let raw_name = entry.name().to_string();
300 if entry.is_symlink() {
301 return Err(BackendError::Other(format!(
302 "entry '{raw_name}': symlinks are not allowed in sealed mem archives"
303 )));
304 }
305 let safe_path = match entry.enclosed_name() {
306 Some(p) => p,
307 None => {
308 return Err(BackendError::Other(format!(
309 "entry '{raw_name}': path escapes archive root \
310 (absolute, '..'-components, or otherwise unsafe)"
311 )));
312 }
313 };
314 if entry.is_dir() {
315 continue;
316 }
317 let relative_path = safe_path.to_string_lossy().replace('\\', "/");
318 if !relative_path.ends_with(".md") {
319 continue;
320 }
321 if relative_path.starts_with(".memstead/") {
326 continue;
327 }
328 let bytes = match read_zip_entry_bounded(&mut entry, limits.max_uncompressed_entry)
329 .map_err(BackendError::Io)?
330 {
331 BoundedZipRead::Within(bytes) => bytes,
332 BoundedZipRead::ExceedsCap => {
333 return Err(BackendError::Other(format!(
334 "entry '{relative_path}' exceeds the {}-byte uncompressed cap",
335 limits.max_uncompressed_entry
336 )));
337 }
338 };
339 uncompressed_total = uncompressed_total.saturating_add(bytes.len() as u64);
340 if uncompressed_total > limits.max_uncompressed_archive {
341 return Err(BackendError::Other(format!(
342 "archive exceeds the {}-byte total uncompressed cap",
343 limits.max_uncompressed_archive
344 )));
345 }
346 visit(&relative_path, &bytes)?;
347 }
348 Ok(())
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354 use crate::backend::MemBackend;
355 use std::io::Write as _;
356 use tempfile::TempDir;
357 use zip::write::SimpleFileOptions;
358
359 fn build_archive(tmp: &Path, name: &str, entries: &[(&str, &[u8])]) -> PathBuf {
362 let path = tmp.join(format!("{name}.mem"));
363 let file = std::fs::File::create(&path).unwrap();
364 let mut writer = zip::ZipWriter::new(file);
365 let opts: SimpleFileOptions = SimpleFileOptions::default();
366 for (rel, bytes) in entries {
367 writer.start_file(*rel, opts).unwrap();
368 writer.write_all(bytes).unwrap();
369 }
370 writer.finish().unwrap();
371 path
372 }
373
374 fn ctx_for_test<'a>() -> CommitContext<'a> {
375 CommitContext::internal()
376 }
377
378 #[test]
379 fn list_returns_only_md_outside_memstead_namespace() {
380 let tmp = TempDir::new().unwrap();
381 let archive = build_archive(
382 tmp.path(),
383 "pkg",
384 &[
385 ("a.md", b"# a"),
386 ("nested/b.md", b"# b"),
387 ("notes.json", b"{}"),
388 (".memstead/config.json", b"{}"),
389 (".memstead/notes.md", b"# skip me"),
390 ],
391 );
392 let backend = ArchiveBackend::new(archive);
393 let mut paths: Vec<String> = backend
394 .list_entities()
395 .unwrap()
396 .into_iter()
397 .map(|p| p.to_string_lossy().into_owned())
398 .collect();
399 paths.sort();
400 assert_eq!(paths, vec!["a.md".to_string(), "nested/b.md".to_string()]);
401 }
402
403 #[test]
407 fn foreign_layout_config_is_not_read() {
408 let tmp = TempDir::new().unwrap();
409 let archive = build_archive(
410 tmp.path(),
411 "foreign",
412 &[
413 ("a.md", b"# a"),
414 (".other/config.json", b"{\"foreign\":true}"),
415 ],
416 );
417 let backend = ArchiveBackend::new(archive);
418 assert_eq!(
419 backend.read_mem_config().unwrap(),
420 None,
421 "a `.other/config.json` archive must not serve config"
422 );
423 }
424
425 #[test]
426 fn read_entity_returns_bytes_for_known_path() {
427 let tmp = TempDir::new().unwrap();
428 let archive = build_archive(
429 tmp.path(),
430 "pkg",
431 &[("a.md", b"# alpha"), ("b/c.md", b"# nested")],
432 );
433 let backend = ArchiveBackend::new(archive);
434 assert_eq!(
435 backend.read_entity(Path::new("a.md")).unwrap(),
436 Some(b"# alpha".to_vec())
437 );
438 assert_eq!(
439 backend.read_entity(Path::new("b/c.md")).unwrap(),
440 Some(b"# nested".to_vec())
441 );
442 }
443
444 #[test]
445 fn read_entity_refuses_oversized_entry() {
446 let tmp = TempDir::new().unwrap();
450 let big = vec![b'a'; (ValidatorLimits::DEFAULT.max_uncompressed_entry + 1) as usize];
451 let archive = build_archive(tmp.path(), "bomb", &[("bomb.md", big.as_slice())]);
452 let backend = ArchiveBackend::new(archive);
453 let err = backend.read_entity(Path::new("bomb.md")).unwrap_err();
454 let msg = format!("{err}");
455 assert!(msg.contains("cap"), "error should name the cap: {msg}");
456 }
457
458 #[test]
459 fn read_entity_returns_none_for_unknown_path() {
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 assert_eq!(backend.read_entity(Path::new("missing.md")).unwrap(), None);
464 }
465
466 #[test]
467 fn writes_return_sealed() {
468 let tmp = TempDir::new().unwrap();
469 let archive = build_archive(tmp.path(), "pkg", &[("a.md", b"# a")]);
470 let backend = ArchiveBackend::new(archive);
471 assert!(matches!(
472 backend.write_entity(Path::new("x.md"), b"x"),
473 Err(BackendError::Sealed)
474 ));
475 assert!(matches!(
476 backend.delete_entity(Path::new("x.md")),
477 Err(BackendError::Sealed)
478 ));
479 assert!(matches!(
480 backend.move_entity(Path::new("a.md"), Path::new("b.md")),
481 Err(BackendError::Sealed)
482 ));
483 assert!(matches!(
484 backend.commit("msg", &ctx_for_test()),
485 Err(BackendError::Sealed)
486 ));
487 }
488
489 #[test]
490 fn provenance_append_is_sealed_read_is_empty() {
491 let tmp = TempDir::new().unwrap();
492 let archive = build_archive(tmp.path(), "pkg", &[("a.md", b"# a")]);
493 let backend = ArchiveBackend::new(archive);
494 let record = Provenance::new(
495 std::time::UNIX_EPOCH,
496 crate::provenance::ProvenanceKind::Create,
497 Some("v:e".into()),
498 crate::vcs::Actor::Unknown,
499 None,
500 None,
501 );
502 assert!(matches!(
503 backend.append_provenance(&record),
504 Err(BackendError::Sealed)
505 ));
506 assert!(backend.read_provenance(None).unwrap().is_empty());
507 assert!(
509 backend
510 .read_provenance(Some("anything"))
511 .unwrap()
512 .is_empty()
513 );
514 }
515
516 #[test]
517 fn missing_archive_returns_typed_error_not_panic() {
518 let backend = ArchiveBackend::new(PathBuf::from("/nonexistent/missing.mem"));
519 match backend.list_entities() {
520 Err(BackendError::Other(msg)) => assert!(msg.contains("archive not found")),
521 other => panic!("expected archive-not-found Other error, got {other:?}"),
522 }
523 }
524
525 #[test]
526 fn from_bytes_lists_and_reads_same_as_path() {
527 let tmp = TempDir::new().unwrap();
531 let archive = build_archive(
532 tmp.path(),
533 "pkg",
534 &[("a.md", b"# alpha"), ("dir/b.md", b"# nested")],
535 );
536 let bytes = std::fs::read(&archive).unwrap();
537 let from_path = ArchiveBackend::new(archive);
538 let from_bytes = ArchiveBackend::from_bytes(bytes);
539
540 let mut path_list: Vec<String> = from_path
541 .list_entities()
542 .unwrap()
543 .into_iter()
544 .map(|p| p.to_string_lossy().into_owned())
545 .collect();
546 let mut bytes_list: Vec<String> = from_bytes
547 .list_entities()
548 .unwrap()
549 .into_iter()
550 .map(|p| p.to_string_lossy().into_owned())
551 .collect();
552 path_list.sort();
553 bytes_list.sort();
554 assert_eq!(path_list, bytes_list);
555
556 for rel in &path_list {
557 let p_bytes = from_path.read_entity(Path::new(rel)).unwrap();
558 let b_bytes = from_bytes.read_entity(Path::new(rel)).unwrap();
559 assert_eq!(p_bytes, b_bytes, "mismatch reading {rel}");
560 }
561 }
562
563 #[test]
564 fn from_bytes_writes_return_sealed() {
565 let backend = ArchiveBackend::from_bytes(
566 build_archive(TempDir::new().unwrap().path(), "pkg", &[("a.md", b"# a")])
567 .as_os_str()
568 .to_string_lossy()
569 .as_bytes()
570 .to_vec(),
571 );
572 assert!(matches!(
576 backend.write_entity(Path::new("x.md"), b"x"),
577 Err(BackendError::Sealed)
578 ));
579 assert!(matches!(
580 backend.commit("msg", &ctx_for_test()),
581 Err(BackendError::Sealed)
582 ));
583 }
584
585 #[test]
586 fn from_bytes_archive_path_is_none() {
587 let backend = ArchiveBackend::from_bytes(Vec::new());
588 assert!(backend.archive_path().is_none());
589 }
590
591 #[test]
592 fn list_then_read_for_every_listed_path() {
593 let tmp = TempDir::new().unwrap();
597 let archive = build_archive(
598 tmp.path(),
599 "pkg",
600 &[
601 ("alpha.md", b"# a"),
602 ("dir/beta.md", b"# b"),
603 ("dir/sub/gamma.md", b"# g"),
604 ],
605 );
606 let backend = ArchiveBackend::new(archive);
607 for path in backend.list_entities().unwrap() {
608 let bytes = backend
609 .read_entity(&path)
610 .unwrap()
611 .unwrap_or_else(|| panic!("listed but unread: {path:?}"));
612 assert!(!bytes.is_empty(), "empty entry: {path:?}");
613 }
614 }
615}