1use std::collections::{BTreeMap, BTreeSet};
37use std::fmt::Write as _;
38use std::io::Read as _;
39use std::path::{Component, Path, PathBuf};
40
41use serde::{Deserialize, Serialize};
42use serde_norway::Value;
43use sha2::{Digest, Sha256};
44
45use crate::parser;
46use crate::store::Store;
47
48pub const MANIFEST_FILE: &str = "assets.jsonl";
50
51pub const SUPERSEDES_ASSET_KEY: &str = "supersedes-asset";
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub struct AssetRecord {
63 pub path: String,
66 pub sha256: String,
69 pub bytes: u64,
71 pub media_type: String,
73 pub wrappers: Vec<String>,
76 pub required: bool,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct Declaration {
84 pub path: String,
86 pub required: bool,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct AssetSupersession {
94 pub original: String,
96 pub replacement: String,
98}
99
100#[derive(Debug, Serialize)]
106pub struct ScanReport {
107 pub manifest: String,
108 pub cataloged: usize,
109 pub hashed: usize,
110 pub preserved: usize,
111 pub bytes: u64,
112 pub wrote: bool,
113 pub dry_run: bool,
114 pub warnings: Vec<String>,
115 pub untracked: Vec<String>,
116}
117
118#[derive(Debug, Serialize)]
122pub struct RefreshReport {
123 pub manifest: String,
124 pub path: String,
125 pub sha256: String,
126 pub bytes: u64,
127 pub wrappers: Vec<String>,
128 pub required: bool,
129 pub superseded_assets: Vec<String>,
131 pub wrote: bool,
132}
133
134#[derive(Debug, Serialize)]
138pub struct RefreshWrapperReport {
139 pub manifest: String,
140 pub wrapper: String,
141 pub cataloged: usize,
142 pub added: usize,
143 pub removed: usize,
144 pub hashed: usize,
145 pub preserved: usize,
146 pub bytes: u64,
147 pub wrote: bool,
148}
149
150#[derive(Debug, Serialize)]
152pub struct AssetState {
153 pub path: String,
154 pub sha256: String,
155 pub bytes: u64,
156 pub required: bool,
157 pub state: String,
159}
160
161#[derive(Debug, Serialize)]
163pub struct StatusReport {
164 pub total: usize,
165 pub present: usize,
166 pub missing: usize,
167 pub required_missing: usize,
168 pub optional_missing: usize,
169 pub bytes_total: u64,
170 pub bytes_missing: u64,
171 pub assets: Vec<AssetState>,
172}
173
174#[derive(Debug, Serialize)]
176pub struct VerifyReport {
177 pub mode: String,
178 pub checked: usize,
179 pub ok: usize,
180 pub missing: Vec<String>,
181 pub corrupt: Vec<String>,
182 pub complete: bool,
183}
184
185pub fn read_manifest(store: &Store) -> crate::Result<Vec<AssetRecord>> {
194 let text = match store
195 .read_text_bounded(Path::new(MANIFEST_FILE), crate::parser::MAX_DBMD_FILE_BYTES)
196 {
197 Ok(text) => text,
198 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
199 Err(error) => return Err(error.into()),
200 };
201 let mut by_path: BTreeMap<String, AssetRecord> = BTreeMap::new();
202 for (i, line) in text.lines().enumerate() {
203 if line.trim().is_empty() {
204 continue;
205 }
206 let rec: AssetRecord = serde_json::from_str(line).map_err(|e| {
207 std::io::Error::new(
208 std::io::ErrorKind::InvalidData,
209 format!("{MANIFEST_FILE} line {}: {e}", i + 1),
210 )
211 })?;
212 by_path.insert(rec.path.clone(), rec);
213 }
214 Ok(by_path.into_values().collect())
215}
216
217fn serialize_manifest(records: &[AssetRecord]) -> String {
224 if records.is_empty() {
225 return String::new();
226 }
227 let mut sorted = records.to_vec();
228 sorted.sort_by(|a, b| a.path.cmp(&b.path));
229 let mut out = String::new();
230 for rec in &sorted {
231 let line = serde_json::to_string(rec).expect("AssetRecord serializes");
232 out.push_str(&line);
233 out.push('\n');
234 }
235 out
236}
237
238pub fn write_manifest(store: &Store, records: &[AssetRecord]) -> crate::Result<()> {
242 let abs = Path::new(MANIFEST_FILE);
243 let out = serialize_manifest(records);
244 if out.is_empty() {
245 match store.remove_file(abs) {
246 Ok(()) => {}
247 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
248 Err(error) => return Err(error.into()),
249 }
250 return Ok(());
251 }
252 store.write_atomic(abs, out.as_bytes())?;
253 Ok(())
254}
255
256pub fn scan(store: &Store, dry_run: bool, untracked: bool) -> crate::Result<ScanReport> {
269 let existing_by_path: BTreeMap<String, AssetRecord> = read_manifest(store)
273 .unwrap_or_default()
274 .into_iter()
275 .map(|r| (r.path.clone(), r))
276 .collect();
277
278 let mut wrappers_by_path: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
280 let mut required_by_path: BTreeMap<String, bool> = BTreeMap::new();
281 let mut declared_paths: BTreeSet<String> = BTreeSet::new();
282 let mut supersessions: BTreeMap<String, (String, String)> = BTreeMap::new();
283 let mut ambiguous_supersessions: BTreeSet<String> = BTreeSet::new();
284 let mut warnings: Vec<String> = Vec::new();
285
286 for rel in store.walk()? {
287 let text = match store.read_text_bounded(&rel, parser::MAX_DBMD_FILE_BYTES) {
288 Ok(text) => text,
289 Err(_) => continue,
290 };
291 let parsed = match parser::split_frontmatter(&text, &rel) {
292 Ok(parsed) => parsed,
293 Err(_) => continue,
294 };
295 let fm = match parser::Frontmatter::parse(&parsed.frontmatter_yaml, &rel) {
296 Ok(frontmatter) => frontmatter,
297 Err(_) => continue, };
299 let wrapper = rel_to_string(&rel);
300 for decl in declared_assets(&fm) {
301 let norm = match normalize_asset_path(&decl.path) {
302 Ok(n) => n,
303 Err(e) => {
304 warnings.push(format!("{wrapper}: {e}"));
305 continue;
306 }
307 };
308 wrappers_by_path
309 .entry(norm.clone())
310 .or_default()
311 .insert(wrapper.clone());
312 let req = required_by_path.entry(norm.clone()).or_insert(false);
313 *req = *req || decl.required;
314 declared_paths.insert(norm);
315 }
316 match asset_supersession(&fm) {
317 Ok(Some(supersession)) => {
318 if let Some((prior, prior_wrapper)) = supersessions.get(&supersession.original) {
319 if prior != &supersession.replacement {
320 ambiguous_supersessions.insert(supersession.original.clone());
321 warnings.push(format!(
322 "{wrapper}: `{SUPERSEDES_ASSET_KEY}` conflicts with {prior_wrapper} for {}",
323 supersession.original
324 ));
325 }
326 } else {
327 supersessions.insert(
328 supersession.original,
329 (supersession.replacement, wrapper.clone()),
330 );
331 }
332 }
333 Ok(None) => {}
334 Err(error) => warnings.push(format!("{wrapper}: {error}")),
335 }
336 }
337
338 let cyclic_supersessions = supersession_cycle_members(&supersessions);
339 for original in &cyclic_supersessions {
340 if let Some((_, wrapper)) = supersessions.get(original) {
341 warnings.push(format!(
342 "{wrapper}: `{SUPERSEDES_ASSET_KEY}` participates in a replacement cycle at {original}"
343 ));
344 }
345 }
346 for (original, (replacement, wrapper)) in supersessions {
347 if ambiguous_supersessions.contains(&original) {
348 continue;
349 }
350 if cyclic_supersessions.contains(&original) {
351 continue;
352 }
353 if !wrappers_by_path.contains_key(&replacement) {
354 warnings.push(format!(
355 "{wrapper}: replacement asset `{replacement}` is not declared"
356 ));
357 continue;
358 }
359 if !wrappers_by_path.contains_key(&original) && !existing_by_path.contains_key(&original) {
360 warnings.push(format!(
361 "{wrapper}: superseded asset `{original}` is neither declared nor cataloged"
362 ));
363 continue;
364 }
365 wrappers_by_path
366 .entry(original.clone())
367 .or_default()
368 .insert(wrapper);
369 required_by_path.insert(original.clone(), false);
370 declared_paths.insert(original);
371 }
372
373 let mut records: Vec<AssetRecord> = Vec::new();
375 let mut hashed = 0usize;
376 let mut preserved = 0usize;
377 for (path, wrappers) in &wrappers_by_path {
378 let required = *required_by_path.get(path).unwrap_or(&true);
379 let wrappers: Vec<String> = wrappers.iter().cloned().collect();
380
381 let abs = match store.capability_relative(Path::new(path)) {
383 Ok(p) => p,
384 Err(_) => {
385 warnings.push(format!("{path}: escapes the store root; skipped"));
386 continue;
387 }
388 };
389
390 match store.open_regular(abs) {
391 Ok(file) => {
392 let (sha256, bytes) = sha256_file(file)?;
393 records.push(AssetRecord {
394 path: path.clone(),
395 sha256,
396 bytes,
397 media_type: media_type_for(path),
398 wrappers,
399 required,
400 });
401 hashed += 1;
402 }
403 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
404 if let Some(prev) = existing_by_path.get(path) {
405 records.push(AssetRecord {
408 path: path.clone(),
409 sha256: prev.sha256.clone(),
410 bytes: prev.bytes,
411 media_type: media_type_for(path),
412 wrappers,
413 required,
414 });
415 preserved += 1;
416 } else {
417 warnings.push(format!(
418 "{path}: declared but absent and never cataloged; cannot hash (skipped)"
419 ));
420 }
421 }
422 Err(error) => {
423 warnings.push(format!(
424 "{path}: is not a readable regular in-store file: {error}"
425 ));
426 }
427 }
428 }
429 records.sort_by(|a, b| a.path.cmp(&b.path));
430
431 let bytes: u64 = records.iter().fold(0u64, |a, r| a.saturating_add(r.bytes));
434 let cataloged = records.len();
435
436 let untracked_list = if untracked {
437 find_untracked(store, &declared_paths)?
438 } else {
439 Vec::new()
440 };
441
442 let mut wrote = false;
452 if !dry_run {
453 let canonical = serialize_manifest(&records);
454 let on_disk = match store
455 .read_bounded(Path::new(MANIFEST_FILE), crate::parser::MAX_DBMD_FILE_BYTES)
456 {
457 Ok(bytes) => bytes,
458 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
459 Err(error) => return Err(error.into()),
460 };
461 if on_disk != canonical.as_bytes() {
462 write_manifest(store, &records)?;
463 wrote = true;
464 }
465 }
466
467 Ok(ScanReport {
468 manifest: MANIFEST_FILE.to_string(),
469 cataloged,
470 hashed,
471 preserved,
472 bytes,
473 wrote,
474 dry_run,
475 warnings,
476 untracked: untracked_list,
477 })
478}
479
480fn supersession_cycle_members(
481 supersessions: &BTreeMap<String, (String, String)>,
482) -> BTreeSet<String> {
483 let mut cyclic = BTreeSet::new();
484 for origin in supersessions.keys() {
485 let mut order = Vec::new();
486 let mut positions = BTreeMap::new();
487 let mut current = origin.as_str();
488 while let Some((next, _)) = supersessions.get(current) {
489 if let Some(start) = positions.get(current).copied() {
490 cyclic.extend(order[start..].iter().cloned());
491 break;
492 }
493 positions.insert(current.to_string(), order.len());
494 order.push(current.to_string());
495 current = next;
496 }
497 }
498 cyclic
499}
500
501pub fn refresh(store: &Store, raw_path: &str, raw_wrapper: &str) -> crate::Result<RefreshReport> {
509 let path = normalize_asset_path(raw_path)
510 .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?;
511
512 let wrapper_path = normalize_asset_path(raw_wrapper)
513 .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?;
514 if !is_markdown(&wrapper_path)
515 || !(wrapper_path.starts_with("sources/") || wrapper_path.starts_with("records/"))
516 {
517 return Err(std::io::Error::new(
518 std::io::ErrorKind::InvalidInput,
519 "wrapper must be a sources/ or records/ markdown content path",
520 )
521 .into());
522 }
523
524 let declaration = |wrapper: &str| -> crate::Result<Option<bool>> {
525 let text =
526 store.read_text_bounded(Path::new(wrapper), crate::parser::MAX_DBMD_FILE_BYTES)?;
527 let parsed = parser::split_frontmatter(&text, Path::new(wrapper))?;
528 let fm = parser::Frontmatter::parse(&parsed.frontmatter_yaml, Path::new(wrapper))?;
529 let mut found = false;
530 let mut required = false;
531 for declaration in declared_assets(&fm) {
532 let declared = normalize_asset_path(&declaration.path).map_err(|message| {
533 std::io::Error::new(std::io::ErrorKind::InvalidInput, message)
534 })?;
535 if declared == path {
536 found = true;
537 required |= declaration.required;
538 }
539 }
540 Ok(found.then_some(required))
541 };
542
543 let Some(requested_required) = declaration(&wrapper_path)? else {
544 return Err(std::io::Error::new(
545 std::io::ErrorKind::InvalidInput,
546 format!("wrapper `{wrapper_path}` does not declare asset `{path}`"),
547 )
548 .into());
549 };
550 let requested_supersession = {
551 let text = store
552 .read_text_bounded(Path::new(&wrapper_path), crate::parser::MAX_DBMD_FILE_BYTES)?;
553 let parsed = parser::split_frontmatter(&text, Path::new(&wrapper_path))?;
554 let fm = parser::Frontmatter::parse(&parsed.frontmatter_yaml, Path::new(&wrapper_path))?;
555 asset_supersession(&fm)
556 .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?
557 };
558 if requested_supersession
559 .as_ref()
560 .is_some_and(|supersession| supersession.replacement != path)
561 {
562 return Err(std::io::Error::new(
563 std::io::ErrorKind::InvalidInput,
564 format!("wrapper `{wrapper_path}` supersedes an asset with a different replacement"),
565 )
566 .into());
567 }
568
569 let existing = read_manifest(store)?;
570 let mut wrappers = BTreeSet::from([wrapper_path.clone()]);
571 if let Some(record) = existing.iter().find(|record| record.path == path) {
572 wrappers.extend(record.wrappers.iter().cloned());
573 }
574 let mut live_wrappers = Vec::new();
575 let mut required = requested_required;
576 for wrapper in wrappers {
577 if wrapper != wrapper_path && !store.regular_file_exists(Path::new(&wrapper))? {
578 continue;
582 }
583 match declaration(&wrapper) {
584 Ok(Some(wrapper_required)) => {
585 required |= wrapper_required;
586 live_wrappers.push(wrapper);
587 }
588 Ok(None) => {}
589 Err(error) => return Err(error),
590 }
591 }
592 live_wrappers.sort();
593
594 let asset_path = store.capability_relative(Path::new(&path))?;
595 let file = store.open_regular(asset_path)?;
596 let (sha256, bytes) = sha256_file(file)?;
597 let record = AssetRecord {
598 path: path.clone(),
599 sha256: sha256.clone(),
600 bytes,
601 media_type: media_type_for(&path),
602 wrappers: live_wrappers.clone(),
603 required,
604 };
605 let mut next = existing;
606 next.retain(|candidate| candidate.path != path);
607 next.push(record);
608 let mut superseded_assets = Vec::new();
609 if let Some(supersession) = requested_supersession {
610 let original = next
611 .iter_mut()
612 .find(|candidate| candidate.path == supersession.original)
613 .ok_or_else(|| {
614 std::io::Error::new(
615 std::io::ErrorKind::InvalidInput,
616 format!(
617 "superseded asset `{}` has no existing manifest row; run `dbmd assets scan` first",
618 supersession.original
619 ),
620 )
621 })?;
622 original.required = false;
623 if !original.wrappers.contains(&wrapper_path) {
624 original.wrappers.push(wrapper_path.clone());
625 original.wrappers.sort();
626 }
627 superseded_assets.push(supersession.original);
628 }
629 next.sort_by(|left, right| left.path.cmp(&right.path));
630
631 let canonical = serialize_manifest(&next);
632 let on_disk =
633 match store.read_bounded(Path::new(MANIFEST_FILE), crate::parser::MAX_DBMD_FILE_BYTES) {
634 Ok(bytes) => bytes,
635 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
636 Err(error) => return Err(error.into()),
637 };
638 let wrote = on_disk != canonical.as_bytes();
639 if wrote {
640 write_manifest(store, &next)?;
641 }
642
643 Ok(RefreshReport {
644 manifest: MANIFEST_FILE.to_string(),
645 path,
646 sha256,
647 bytes,
648 wrappers: live_wrappers,
649 required,
650 superseded_assets,
651 wrote,
652 })
653}
654
655pub fn refresh_wrapper(store: &Store, raw_wrapper: &str) -> crate::Result<RefreshWrapperReport> {
667 let wrapper_path = normalize_asset_path(raw_wrapper)
668 .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?;
669 if !is_markdown(&wrapper_path)
670 || !(wrapper_path.starts_with("sources/") || wrapper_path.starts_with("records/"))
671 {
672 return Err(std::io::Error::new(
673 std::io::ErrorKind::InvalidInput,
674 "wrapper must be a sources/ or records/ markdown content path",
675 )
676 .into());
677 }
678
679 let wrapper_declarations = |wrapper: &str| -> crate::Result<BTreeMap<String, bool>> {
680 let text =
681 store.read_text_bounded(Path::new(wrapper), crate::parser::MAX_DBMD_FILE_BYTES)?;
682 let parsed = parser::split_frontmatter(&text, Path::new(wrapper))?;
683 let fm = parser::Frontmatter::parse(&parsed.frontmatter_yaml, Path::new(wrapper))?;
684 if asset_supersession(&fm)
685 .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?
686 .is_some()
687 {
688 return Err(std::io::Error::new(
689 std::io::ErrorKind::InvalidInput,
690 "refresh-wrapper does not accept supersedes-asset; use assets refresh for that replacement",
691 )
692 .into());
693 }
694 let mut declarations = BTreeMap::new();
695 for declaration in declared_assets(&fm) {
696 let path = normalize_asset_path(&declaration.path).map_err(|message| {
697 std::io::Error::new(std::io::ErrorKind::InvalidInput, message)
698 })?;
699 let required = declarations.entry(path).or_insert(false);
700 *required |= declaration.required;
701 }
702 Ok(declarations)
703 };
704
705 let requested = wrapper_declarations(&wrapper_path)?;
709
710 let existing = read_manifest(store)?;
711 let existing_by_path: BTreeMap<String, AssetRecord> = existing
712 .iter()
713 .cloned()
714 .map(|record| (record.path.clone(), record))
715 .collect();
716 let old_paths: BTreeSet<String> = existing
717 .iter()
718 .filter(|record| record.wrappers.contains(&wrapper_path))
719 .map(|record| record.path.clone())
720 .collect();
721 let requested_paths: BTreeSet<String> = requested.keys().cloned().collect();
722
723 let mut wrapper_cache: BTreeMap<String, Option<BTreeMap<String, bool>>> = BTreeMap::new();
724 let mut live_other_declaration = |wrapper: &str, path: &str| -> crate::Result<Option<bool>> {
725 if !wrapper_cache.contains_key(wrapper) {
726 let declarations = if store.regular_file_exists(Path::new(wrapper))? {
727 Some(wrapper_declarations(wrapper)?)
728 } else {
729 None
730 };
731 wrapper_cache.insert(wrapper.to_string(), declarations);
732 }
733 Ok(wrapper_cache
734 .get(wrapper)
735 .and_then(Option::as_ref)
736 .and_then(|declarations| declarations.get(path).copied()))
737 };
738
739 let mut next = Vec::new();
740 for mut record in existing.iter().cloned() {
741 if requested.contains_key(&record.path) {
742 continue;
743 }
744 if record.wrappers.contains(&wrapper_path) {
745 let mut live_wrappers = Vec::new();
746 let mut required = false;
747 for wrapper in &record.wrappers {
748 if wrapper == &wrapper_path {
749 continue;
750 }
751 if let Some(wrapper_required) = live_other_declaration(wrapper, &record.path)? {
752 live_wrappers.push(wrapper.clone());
753 required |= wrapper_required;
754 }
755 }
756 if live_wrappers.is_empty() {
757 continue;
758 }
759 live_wrappers.sort();
760 record.wrappers = live_wrappers;
761 record.required = required;
762 }
763 next.push(record);
764 }
765
766 let mut hashed = 0usize;
767 let mut preserved = 0usize;
768 let mut bytes_total = 0u64;
769 for (path, requested_required) in &requested {
770 let mut wrappers = vec![wrapper_path.clone()];
771 let mut required = *requested_required;
772 if let Some(existing_record) = existing_by_path.get(path) {
773 for wrapper in &existing_record.wrappers {
774 if wrapper == &wrapper_path {
775 continue;
776 }
777 if let Some(wrapper_required) = live_other_declaration(wrapper, path)? {
778 wrappers.push(wrapper.clone());
779 required |= wrapper_required;
780 }
781 }
782 }
783 wrappers.sort();
784 wrappers.dedup();
785
786 let (sha256, bytes, media_type) = match store.open_regular(Path::new(path)) {
787 Ok(file) => {
788 let (sha256, bytes) = sha256_file(file)?;
789 hashed += 1;
790 (sha256, bytes, media_type_for(path))
791 }
792 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
793 let existing_record = existing_by_path.get(path).ok_or_else(|| {
794 std::io::Error::new(
795 std::io::ErrorKind::NotFound,
796 format!(
797 "declared asset `{path}` is absent and has no manifest row to preserve"
798 ),
799 )
800 })?;
801 preserved += 1;
802 (
803 existing_record.sha256.clone(),
804 existing_record.bytes,
805 existing_record.media_type.clone(),
806 )
807 }
808 Err(error) => return Err(error.into()),
809 };
810 bytes_total = bytes_total.saturating_add(bytes);
811 next.push(AssetRecord {
812 path: path.clone(),
813 sha256,
814 bytes,
815 media_type,
816 wrappers,
817 required,
818 });
819 }
820
821 next.sort_by(|left, right| left.path.cmp(&right.path));
822 let canonical = serialize_manifest(&next);
823 let on_disk =
824 match store.read_bounded(Path::new(MANIFEST_FILE), crate::parser::MAX_DBMD_FILE_BYTES) {
825 Ok(bytes) => bytes,
826 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
827 Err(error) => return Err(error.into()),
828 };
829 let wrote = on_disk != canonical.as_bytes();
830 if wrote {
831 write_manifest(store, &next)?;
832 }
833
834 Ok(RefreshWrapperReport {
835 manifest: MANIFEST_FILE.to_string(),
836 wrapper: wrapper_path,
837 cataloged: requested.len(),
838 added: requested_paths.difference(&old_paths).count(),
839 removed: old_paths.difference(&requested_paths).count(),
840 hashed,
841 preserved,
842 bytes: bytes_total,
843 wrote,
844 })
845}
846
847pub fn verify(store: &Store, include_optional: bool, quick: bool) -> crate::Result<VerifyReport> {
857 let records = read_manifest(store)?;
858 let mut missing = Vec::new();
859 let mut corrupt = Vec::new();
860 let mut checked = 0usize;
861
862 for rec in &records {
863 if !rec.required && !include_optional {
864 continue;
865 }
866 checked += 1;
867 let abs = match store.capability_relative(Path::new(&rec.path)) {
868 Ok(p) => p,
869 Err(_) => {
870 corrupt.push(rec.path.clone());
872 continue;
873 }
874 };
875 let file = match store.open_regular(abs) {
876 Ok(file) => file,
877 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
878 missing.push(rec.path.clone());
879 continue;
880 }
881 Err(_) => {
882 corrupt.push(rec.path.clone());
883 continue;
884 }
885 };
886 if quick {
887 let len = file.metadata()?.len();
888 if len != rec.bytes {
889 corrupt.push(rec.path.clone());
890 }
891 } else {
892 let (sha, bytes) = sha256_file(file)?;
893 if sha != rec.sha256 || bytes != rec.bytes {
894 corrupt.push(rec.path.clone());
895 }
896 }
897 }
898
899 let ok = checked - missing.len() - corrupt.len();
900 let complete = missing.is_empty() && corrupt.is_empty();
901 Ok(VerifyReport {
902 mode: if quick { "quick" } else { "deep" }.to_string(),
903 checked,
904 ok,
905 missing,
906 corrupt,
907 complete,
908 })
909}
910
911pub fn status(store: &Store) -> crate::Result<StatusReport> {
919 let records = read_manifest(store)?;
920 let mut present = 0usize;
921 let mut missing = 0usize;
922 let mut required_missing = 0usize;
923 let mut optional_missing = 0usize;
924 let mut bytes_total = 0u64;
925 let mut bytes_missing = 0u64;
926 let mut assets = Vec::with_capacity(records.len());
927
928 for rec in &records {
929 bytes_total = bytes_total.saturating_add(rec.bytes);
934 let is_present = store.open_regular(Path::new(&rec.path)).is_ok();
942 let state = if is_present {
943 present += 1;
944 "present"
945 } else {
946 missing += 1;
947 bytes_missing = bytes_missing.saturating_add(rec.bytes);
948 if rec.required {
949 required_missing += 1;
950 } else {
951 optional_missing += 1;
952 }
953 "missing"
954 };
955 assets.push(AssetState {
956 path: rec.path.clone(),
957 sha256: rec.sha256.clone(),
958 bytes: rec.bytes,
959 required: rec.required,
960 state: state.to_string(),
961 });
962 }
963
964 Ok(StatusReport {
965 total: records.len(),
966 present,
967 missing,
968 required_missing,
969 optional_missing,
970 bytes_total,
971 bytes_missing,
972 assets,
973 })
974}
975
976pub fn paths(store: &Store) -> crate::Result<Vec<String>> {
999 Ok(read_manifest(store)?
1000 .into_iter()
1001 .filter(|r| store.capability_relative(Path::new(&r.path)).is_ok())
1002 .filter(|r| !is_markdown(&r.path))
1003 .map(|r| r.path)
1004 .collect())
1005}
1006
1007pub fn declared_assets(fm: &parser::Frontmatter) -> Vec<Declaration> {
1017 let mut out = Vec::new();
1018 if let Some(v) = fm.get("asset") {
1019 collect_declarations(&v, &mut out);
1020 }
1021 if let Some(v) = fm.get("assets") {
1022 collect_declarations(&v, &mut out);
1023 }
1024 out
1025}
1026
1027pub fn declarations_from_yaml_map(map: &BTreeMap<String, Value>) -> Vec<Declaration> {
1031 let mut out = Vec::new();
1032 if let Some(v) = map.get("asset") {
1033 collect_declarations(v, &mut out);
1034 }
1035 if let Some(v) = map.get("assets") {
1036 collect_declarations(v, &mut out);
1037 }
1038 out
1039}
1040
1041pub fn asset_supersession(fm: &parser::Frontmatter) -> Result<Option<AssetSupersession>, String> {
1045 asset_supersession_from_parts(fm.get(SUPERSEDES_ASSET_KEY).as_ref(), declared_assets(fm))
1046}
1047
1048pub fn asset_supersession_from_yaml_map(
1050 map: &BTreeMap<String, Value>,
1051) -> Result<Option<AssetSupersession>, String> {
1052 asset_supersession_from_parts(
1053 map.get(SUPERSEDES_ASSET_KEY),
1054 declarations_from_yaml_map(map),
1055 )
1056}
1057
1058fn asset_supersession_from_parts(
1059 value: Option<&Value>,
1060 declarations: Vec<Declaration>,
1061) -> Result<Option<AssetSupersession>, String> {
1062 let Some(value) = value else {
1063 return Ok(None);
1064 };
1065 let Value::String(original) = value else {
1066 return Err(format!("`{SUPERSEDES_ASSET_KEY}` must be one asset path"));
1067 };
1068 if declarations.len() != 1 || !declarations[0].required {
1069 return Err(format!(
1070 "a `{SUPERSEDES_ASSET_KEY}` wrapper must declare exactly one required replacement asset"
1071 ));
1072 }
1073 let original = normalize_asset_path(original)?;
1074 let replacement = normalize_asset_path(&declarations[0].path)?;
1075 if original == replacement {
1076 return Err(format!(
1077 "`{SUPERSEDES_ASSET_KEY}` cannot name the wrapper's replacement asset"
1078 ));
1079 }
1080 Ok(Some(AssetSupersession {
1081 original,
1082 replacement,
1083 }))
1084}
1085
1086fn collect_declarations(v: &Value, out: &mut Vec<Declaration>) {
1087 match v {
1088 Value::String(s) => out.push(Declaration {
1089 path: s.clone(),
1090 required: true,
1091 }),
1092 Value::Sequence(items) => {
1093 for item in items {
1094 match item {
1095 Value::String(s) => out.push(Declaration {
1096 path: s.clone(),
1097 required: true,
1098 }),
1099 Value::Mapping(m) => {
1100 let path = m
1101 .get(Value::String("path".to_string()))
1102 .and_then(|x| x.as_str())
1103 .map(|s| s.to_string());
1104 if let Some(path) = path {
1105 let required = m
1106 .get(Value::String("required".to_string()))
1107 .and_then(|x| x.as_bool())
1108 .unwrap_or(true);
1109 out.push(Declaration { path, required });
1110 }
1111 }
1112 _ => {}
1113 }
1114 }
1115 }
1116 _ => {}
1117 }
1118}
1119
1120pub fn normalize_asset_path(raw: &str) -> Result<String, String> {
1136 let trimmed = raw.trim();
1137 if trimmed.is_empty() {
1138 return Err("empty asset path".to_string());
1139 }
1140 let p = Path::new(trimmed);
1141 if p.is_absolute() {
1142 return Err(format!("absolute asset path not allowed: {raw}"));
1143 }
1144 let mut normal: Vec<&std::ffi::OsStr> = Vec::new();
1145 for c in p.components() {
1146 match c {
1147 Component::ParentDir => return Err(format!("`..` not allowed in asset path: {raw}")),
1148 Component::Prefix(_) | Component::RootDir => {
1149 return Err(format!("asset path escapes the store: {raw}"))
1150 }
1151 Component::CurDir => {}
1154 Component::Normal(seg) => normal.push(seg),
1155 }
1156 }
1157 if normal.is_empty() {
1158 return Err(format!("asset path names no file: {raw}"));
1160 }
1161 let joined: PathBuf = normal.into_iter().collect();
1162 Ok(joined.to_string_lossy().replace('\\', "/"))
1163}
1164
1165fn is_markdown(path: &str) -> bool {
1166 Path::new(path)
1167 .extension()
1168 .and_then(|e| e.to_str())
1169 .map(|e| e.eq_ignore_ascii_case("md"))
1170 .unwrap_or(false)
1171}
1172
1173fn rel_to_string(p: &Path) -> String {
1174 p.to_string_lossy().replace('\\', "/")
1175}
1176
1177fn sha256_file(mut f: std::fs::File) -> std::io::Result<(String, u64)> {
1180 let mut hasher = Sha256::new();
1181 let mut buf = [0u8; 65536];
1182 let mut total: u64 = 0;
1183 loop {
1184 let n = f.read(&mut buf)?;
1185 if n == 0 {
1186 break;
1187 }
1188 hasher.update(&buf[..n]);
1189 total += n as u64;
1190 }
1191 let digest = hasher.finalize();
1192 let mut hex = String::with_capacity(64);
1193 for b in digest.iter() {
1194 let _ = write!(hex, "{b:02x}");
1195 }
1196 Ok((hex, total))
1197}
1198
1199fn media_type_for(path: &str) -> String {
1203 let ext = Path::new(path)
1204 .extension()
1205 .and_then(|e| e.to_str())
1206 .unwrap_or("")
1207 .to_ascii_lowercase();
1208 let mt = match ext.as_str() {
1209 "pdf" => "application/pdf",
1210 "png" => "image/png",
1211 "jpg" | "jpeg" => "image/jpeg",
1212 "gif" => "image/gif",
1213 "webp" => "image/webp",
1214 "svg" => "image/svg+xml",
1215 "tiff" | "tif" => "image/tiff",
1216 "mp4" => "video/mp4",
1217 "mov" => "video/quicktime",
1218 "webm" => "video/webm",
1219 "mkv" => "video/x-matroska",
1220 "mp3" => "audio/mpeg",
1221 "wav" => "audio/wav",
1222 "m4a" => "audio/mp4",
1223 "flac" => "audio/flac",
1224 "zip" => "application/zip",
1225 "gz" | "tgz" => "application/gzip",
1226 "tar" => "application/x-tar",
1227 "csv" => "text/csv",
1228 "tsv" => "text/tab-separated-values",
1229 "md" | "markdown" => "text/markdown",
1230 "json" => "application/json",
1231 "xml" => "application/xml",
1232 "txt" => "text/plain",
1233 "vtt" => "text/vtt",
1234 "srt" => "application/x-subrip",
1235 "html" | "htm" => "text/html",
1236 "epub" => "application/epub+zip",
1237 "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
1238 "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
1239 "pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
1240 "doc" => "application/msword",
1241 "xls" => "application/vnd.ms-excel",
1242 "ppt" => "application/vnd.ms-powerpoint",
1243 _ => "application/octet-stream",
1244 };
1245 mt.to_string()
1246}
1247
1248fn find_untracked(store: &Store, declared: &BTreeSet<String>) -> crate::Result<Vec<String>> {
1252 let mut out = Vec::new();
1253 let paths = match store.walk_regular_files(Path::new("sources")) {
1254 Ok(paths) => paths,
1255 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(out),
1256 Err(error) => return Err(error.into()),
1257 };
1258 for path in paths {
1259 let name = match path.file_name().and_then(|name| name.to_str()) {
1260 Some(name) => name,
1261 None => continue,
1262 };
1263 if is_markdown(name) || name == "index.jsonl" {
1264 continue;
1265 }
1266 let rel = rel_to_string(&path);
1267 if !declared.contains(&rel) {
1268 out.push(rel);
1269 }
1270 }
1271 out.sort();
1272 Ok(out)
1273}
1274
1275#[cfg(test)]
1276mod tests {
1277 use super::*;
1278
1279 #[test]
1280 fn supersession_cycles_exclude_only_cycle_members() {
1281 let supersessions = BTreeMap::from([
1282 ("a".to_string(), ("b".to_string(), "a.md".to_string())),
1283 ("b".to_string(), ("a".to_string(), "b.md".to_string())),
1284 (
1285 "before".to_string(),
1286 ("a".to_string(), "before.md".to_string()),
1287 ),
1288 (
1289 "clean".to_string(),
1290 ("next".to_string(), "clean.md".to_string()),
1291 ),
1292 ]);
1293 assert_eq!(
1294 supersession_cycle_members(&supersessions),
1295 BTreeSet::from(["a".to_string(), "b".to_string()])
1296 );
1297 }
1298
1299 #[test]
1305 fn normalize_asset_path_folds_curdir_and_rejects_traversal() {
1306 assert_eq!(
1307 normalize_asset_path("./sources/x.pdf").unwrap(),
1308 "sources/x.pdf"
1309 );
1310 assert_eq!(
1311 normalize_asset_path("sources/x.pdf").unwrap(),
1312 "sources/x.pdf"
1313 );
1314 assert_eq!(
1315 normalize_asset_path("sources/./x.pdf").unwrap(),
1316 "sources/x.pdf"
1317 );
1318 assert_eq!(
1319 normalize_asset_path("sources/x.pdf/").unwrap(),
1320 "sources/x.pdf"
1321 );
1322
1323 assert!(normalize_asset_path("../outside.txt").is_err());
1325 assert!(normalize_asset_path("sources/../../etc/passwd").is_err());
1326 assert!(normalize_asset_path("/abs/x.pdf").is_err());
1327 assert!(normalize_asset_path(".").is_err());
1329 assert!(normalize_asset_path("./").is_err());
1330 assert!(normalize_asset_path("").is_err());
1331 }
1332
1333 #[test]
1338 fn status_and_scan_saturate_on_overflowing_manifest_bytes() {
1339 let tmp = tempfile::TempDir::new().unwrap();
1340 let root = tmp.path();
1341 std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n# store\n").unwrap();
1342 std::fs::write(
1344 root.join("assets.jsonl"),
1345 "{\"path\":\"records/a.bin\",\"sha256\":\"x\",\"bytes\":18446744073709551615,\
1346\"media_type\":\"application/octet-stream\",\"wrappers\":[\"records/w.md\"],\"required\":true}\n\
1347{\"path\":\"records/b.bin\",\"sha256\":\"y\",\"bytes\":1,\
1348\"media_type\":\"application/octet-stream\",\"wrappers\":[\"records/w.md\"],\"required\":true}\n",
1349 )
1350 .unwrap();
1351 let store = Store::from_root_and_config(root, crate::parser::Config::default()).unwrap();
1352
1353 let report = status(&store).expect("status is non-failing on a poisoned manifest");
1356 assert_eq!(
1357 report.bytes_total,
1358 u64::MAX,
1359 "byte total must saturate, not wrap"
1360 );
1361 assert_eq!(
1362 report.bytes_missing,
1363 u64::MAX,
1364 "missing bytes must saturate too"
1365 );
1366 assert_eq!(report.total, 2);
1367
1368 scan(&store, true, false).expect("scan must not overflow on a poisoned manifest");
1370 }
1371
1372 fn store_with_one_asset() -> (tempfile::TempDir, Store, String) {
1375 let tmp = tempfile::TempDir::new().unwrap();
1376 let root = tmp.path();
1377 std::fs::create_dir_all(root.join("sources")).unwrap();
1378 std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n# store\n").unwrap();
1379 std::fs::write(
1380 root.join("sources/a.pdf.md"),
1381 "---\ntype: pdf-source\nsummary: x\nasset: sources/a.pdf\n---\nbody\n",
1382 )
1383 .unwrap();
1384 std::fs::write(root.join("sources/a.pdf"), b"PDFBYTES").unwrap();
1385 let store = Store::from_root_and_config(root, crate::parser::Config::default()).unwrap();
1386 let report = scan(&store, false, false).unwrap();
1387 assert!(
1388 report.wrote,
1389 "first scan writes the manifest; report: {report:?}"
1390 );
1391 let canonical = std::fs::read_to_string(root.join(MANIFEST_FILE)).unwrap();
1392 (tmp, store, canonical)
1393 }
1394
1395 #[test]
1404 fn scan_recompacts_duplicate_line_manifest() {
1405 let (_tmp, store, canonical) = store_with_one_asset();
1406 let abs = store.root.join(MANIFEST_FILE);
1407
1408 std::fs::write(&abs, format!("{canonical}{canonical}")).unwrap();
1410 assert_eq!(std::fs::read_to_string(&abs).unwrap().lines().count(), 2);
1411
1412 let report = scan(&store, false, false).unwrap();
1413 assert!(
1414 report.wrote,
1415 "a non-canonical (duplicate-line) manifest must be recompacted and reported as updated"
1416 );
1417 let after = std::fs::read_to_string(&abs).unwrap();
1418 assert_eq!(
1419 after.lines().count(),
1420 1,
1421 "duplicate lines must collapse to the single canonical line"
1422 );
1423 assert_eq!(
1424 after, canonical,
1425 "scan must restore the exact canonical bytes"
1426 );
1427 }
1428
1429 #[test]
1433 fn scan_recompacts_noncanonical_byte_layout() {
1434 let (_tmp, store, canonical) = store_with_one_asset();
1435 let abs = store.root.join(MANIFEST_FILE);
1436
1437 std::fs::write(&abs, canonical.trim_end_matches('\n')).unwrap();
1439 let report = scan(&store, false, false).unwrap();
1440 assert!(
1441 report.wrote,
1442 "a manifest missing its trailing newline must be recompacted"
1443 );
1444 assert_eq!(
1445 std::fs::read_to_string(&abs).unwrap(),
1446 canonical,
1447 "scan must restore the canonical trailing newline"
1448 );
1449 }
1450
1451 #[test]
1461 fn paths_omits_store_escaping_records() {
1462 let tmp = tempfile::TempDir::new().unwrap();
1463 let root = tmp.path();
1464 std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n# store\n").unwrap();
1465 std::fs::write(
1467 root.join("assets.jsonl"),
1468 "{\"path\":\"sources/legit.pdf\",\"sha256\":\"a\",\"bytes\":9,\
1469\"media_type\":\"application/pdf\",\"wrappers\":[\"sources/legit.pdf.md\"],\"required\":true}\n\
1470{\"path\":\"../../../../../../etc/passwd\",\"sha256\":\"b\",\"bytes\":4096,\
1471\"media_type\":\"text/plain\",\"wrappers\":[\"sources/legit.pdf.md\"],\"required\":false}\n\
1472{\"path\":\"/etc/hosts\",\"sha256\":\"c\",\"bytes\":4096,\
1473\"media_type\":\"text/plain\",\"wrappers\":[\"sources/legit.pdf.md\"],\"required\":false}\n",
1474 )
1475 .unwrap();
1476 let store = Store::from_root_and_config(root, crate::parser::Config::default()).unwrap();
1477
1478 let out = paths(&store).expect("paths is non-failing on a poisoned manifest");
1479 assert_eq!(
1480 out,
1481 vec!["sources/legit.pdf".to_string()],
1482 "only the safe in-store path is emitted; escaping paths are omitted"
1483 );
1484 assert!(
1485 !out.iter().any(|p| p.starts_with('/') || p.contains("..")),
1486 "no absolute or `..` path may ever leak from `paths`: {out:?}"
1487 );
1488 }
1489
1490 #[test]
1493 fn paths_passes_a_clean_manifest_through_unchanged() {
1494 let (_tmp, store, _canonical) = store_with_one_asset();
1495 let out = paths(&store).expect("paths over a clean manifest");
1496 assert_eq!(out, vec!["sources/a.pdf".to_string()]);
1497 }
1498
1499 #[test]
1505 fn markdown_content_files_are_accepted_as_assets_and_omitted_from_paths() {
1506 let tmp = tempfile::TempDir::new().unwrap();
1507 let root = tmp.path();
1508 std::fs::create_dir_all(root.join("sources")).unwrap();
1509 std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n# store\n").unwrap();
1510 std::fs::write(
1511 root.join("sources/bundle.md"),
1512 "---\ntype: pdf-source\nsummary: bundle wrapper\nassets:\n - sources/notes.md\n - sources/a.pdf\n---\nbody\n",
1513 )
1514 .unwrap();
1515 std::fs::write(
1516 root.join("sources/notes.md"),
1517 "---\ntype: pdf-source\nsummary: a content file doubling as an asset\n---\nnotes\n",
1518 )
1519 .unwrap();
1520 std::fs::write(root.join("sources/a.pdf"), b"PDFBYTES").unwrap();
1521 let store = Store::from_root_and_config(root, crate::parser::Config::default()).unwrap();
1522
1523 let report = scan(&store, false, false).unwrap();
1524 assert!(
1525 report.warnings.is_empty(),
1526 "a markdown asset must not be skipped or warned about: {:?}",
1527 report.warnings
1528 );
1529 assert_eq!(
1530 report.cataloged, 2,
1531 "both the pdf and the markdown asset are cataloged"
1532 );
1533
1534 let refreshed = refresh(&store, "sources/notes.md", "sources/bundle.md")
1535 .expect("refresh accepts a markdown asset coordinate");
1536 assert_eq!(refreshed.path, "sources/notes.md");
1537 let reconciled = refresh_wrapper(&store, "sources/bundle.md")
1538 .expect("refresh-wrapper accepts a wrapper declaring a markdown asset");
1539 assert_eq!(reconciled.cataloged, 2);
1540
1541 let manifest = read_manifest(&store).unwrap();
1542 let md_row = manifest
1543 .iter()
1544 .find(|record| record.path == "sources/notes.md")
1545 .expect("the markdown asset has a manifest row");
1546 assert_eq!(md_row.media_type, "text/markdown");
1547
1548 let listed = paths(&store).expect("paths over the mixed manifest");
1549 assert_eq!(
1550 listed,
1551 vec!["sources/a.pdf".to_string()],
1552 "markdown assets are omitted from the ignore-feed list"
1553 );
1554 }
1555
1556 #[test]
1560 fn scan_canonical_manifest_is_left_untouched() {
1561 let (_tmp, store, canonical) = store_with_one_asset();
1562 let abs = store.root.join(MANIFEST_FILE);
1563
1564 let report = scan(&store, false, false).unwrap();
1565 assert!(
1566 !report.wrote,
1567 "a canonical, unchanged manifest must not be rewritten"
1568 );
1569 assert_eq!(
1570 std::fs::read_to_string(&abs).unwrap(),
1571 canonical,
1572 "a no-op rescan must leave the manifest byte-identical"
1573 );
1574 }
1575
1576 #[test]
1577 fn refresh_wrapper_reconciles_one_generated_asset_set_in_one_manifest_write() {
1578 let tmp = tempfile::TempDir::new().unwrap();
1579 let root = tmp.path();
1580 std::fs::create_dir_all(root.join("records/package")).unwrap();
1581 std::fs::create_dir_all(root.join("sources/package/objects")).unwrap();
1582 std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
1583 let wrapper = "records/package/current.md";
1584 std::fs::write(
1585 root.join(wrapper),
1586 "---\ntype: package\nsummary: current\nassets:\n - sources/package/objects/a.blob\n - sources/package/objects/b.blob\n---\n",
1587 )
1588 .unwrap();
1589 std::fs::write(root.join("sources/package/objects/a.blob"), b"a").unwrap();
1590 std::fs::write(root.join("sources/package/objects/b.blob"), b"bb").unwrap();
1591 let store = Store::from_root_and_config(root, crate::parser::Config::default()).unwrap();
1592
1593 let first = refresh_wrapper(&store, wrapper).unwrap();
1594 assert_eq!(first.cataloged, 2);
1595 assert_eq!(first.added, 2);
1596 assert_eq!(first.removed, 0);
1597 assert_eq!(first.hashed, 2);
1598 assert_eq!(first.bytes, 3);
1599 assert!(first.wrote);
1600
1601 let no_change = refresh_wrapper(&store, wrapper).unwrap();
1602 assert!(!no_change.wrote);
1603 assert_eq!(no_change.added, 0);
1604 assert_eq!(no_change.removed, 0);
1605
1606 std::fs::write(
1607 root.join(wrapper),
1608 "---\ntype: package\nsummary: current\nassets:\n - sources/package/objects/b.blob\n - sources/package/objects/c.blob\n---\n",
1609 )
1610 .unwrap();
1611 std::fs::write(root.join("sources/package/objects/c.blob"), b"ccc").unwrap();
1612 let changed = refresh_wrapper(&store, wrapper).unwrap();
1613 assert_eq!(changed.cataloged, 2);
1614 assert_eq!(changed.added, 1);
1615 assert_eq!(changed.removed, 1);
1616 assert_eq!(changed.bytes, 5);
1617 assert!(changed.wrote);
1618
1619 let records = read_manifest(&store).unwrap();
1620 let paths: Vec<&str> = records.iter().map(|record| record.path.as_str()).collect();
1621 assert_eq!(
1622 paths,
1623 vec![
1624 "sources/package/objects/b.blob",
1625 "sources/package/objects/c.blob"
1626 ]
1627 );
1628
1629 std::fs::write(
1630 root.join(wrapper),
1631 "---\ntype: package\nsummary: current\n---\n",
1632 )
1633 .unwrap();
1634 let cleared = refresh_wrapper(&store, wrapper).unwrap();
1635 assert_eq!(cleared.cataloged, 0);
1636 assert_eq!(cleared.added, 0);
1637 assert_eq!(cleared.removed, 2);
1638 assert!(cleared.wrote);
1639 assert!(read_manifest(&store).unwrap().is_empty());
1640 }
1641
1642 #[cfg(unix)]
1643 #[test]
1644 fn manifest_membership_reads_opened_root_after_path_replacement() {
1645 use std::os::unix::fs::symlink;
1646
1647 let sandbox = tempfile::tempdir().unwrap();
1648 let root = sandbox.path().join("store");
1649 std::fs::create_dir_all(&root).unwrap();
1650 std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
1651 std::fs::write(
1652 root.join(MANIFEST_FILE),
1653 "{\"path\":\"sources/owned.pdf\",\"sha256\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"bytes\":1,\"media_type\":\"application/pdf\",\"wrappers\":[],\"required\":true}\n",
1654 )
1655 .unwrap();
1656 let store = Store::open_strict(&root).unwrap();
1657 let detached = sandbox.path().join("detached");
1658 std::fs::rename(&root, &detached).unwrap();
1659
1660 let replacement = sandbox.path().join("replacement");
1661 std::fs::create_dir_all(&replacement).unwrap();
1662 std::fs::write(replacement.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
1663 std::fs::write(
1664 replacement.join(MANIFEST_FILE),
1665 "{\"path\":\"sources/replacement-secret.pdf\",\"sha256\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"bytes\":1,\"media_type\":\"application/pdf\",\"wrappers\":[],\"required\":true}\n",
1666 )
1667 .unwrap();
1668 symlink(&replacement, &root).unwrap();
1669
1670 assert_eq!(paths(&store).unwrap(), vec!["sources/owned.pdf"]);
1671 }
1672}