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
244fn 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 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 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 #[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 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 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 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 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 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}