1use std::collections::{BTreeMap, BTreeSet};
32use std::fmt::Write as _;
33use std::io::Read as _;
34use std::path::{Component, Path, PathBuf};
35
36use serde::{Deserialize, Serialize};
37use serde_norway::Value;
38use sha2::{Digest, Sha256};
39
40use crate::parser;
41use crate::store::Store;
42
43pub const MANIFEST_FILE: &str = "assets.jsonl";
45
46pub const SUPERSEDES_ASSET_KEY: &str = "supersedes-asset";
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct AssetRecord {
58 pub path: String,
61 pub sha256: String,
64 pub bytes: u64,
66 pub media_type: String,
68 pub wrappers: Vec<String>,
71 pub required: bool,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct Declaration {
79 pub path: String,
81 pub required: bool,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct AssetSupersession {
89 pub original: String,
91 pub replacement: String,
93}
94
95#[derive(Debug, Serialize)]
101pub struct ScanReport {
102 pub manifest: String,
103 pub cataloged: usize,
104 pub hashed: usize,
105 pub preserved: usize,
106 pub bytes: u64,
107 pub wrote: bool,
108 pub dry_run: bool,
109 pub warnings: Vec<String>,
110 pub untracked: Vec<String>,
111}
112
113#[derive(Debug, Serialize)]
117pub struct RefreshReport {
118 pub manifest: String,
119 pub path: String,
120 pub sha256: String,
121 pub bytes: u64,
122 pub wrappers: Vec<String>,
123 pub required: bool,
124 pub superseded_assets: Vec<String>,
126 pub wrote: bool,
127}
128
129#[derive(Debug, Serialize)]
133pub struct RefreshWrapperReport {
134 pub manifest: String,
135 pub wrapper: String,
136 pub cataloged: usize,
137 pub added: usize,
138 pub removed: usize,
139 pub hashed: usize,
140 pub preserved: usize,
141 pub bytes: u64,
142 pub wrote: bool,
143}
144
145#[derive(Debug, Serialize)]
147pub struct AssetState {
148 pub path: String,
149 pub sha256: String,
150 pub bytes: u64,
151 pub required: bool,
152 pub state: String,
154}
155
156#[derive(Debug, Serialize)]
158pub struct StatusReport {
159 pub total: usize,
160 pub present: usize,
161 pub missing: usize,
162 pub required_missing: usize,
163 pub optional_missing: usize,
164 pub bytes_total: u64,
165 pub bytes_missing: u64,
166 pub assets: Vec<AssetState>,
167}
168
169#[derive(Debug, Serialize)]
171pub struct VerifyReport {
172 pub mode: String,
173 pub checked: usize,
174 pub ok: usize,
175 pub missing: Vec<String>,
176 pub corrupt: Vec<String>,
177 pub complete: bool,
178}
179
180pub fn read_manifest(store: &Store) -> crate::Result<Vec<AssetRecord>> {
189 let text = match store
190 .read_text_bounded(Path::new(MANIFEST_FILE), crate::parser::MAX_DBMD_FILE_BYTES)
191 {
192 Ok(text) => text,
193 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
194 Err(error) => return Err(error.into()),
195 };
196 let mut by_path: BTreeMap<String, AssetRecord> = BTreeMap::new();
197 for (i, line) in text.lines().enumerate() {
198 if line.trim().is_empty() {
199 continue;
200 }
201 let rec: AssetRecord = serde_json::from_str(line).map_err(|e| {
202 std::io::Error::new(
203 std::io::ErrorKind::InvalidData,
204 format!("{MANIFEST_FILE} line {}: {e}", i + 1),
205 )
206 })?;
207 by_path.insert(rec.path.clone(), rec);
208 }
209 Ok(by_path.into_values().collect())
210}
211
212fn serialize_manifest(records: &[AssetRecord]) -> String {
219 if records.is_empty() {
220 return String::new();
221 }
222 let mut sorted = records.to_vec();
223 sorted.sort_by(|a, b| a.path.cmp(&b.path));
224 let mut out = String::new();
225 for rec in &sorted {
226 let line = serde_json::to_string(rec).expect("AssetRecord serializes");
227 out.push_str(&line);
228 out.push('\n');
229 }
230 out
231}
232
233pub fn write_manifest(store: &Store, records: &[AssetRecord]) -> crate::Result<()> {
237 let abs = Path::new(MANIFEST_FILE);
238 let out = serialize_manifest(records);
239 if out.is_empty() {
240 match store.remove_file(abs) {
241 Ok(()) => {}
242 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
243 Err(error) => return Err(error.into()),
244 }
245 return Ok(());
246 }
247 store.write_atomic(abs, out.as_bytes())?;
248 Ok(())
249}
250
251pub fn scan(store: &Store, dry_run: bool, untracked: bool) -> crate::Result<ScanReport> {
264 let existing_by_path: BTreeMap<String, AssetRecord> = read_manifest(store)
268 .unwrap_or_default()
269 .into_iter()
270 .map(|r| (r.path.clone(), r))
271 .collect();
272
273 let mut wrappers_by_path: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
275 let mut required_by_path: BTreeMap<String, bool> = BTreeMap::new();
276 let mut declared_paths: BTreeSet<String> = BTreeSet::new();
277 let mut supersessions: BTreeMap<String, (String, String)> = BTreeMap::new();
278 let mut ambiguous_supersessions: BTreeSet<String> = BTreeSet::new();
279 let mut warnings: Vec<String> = Vec::new();
280
281 for rel in store.walk()? {
282 let text = match store.read_text_bounded(&rel, parser::MAX_DBMD_FILE_BYTES) {
283 Ok(text) => text,
284 Err(_) => continue,
285 };
286 let parsed = match parser::split_frontmatter(&text, &rel) {
287 Ok(parsed) => parsed,
288 Err(_) => continue,
289 };
290 let fm = match parser::Frontmatter::parse(&parsed.frontmatter_yaml, &rel) {
291 Ok(frontmatter) => frontmatter,
292 Err(_) => continue, };
294 let wrapper = rel_to_string(&rel);
295 for decl in declared_assets(&fm) {
296 let norm = match normalize_asset_path(&decl.path) {
297 Ok(n) => n,
298 Err(e) => {
299 warnings.push(format!("{wrapper}: {e}"));
300 continue;
301 }
302 };
303 if is_markdown(&norm) {
304 warnings.push(format!(
305 "{wrapper}: asset path points at a markdown content file ({norm}); skipped"
306 ));
307 continue;
308 }
309 wrappers_by_path
310 .entry(norm.clone())
311 .or_default()
312 .insert(wrapper.clone());
313 let req = required_by_path.entry(norm.clone()).or_insert(false);
314 *req = *req || decl.required;
315 declared_paths.insert(norm);
316 }
317 match asset_supersession(&fm) {
318 Ok(Some(supersession)) => {
319 if let Some((prior, prior_wrapper)) = supersessions.get(&supersession.original) {
320 if prior != &supersession.replacement {
321 ambiguous_supersessions.insert(supersession.original.clone());
322 warnings.push(format!(
323 "{wrapper}: `{SUPERSEDES_ASSET_KEY}` conflicts with {prior_wrapper} for {}",
324 supersession.original
325 ));
326 }
327 } else {
328 supersessions.insert(
329 supersession.original,
330 (supersession.replacement, wrapper.clone()),
331 );
332 }
333 }
334 Ok(None) => {}
335 Err(error) => warnings.push(format!("{wrapper}: {error}")),
336 }
337 }
338
339 let cyclic_supersessions = supersession_cycle_members(&supersessions);
340 for original in &cyclic_supersessions {
341 if let Some((_, wrapper)) = supersessions.get(original) {
342 warnings.push(format!(
343 "{wrapper}: `{SUPERSEDES_ASSET_KEY}` participates in a replacement cycle at {original}"
344 ));
345 }
346 }
347 for (original, (replacement, wrapper)) in supersessions {
348 if ambiguous_supersessions.contains(&original) {
349 continue;
350 }
351 if cyclic_supersessions.contains(&original) {
352 continue;
353 }
354 if !wrappers_by_path.contains_key(&replacement) {
355 warnings.push(format!(
356 "{wrapper}: replacement asset `{replacement}` is not declared"
357 ));
358 continue;
359 }
360 if !wrappers_by_path.contains_key(&original) && !existing_by_path.contains_key(&original) {
361 warnings.push(format!(
362 "{wrapper}: superseded asset `{original}` is neither declared nor cataloged"
363 ));
364 continue;
365 }
366 wrappers_by_path
367 .entry(original.clone())
368 .or_default()
369 .insert(wrapper);
370 required_by_path.insert(original.clone(), false);
371 declared_paths.insert(original);
372 }
373
374 let mut records: Vec<AssetRecord> = Vec::new();
376 let mut hashed = 0usize;
377 let mut preserved = 0usize;
378 for (path, wrappers) in &wrappers_by_path {
379 let required = *required_by_path.get(path).unwrap_or(&true);
380 let wrappers: Vec<String> = wrappers.iter().cloned().collect();
381
382 let abs = match store.capability_relative(Path::new(path)) {
384 Ok(p) => p,
385 Err(_) => {
386 warnings.push(format!("{path}: escapes the store root; skipped"));
387 continue;
388 }
389 };
390
391 match store.open_regular(abs) {
392 Ok(file) => {
393 let (sha256, bytes) = sha256_file(file)?;
394 records.push(AssetRecord {
395 path: path.clone(),
396 sha256,
397 bytes,
398 media_type: media_type_for(path),
399 wrappers,
400 required,
401 });
402 hashed += 1;
403 }
404 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
405 if let Some(prev) = existing_by_path.get(path) {
406 records.push(AssetRecord {
409 path: path.clone(),
410 sha256: prev.sha256.clone(),
411 bytes: prev.bytes,
412 media_type: media_type_for(path),
413 wrappers,
414 required,
415 });
416 preserved += 1;
417 } else {
418 warnings.push(format!(
419 "{path}: declared but absent and never cataloged; cannot hash (skipped)"
420 ));
421 }
422 }
423 Err(error) => {
424 warnings.push(format!(
425 "{path}: is not a readable regular in-store file: {error}"
426 ));
427 }
428 }
429 }
430 records.sort_by(|a, b| a.path.cmp(&b.path));
431
432 let bytes: u64 = records.iter().fold(0u64, |a, r| a.saturating_add(r.bytes));
435 let cataloged = records.len();
436
437 let untracked_list = if untracked {
438 find_untracked(store, &declared_paths)?
439 } else {
440 Vec::new()
441 };
442
443 let mut wrote = false;
453 if !dry_run {
454 let canonical = serialize_manifest(&records);
455 let on_disk = match store
456 .read_bounded(Path::new(MANIFEST_FILE), crate::parser::MAX_DBMD_FILE_BYTES)
457 {
458 Ok(bytes) => bytes,
459 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
460 Err(error) => return Err(error.into()),
461 };
462 if on_disk != canonical.as_bytes() {
463 write_manifest(store, &records)?;
464 wrote = true;
465 }
466 }
467
468 Ok(ScanReport {
469 manifest: MANIFEST_FILE.to_string(),
470 cataloged,
471 hashed,
472 preserved,
473 bytes,
474 wrote,
475 dry_run,
476 warnings,
477 untracked: untracked_list,
478 })
479}
480
481fn supersession_cycle_members(
482 supersessions: &BTreeMap<String, (String, String)>,
483) -> BTreeSet<String> {
484 let mut cyclic = BTreeSet::new();
485 for origin in supersessions.keys() {
486 let mut order = Vec::new();
487 let mut positions = BTreeMap::new();
488 let mut current = origin.as_str();
489 while let Some((next, _)) = supersessions.get(current) {
490 if let Some(start) = positions.get(current).copied() {
491 cyclic.extend(order[start..].iter().cloned());
492 break;
493 }
494 positions.insert(current.to_string(), order.len());
495 order.push(current.to_string());
496 current = next;
497 }
498 }
499 cyclic
500}
501
502pub fn refresh(store: &Store, raw_path: &str, raw_wrapper: &str) -> crate::Result<RefreshReport> {
510 let path = normalize_asset_path(raw_path)
511 .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?;
512 if is_markdown(&path) {
513 return Err(std::io::Error::new(
514 std::io::ErrorKind::InvalidInput,
515 "asset path points at a markdown content file",
516 )
517 .into());
518 }
519
520 let wrapper_path = normalize_asset_path(raw_wrapper)
521 .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?;
522 if !is_markdown(&wrapper_path)
523 || !(wrapper_path.starts_with("sources/") || wrapper_path.starts_with("records/"))
524 {
525 return Err(std::io::Error::new(
526 std::io::ErrorKind::InvalidInput,
527 "wrapper must be a sources/ or records/ markdown content path",
528 )
529 .into());
530 }
531
532 let declaration = |wrapper: &str| -> crate::Result<Option<bool>> {
533 let text =
534 store.read_text_bounded(Path::new(wrapper), crate::parser::MAX_DBMD_FILE_BYTES)?;
535 let parsed = parser::split_frontmatter(&text, Path::new(wrapper))?;
536 let fm = parser::Frontmatter::parse(&parsed.frontmatter_yaml, Path::new(wrapper))?;
537 let mut found = false;
538 let mut required = false;
539 for declaration in declared_assets(&fm) {
540 let declared = normalize_asset_path(&declaration.path).map_err(|message| {
541 std::io::Error::new(std::io::ErrorKind::InvalidInput, message)
542 })?;
543 if declared == path {
544 found = true;
545 required |= declaration.required;
546 }
547 }
548 Ok(found.then_some(required))
549 };
550
551 let Some(requested_required) = declaration(&wrapper_path)? else {
552 return Err(std::io::Error::new(
553 std::io::ErrorKind::InvalidInput,
554 format!("wrapper `{wrapper_path}` does not declare asset `{path}`"),
555 )
556 .into());
557 };
558 let requested_supersession = {
559 let text = store
560 .read_text_bounded(Path::new(&wrapper_path), crate::parser::MAX_DBMD_FILE_BYTES)?;
561 let parsed = parser::split_frontmatter(&text, Path::new(&wrapper_path))?;
562 let fm = parser::Frontmatter::parse(&parsed.frontmatter_yaml, Path::new(&wrapper_path))?;
563 asset_supersession(&fm)
564 .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?
565 };
566 if requested_supersession
567 .as_ref()
568 .is_some_and(|supersession| supersession.replacement != path)
569 {
570 return Err(std::io::Error::new(
571 std::io::ErrorKind::InvalidInput,
572 format!("wrapper `{wrapper_path}` supersedes an asset with a different replacement"),
573 )
574 .into());
575 }
576
577 let existing = read_manifest(store)?;
578 let mut wrappers = BTreeSet::from([wrapper_path.clone()]);
579 if let Some(record) = existing.iter().find(|record| record.path == path) {
580 wrappers.extend(record.wrappers.iter().cloned());
581 }
582 let mut live_wrappers = Vec::new();
583 let mut required = requested_required;
584 for wrapper in wrappers {
585 if wrapper != wrapper_path && !store.regular_file_exists(Path::new(&wrapper))? {
586 continue;
590 }
591 match declaration(&wrapper) {
592 Ok(Some(wrapper_required)) => {
593 required |= wrapper_required;
594 live_wrappers.push(wrapper);
595 }
596 Ok(None) => {}
597 Err(error) => return Err(error),
598 }
599 }
600 live_wrappers.sort();
601
602 let asset_path = store.capability_relative(Path::new(&path))?;
603 let file = store.open_regular(asset_path)?;
604 let (sha256, bytes) = sha256_file(file)?;
605 let record = AssetRecord {
606 path: path.clone(),
607 sha256: sha256.clone(),
608 bytes,
609 media_type: media_type_for(&path),
610 wrappers: live_wrappers.clone(),
611 required,
612 };
613 let mut next = existing;
614 next.retain(|candidate| candidate.path != path);
615 next.push(record);
616 let mut superseded_assets = Vec::new();
617 if let Some(supersession) = requested_supersession {
618 let original = next
619 .iter_mut()
620 .find(|candidate| candidate.path == supersession.original)
621 .ok_or_else(|| {
622 std::io::Error::new(
623 std::io::ErrorKind::InvalidInput,
624 format!(
625 "superseded asset `{}` has no existing manifest row; run `dbmd assets scan` first",
626 supersession.original
627 ),
628 )
629 })?;
630 original.required = false;
631 if !original.wrappers.contains(&wrapper_path) {
632 original.wrappers.push(wrapper_path.clone());
633 original.wrappers.sort();
634 }
635 superseded_assets.push(supersession.original);
636 }
637 next.sort_by(|left, right| left.path.cmp(&right.path));
638
639 let canonical = serialize_manifest(&next);
640 let on_disk =
641 match store.read_bounded(Path::new(MANIFEST_FILE), crate::parser::MAX_DBMD_FILE_BYTES) {
642 Ok(bytes) => bytes,
643 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
644 Err(error) => return Err(error.into()),
645 };
646 let wrote = on_disk != canonical.as_bytes();
647 if wrote {
648 write_manifest(store, &next)?;
649 }
650
651 Ok(RefreshReport {
652 manifest: MANIFEST_FILE.to_string(),
653 path,
654 sha256,
655 bytes,
656 wrappers: live_wrappers,
657 required,
658 superseded_assets,
659 wrote,
660 })
661}
662
663pub fn refresh_wrapper(store: &Store, raw_wrapper: &str) -> crate::Result<RefreshWrapperReport> {
675 let wrapper_path = normalize_asset_path(raw_wrapper)
676 .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?;
677 if !is_markdown(&wrapper_path)
678 || !(wrapper_path.starts_with("sources/") || wrapper_path.starts_with("records/"))
679 {
680 return Err(std::io::Error::new(
681 std::io::ErrorKind::InvalidInput,
682 "wrapper must be a sources/ or records/ markdown content path",
683 )
684 .into());
685 }
686
687 let wrapper_declarations = |wrapper: &str| -> crate::Result<BTreeMap<String, bool>> {
688 let text =
689 store.read_text_bounded(Path::new(wrapper), crate::parser::MAX_DBMD_FILE_BYTES)?;
690 let parsed = parser::split_frontmatter(&text, Path::new(wrapper))?;
691 let fm = parser::Frontmatter::parse(&parsed.frontmatter_yaml, Path::new(wrapper))?;
692 if asset_supersession(&fm)
693 .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?
694 .is_some()
695 {
696 return Err(std::io::Error::new(
697 std::io::ErrorKind::InvalidInput,
698 "refresh-wrapper does not accept supersedes-asset; use assets refresh for that replacement",
699 )
700 .into());
701 }
702 let mut declarations = BTreeMap::new();
703 for declaration in declared_assets(&fm) {
704 let path = normalize_asset_path(&declaration.path).map_err(|message| {
705 std::io::Error::new(std::io::ErrorKind::InvalidInput, message)
706 })?;
707 if is_markdown(&path) {
708 return Err(std::io::Error::new(
709 std::io::ErrorKind::InvalidInput,
710 format!("asset path points at a markdown content file: {path}"),
711 )
712 .into());
713 }
714 let required = declarations.entry(path).or_insert(false);
715 *required |= declaration.required;
716 }
717 Ok(declarations)
718 };
719
720 let requested = wrapper_declarations(&wrapper_path)?;
724
725 let existing = read_manifest(store)?;
726 let existing_by_path: BTreeMap<String, AssetRecord> = existing
727 .iter()
728 .cloned()
729 .map(|record| (record.path.clone(), record))
730 .collect();
731 let old_paths: BTreeSet<String> = existing
732 .iter()
733 .filter(|record| record.wrappers.contains(&wrapper_path))
734 .map(|record| record.path.clone())
735 .collect();
736 let requested_paths: BTreeSet<String> = requested.keys().cloned().collect();
737
738 let mut wrapper_cache: BTreeMap<String, Option<BTreeMap<String, bool>>> = BTreeMap::new();
739 let mut live_other_declaration = |wrapper: &str, path: &str| -> crate::Result<Option<bool>> {
740 if !wrapper_cache.contains_key(wrapper) {
741 let declarations = if store.regular_file_exists(Path::new(wrapper))? {
742 Some(wrapper_declarations(wrapper)?)
743 } else {
744 None
745 };
746 wrapper_cache.insert(wrapper.to_string(), declarations);
747 }
748 Ok(wrapper_cache
749 .get(wrapper)
750 .and_then(Option::as_ref)
751 .and_then(|declarations| declarations.get(path).copied()))
752 };
753
754 let mut next = Vec::new();
755 for mut record in existing.iter().cloned() {
756 if requested.contains_key(&record.path) {
757 continue;
758 }
759 if record.wrappers.contains(&wrapper_path) {
760 let mut live_wrappers = Vec::new();
761 let mut required = false;
762 for wrapper in &record.wrappers {
763 if wrapper == &wrapper_path {
764 continue;
765 }
766 if let Some(wrapper_required) = live_other_declaration(wrapper, &record.path)? {
767 live_wrappers.push(wrapper.clone());
768 required |= wrapper_required;
769 }
770 }
771 if live_wrappers.is_empty() {
772 continue;
773 }
774 live_wrappers.sort();
775 record.wrappers = live_wrappers;
776 record.required = required;
777 }
778 next.push(record);
779 }
780
781 let mut hashed = 0usize;
782 let mut preserved = 0usize;
783 let mut bytes_total = 0u64;
784 for (path, requested_required) in &requested {
785 let mut wrappers = vec![wrapper_path.clone()];
786 let mut required = *requested_required;
787 if let Some(existing_record) = existing_by_path.get(path) {
788 for wrapper in &existing_record.wrappers {
789 if wrapper == &wrapper_path {
790 continue;
791 }
792 if let Some(wrapper_required) = live_other_declaration(wrapper, path)? {
793 wrappers.push(wrapper.clone());
794 required |= wrapper_required;
795 }
796 }
797 }
798 wrappers.sort();
799 wrappers.dedup();
800
801 let (sha256, bytes, media_type) = match store.open_regular(Path::new(path)) {
802 Ok(file) => {
803 let (sha256, bytes) = sha256_file(file)?;
804 hashed += 1;
805 (sha256, bytes, media_type_for(path))
806 }
807 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
808 let existing_record = existing_by_path.get(path).ok_or_else(|| {
809 std::io::Error::new(
810 std::io::ErrorKind::NotFound,
811 format!(
812 "declared asset `{path}` is absent and has no manifest row to preserve"
813 ),
814 )
815 })?;
816 preserved += 1;
817 (
818 existing_record.sha256.clone(),
819 existing_record.bytes,
820 existing_record.media_type.clone(),
821 )
822 }
823 Err(error) => return Err(error.into()),
824 };
825 bytes_total = bytes_total.saturating_add(bytes);
826 next.push(AssetRecord {
827 path: path.clone(),
828 sha256,
829 bytes,
830 media_type,
831 wrappers,
832 required,
833 });
834 }
835
836 next.sort_by(|left, right| left.path.cmp(&right.path));
837 let canonical = serialize_manifest(&next);
838 let on_disk =
839 match store.read_bounded(Path::new(MANIFEST_FILE), crate::parser::MAX_DBMD_FILE_BYTES) {
840 Ok(bytes) => bytes,
841 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
842 Err(error) => return Err(error.into()),
843 };
844 let wrote = on_disk != canonical.as_bytes();
845 if wrote {
846 write_manifest(store, &next)?;
847 }
848
849 Ok(RefreshWrapperReport {
850 manifest: MANIFEST_FILE.to_string(),
851 wrapper: wrapper_path,
852 cataloged: requested.len(),
853 added: requested_paths.difference(&old_paths).count(),
854 removed: old_paths.difference(&requested_paths).count(),
855 hashed,
856 preserved,
857 bytes: bytes_total,
858 wrote,
859 })
860}
861
862pub fn verify(store: &Store, include_optional: bool, quick: bool) -> crate::Result<VerifyReport> {
872 let records = read_manifest(store)?;
873 let mut missing = Vec::new();
874 let mut corrupt = Vec::new();
875 let mut checked = 0usize;
876
877 for rec in &records {
878 if !rec.required && !include_optional {
879 continue;
880 }
881 checked += 1;
882 let abs = match store.capability_relative(Path::new(&rec.path)) {
883 Ok(p) => p,
884 Err(_) => {
885 corrupt.push(rec.path.clone());
887 continue;
888 }
889 };
890 let file = match store.open_regular(abs) {
891 Ok(file) => file,
892 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
893 missing.push(rec.path.clone());
894 continue;
895 }
896 Err(_) => {
897 corrupt.push(rec.path.clone());
898 continue;
899 }
900 };
901 if quick {
902 let len = file.metadata()?.len();
903 if len != rec.bytes {
904 corrupt.push(rec.path.clone());
905 }
906 } else {
907 let (sha, bytes) = sha256_file(file)?;
908 if sha != rec.sha256 || bytes != rec.bytes {
909 corrupt.push(rec.path.clone());
910 }
911 }
912 }
913
914 let ok = checked - missing.len() - corrupt.len();
915 let complete = missing.is_empty() && corrupt.is_empty();
916 Ok(VerifyReport {
917 mode: if quick { "quick" } else { "deep" }.to_string(),
918 checked,
919 ok,
920 missing,
921 corrupt,
922 complete,
923 })
924}
925
926pub fn status(store: &Store) -> crate::Result<StatusReport> {
934 let records = read_manifest(store)?;
935 let mut present = 0usize;
936 let mut missing = 0usize;
937 let mut required_missing = 0usize;
938 let mut optional_missing = 0usize;
939 let mut bytes_total = 0u64;
940 let mut bytes_missing = 0u64;
941 let mut assets = Vec::with_capacity(records.len());
942
943 for rec in &records {
944 bytes_total = bytes_total.saturating_add(rec.bytes);
949 let is_present = store.open_regular(Path::new(&rec.path)).is_ok();
957 let state = if is_present {
958 present += 1;
959 "present"
960 } else {
961 missing += 1;
962 bytes_missing = bytes_missing.saturating_add(rec.bytes);
963 if rec.required {
964 required_missing += 1;
965 } else {
966 optional_missing += 1;
967 }
968 "missing"
969 };
970 assets.push(AssetState {
971 path: rec.path.clone(),
972 sha256: rec.sha256.clone(),
973 bytes: rec.bytes,
974 required: rec.required,
975 state: state.to_string(),
976 });
977 }
978
979 Ok(StatusReport {
980 total: records.len(),
981 present,
982 missing,
983 required_missing,
984 optional_missing,
985 bytes_total,
986 bytes_missing,
987 assets,
988 })
989}
990
991pub fn paths(store: &Store) -> crate::Result<Vec<String>> {
1009 Ok(read_manifest(store)?
1010 .into_iter()
1011 .filter(|r| store.capability_relative(Path::new(&r.path)).is_ok())
1012 .map(|r| r.path)
1013 .collect())
1014}
1015
1016pub fn declared_assets(fm: &parser::Frontmatter) -> Vec<Declaration> {
1026 let mut out = Vec::new();
1027 if let Some(v) = fm.get("asset") {
1028 collect_declarations(&v, &mut out);
1029 }
1030 if let Some(v) = fm.get("assets") {
1031 collect_declarations(&v, &mut out);
1032 }
1033 out
1034}
1035
1036pub fn declarations_from_yaml_map(map: &BTreeMap<String, Value>) -> Vec<Declaration> {
1040 let mut out = Vec::new();
1041 if let Some(v) = map.get("asset") {
1042 collect_declarations(v, &mut out);
1043 }
1044 if let Some(v) = map.get("assets") {
1045 collect_declarations(v, &mut out);
1046 }
1047 out
1048}
1049
1050pub fn asset_supersession(fm: &parser::Frontmatter) -> Result<Option<AssetSupersession>, String> {
1054 asset_supersession_from_parts(fm.get(SUPERSEDES_ASSET_KEY).as_ref(), declared_assets(fm))
1055}
1056
1057pub fn asset_supersession_from_yaml_map(
1059 map: &BTreeMap<String, Value>,
1060) -> Result<Option<AssetSupersession>, String> {
1061 asset_supersession_from_parts(
1062 map.get(SUPERSEDES_ASSET_KEY),
1063 declarations_from_yaml_map(map),
1064 )
1065}
1066
1067fn asset_supersession_from_parts(
1068 value: Option<&Value>,
1069 declarations: Vec<Declaration>,
1070) -> Result<Option<AssetSupersession>, String> {
1071 let Some(value) = value else {
1072 return Ok(None);
1073 };
1074 let Value::String(original) = value else {
1075 return Err(format!("`{SUPERSEDES_ASSET_KEY}` must be one asset path"));
1076 };
1077 if declarations.len() != 1 || !declarations[0].required {
1078 return Err(format!(
1079 "a `{SUPERSEDES_ASSET_KEY}` wrapper must declare exactly one required replacement asset"
1080 ));
1081 }
1082 let original = normalize_asset_path(original)?;
1083 let replacement = normalize_asset_path(&declarations[0].path)?;
1084 if original == replacement {
1085 return Err(format!(
1086 "`{SUPERSEDES_ASSET_KEY}` cannot name the wrapper's replacement asset"
1087 ));
1088 }
1089 Ok(Some(AssetSupersession {
1090 original,
1091 replacement,
1092 }))
1093}
1094
1095fn collect_declarations(v: &Value, out: &mut Vec<Declaration>) {
1096 match v {
1097 Value::String(s) => out.push(Declaration {
1098 path: s.clone(),
1099 required: true,
1100 }),
1101 Value::Sequence(items) => {
1102 for item in items {
1103 match item {
1104 Value::String(s) => out.push(Declaration {
1105 path: s.clone(),
1106 required: true,
1107 }),
1108 Value::Mapping(m) => {
1109 let path = m
1110 .get(Value::String("path".to_string()))
1111 .and_then(|x| x.as_str())
1112 .map(|s| s.to_string());
1113 if let Some(path) = path {
1114 let required = m
1115 .get(Value::String("required".to_string()))
1116 .and_then(|x| x.as_bool())
1117 .unwrap_or(true);
1118 out.push(Declaration { path, required });
1119 }
1120 }
1121 _ => {}
1122 }
1123 }
1124 }
1125 _ => {}
1126 }
1127}
1128
1129pub fn normalize_asset_path(raw: &str) -> Result<String, String> {
1145 let trimmed = raw.trim();
1146 if trimmed.is_empty() {
1147 return Err("empty asset path".to_string());
1148 }
1149 let p = Path::new(trimmed);
1150 if p.is_absolute() {
1151 return Err(format!("absolute asset path not allowed: {raw}"));
1152 }
1153 let mut normal: Vec<&std::ffi::OsStr> = Vec::new();
1154 for c in p.components() {
1155 match c {
1156 Component::ParentDir => return Err(format!("`..` not allowed in asset path: {raw}")),
1157 Component::Prefix(_) | Component::RootDir => {
1158 return Err(format!("asset path escapes the store: {raw}"))
1159 }
1160 Component::CurDir => {}
1163 Component::Normal(seg) => normal.push(seg),
1164 }
1165 }
1166 if normal.is_empty() {
1167 return Err(format!("asset path names no file: {raw}"));
1169 }
1170 let joined: PathBuf = normal.into_iter().collect();
1171 Ok(joined.to_string_lossy().replace('\\', "/"))
1172}
1173
1174fn is_markdown(path: &str) -> bool {
1175 Path::new(path)
1176 .extension()
1177 .and_then(|e| e.to_str())
1178 .map(|e| e.eq_ignore_ascii_case("md"))
1179 .unwrap_or(false)
1180}
1181
1182fn rel_to_string(p: &Path) -> String {
1183 p.to_string_lossy().replace('\\', "/")
1184}
1185
1186fn sha256_file(mut f: std::fs::File) -> std::io::Result<(String, u64)> {
1189 let mut hasher = Sha256::new();
1190 let mut buf = [0u8; 65536];
1191 let mut total: u64 = 0;
1192 loop {
1193 let n = f.read(&mut buf)?;
1194 if n == 0 {
1195 break;
1196 }
1197 hasher.update(&buf[..n]);
1198 total += n as u64;
1199 }
1200 let digest = hasher.finalize();
1201 let mut hex = String::with_capacity(64);
1202 for b in digest.iter() {
1203 let _ = write!(hex, "{b:02x}");
1204 }
1205 Ok((hex, total))
1206}
1207
1208fn media_type_for(path: &str) -> String {
1212 let ext = Path::new(path)
1213 .extension()
1214 .and_then(|e| e.to_str())
1215 .unwrap_or("")
1216 .to_ascii_lowercase();
1217 let mt = match ext.as_str() {
1218 "pdf" => "application/pdf",
1219 "png" => "image/png",
1220 "jpg" | "jpeg" => "image/jpeg",
1221 "gif" => "image/gif",
1222 "webp" => "image/webp",
1223 "svg" => "image/svg+xml",
1224 "tiff" | "tif" => "image/tiff",
1225 "mp4" => "video/mp4",
1226 "mov" => "video/quicktime",
1227 "webm" => "video/webm",
1228 "mkv" => "video/x-matroska",
1229 "mp3" => "audio/mpeg",
1230 "wav" => "audio/wav",
1231 "m4a" => "audio/mp4",
1232 "flac" => "audio/flac",
1233 "zip" => "application/zip",
1234 "gz" | "tgz" => "application/gzip",
1235 "tar" => "application/x-tar",
1236 "csv" => "text/csv",
1237 "tsv" => "text/tab-separated-values",
1238 "json" => "application/json",
1239 "xml" => "application/xml",
1240 "txt" => "text/plain",
1241 "vtt" => "text/vtt",
1242 "srt" => "application/x-subrip",
1243 "html" | "htm" => "text/html",
1244 "epub" => "application/epub+zip",
1245 "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
1246 "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
1247 "pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
1248 "doc" => "application/msword",
1249 "xls" => "application/vnd.ms-excel",
1250 "ppt" => "application/vnd.ms-powerpoint",
1251 _ => "application/octet-stream",
1252 };
1253 mt.to_string()
1254}
1255
1256fn find_untracked(store: &Store, declared: &BTreeSet<String>) -> crate::Result<Vec<String>> {
1260 let mut out = Vec::new();
1261 let paths = match store.walk_regular_files(Path::new("sources")) {
1262 Ok(paths) => paths,
1263 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(out),
1264 Err(error) => return Err(error.into()),
1265 };
1266 for path in paths {
1267 let name = match path.file_name().and_then(|name| name.to_str()) {
1268 Some(name) => name,
1269 None => continue,
1270 };
1271 if is_markdown(name) || name == "index.jsonl" {
1272 continue;
1273 }
1274 let rel = rel_to_string(&path);
1275 if !declared.contains(&rel) {
1276 out.push(rel);
1277 }
1278 }
1279 out.sort();
1280 Ok(out)
1281}
1282
1283#[cfg(test)]
1284mod tests {
1285 use super::*;
1286
1287 #[test]
1288 fn supersession_cycles_exclude_only_cycle_members() {
1289 let supersessions = BTreeMap::from([
1290 ("a".to_string(), ("b".to_string(), "a.md".to_string())),
1291 ("b".to_string(), ("a".to_string(), "b.md".to_string())),
1292 (
1293 "before".to_string(),
1294 ("a".to_string(), "before.md".to_string()),
1295 ),
1296 (
1297 "clean".to_string(),
1298 ("next".to_string(), "clean.md".to_string()),
1299 ),
1300 ]);
1301 assert_eq!(
1302 supersession_cycle_members(&supersessions),
1303 BTreeSet::from(["a".to_string(), "b".to_string()])
1304 );
1305 }
1306
1307 #[test]
1313 fn normalize_asset_path_folds_curdir_and_rejects_traversal() {
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 assert_eq!(
1323 normalize_asset_path("sources/./x.pdf").unwrap(),
1324 "sources/x.pdf"
1325 );
1326 assert_eq!(
1327 normalize_asset_path("sources/x.pdf/").unwrap(),
1328 "sources/x.pdf"
1329 );
1330
1331 assert!(normalize_asset_path("../outside.txt").is_err());
1333 assert!(normalize_asset_path("sources/../../etc/passwd").is_err());
1334 assert!(normalize_asset_path("/abs/x.pdf").is_err());
1335 assert!(normalize_asset_path(".").is_err());
1337 assert!(normalize_asset_path("./").is_err());
1338 assert!(normalize_asset_path("").is_err());
1339 }
1340
1341 #[test]
1346 fn status_and_scan_saturate_on_overflowing_manifest_bytes() {
1347 let tmp = tempfile::TempDir::new().unwrap();
1348 let root = tmp.path();
1349 std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n# store\n").unwrap();
1350 std::fs::write(
1352 root.join("assets.jsonl"),
1353 "{\"path\":\"records/a.bin\",\"sha256\":\"x\",\"bytes\":18446744073709551615,\
1354\"media_type\":\"application/octet-stream\",\"wrappers\":[\"records/w.md\"],\"required\":true}\n\
1355{\"path\":\"records/b.bin\",\"sha256\":\"y\",\"bytes\":1,\
1356\"media_type\":\"application/octet-stream\",\"wrappers\":[\"records/w.md\"],\"required\":true}\n",
1357 )
1358 .unwrap();
1359 let store = Store::from_root_and_config(root, crate::parser::Config::default()).unwrap();
1360
1361 let report = status(&store).expect("status is non-failing on a poisoned manifest");
1364 assert_eq!(
1365 report.bytes_total,
1366 u64::MAX,
1367 "byte total must saturate, not wrap"
1368 );
1369 assert_eq!(
1370 report.bytes_missing,
1371 u64::MAX,
1372 "missing bytes must saturate too"
1373 );
1374 assert_eq!(report.total, 2);
1375
1376 scan(&store, true, false).expect("scan must not overflow on a poisoned manifest");
1378 }
1379
1380 fn store_with_one_asset() -> (tempfile::TempDir, Store, String) {
1383 let tmp = tempfile::TempDir::new().unwrap();
1384 let root = tmp.path();
1385 std::fs::create_dir_all(root.join("sources")).unwrap();
1386 std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n# store\n").unwrap();
1387 std::fs::write(
1388 root.join("sources/a.pdf.md"),
1389 "---\ntype: pdf-source\nsummary: x\nasset: sources/a.pdf\n---\nbody\n",
1390 )
1391 .unwrap();
1392 std::fs::write(root.join("sources/a.pdf"), b"PDFBYTES").unwrap();
1393 let store = Store::from_root_and_config(root, crate::parser::Config::default()).unwrap();
1394 let report = scan(&store, false, false).unwrap();
1395 assert!(
1396 report.wrote,
1397 "first scan writes the manifest; report: {report:?}"
1398 );
1399 let canonical = std::fs::read_to_string(root.join(MANIFEST_FILE)).unwrap();
1400 (tmp, store, canonical)
1401 }
1402
1403 #[test]
1412 fn scan_recompacts_duplicate_line_manifest() {
1413 let (_tmp, store, canonical) = store_with_one_asset();
1414 let abs = store.root.join(MANIFEST_FILE);
1415
1416 std::fs::write(&abs, format!("{canonical}{canonical}")).unwrap();
1418 assert_eq!(std::fs::read_to_string(&abs).unwrap().lines().count(), 2);
1419
1420 let report = scan(&store, false, false).unwrap();
1421 assert!(
1422 report.wrote,
1423 "a non-canonical (duplicate-line) manifest must be recompacted and reported as updated"
1424 );
1425 let after = std::fs::read_to_string(&abs).unwrap();
1426 assert_eq!(
1427 after.lines().count(),
1428 1,
1429 "duplicate lines must collapse to the single canonical line"
1430 );
1431 assert_eq!(
1432 after, canonical,
1433 "scan must restore the exact canonical bytes"
1434 );
1435 }
1436
1437 #[test]
1441 fn scan_recompacts_noncanonical_byte_layout() {
1442 let (_tmp, store, canonical) = store_with_one_asset();
1443 let abs = store.root.join(MANIFEST_FILE);
1444
1445 std::fs::write(&abs, canonical.trim_end_matches('\n')).unwrap();
1447 let report = scan(&store, false, false).unwrap();
1448 assert!(
1449 report.wrote,
1450 "a manifest missing its trailing newline must be recompacted"
1451 );
1452 assert_eq!(
1453 std::fs::read_to_string(&abs).unwrap(),
1454 canonical,
1455 "scan must restore the canonical trailing newline"
1456 );
1457 }
1458
1459 #[test]
1469 fn paths_omits_store_escaping_records() {
1470 let tmp = tempfile::TempDir::new().unwrap();
1471 let root = tmp.path();
1472 std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n# store\n").unwrap();
1473 std::fs::write(
1475 root.join("assets.jsonl"),
1476 "{\"path\":\"sources/legit.pdf\",\"sha256\":\"a\",\"bytes\":9,\
1477\"media_type\":\"application/pdf\",\"wrappers\":[\"sources/legit.pdf.md\"],\"required\":true}\n\
1478{\"path\":\"../../../../../../etc/passwd\",\"sha256\":\"b\",\"bytes\":4096,\
1479\"media_type\":\"text/plain\",\"wrappers\":[\"sources/legit.pdf.md\"],\"required\":false}\n\
1480{\"path\":\"/etc/hosts\",\"sha256\":\"c\",\"bytes\":4096,\
1481\"media_type\":\"text/plain\",\"wrappers\":[\"sources/legit.pdf.md\"],\"required\":false}\n",
1482 )
1483 .unwrap();
1484 let store = Store::from_root_and_config(root, crate::parser::Config::default()).unwrap();
1485
1486 let out = paths(&store).expect("paths is non-failing on a poisoned manifest");
1487 assert_eq!(
1488 out,
1489 vec!["sources/legit.pdf".to_string()],
1490 "only the safe in-store path is emitted; escaping paths are omitted"
1491 );
1492 assert!(
1493 !out.iter().any(|p| p.starts_with('/') || p.contains("..")),
1494 "no absolute or `..` path may ever leak from `paths`: {out:?}"
1495 );
1496 }
1497
1498 #[test]
1501 fn paths_passes_a_clean_manifest_through_unchanged() {
1502 let (_tmp, store, _canonical) = store_with_one_asset();
1503 let out = paths(&store).expect("paths over a clean manifest");
1504 assert_eq!(out, vec!["sources/a.pdf".to_string()]);
1505 }
1506
1507 #[test]
1511 fn scan_canonical_manifest_is_left_untouched() {
1512 let (_tmp, store, canonical) = store_with_one_asset();
1513 let abs = store.root.join(MANIFEST_FILE);
1514
1515 let report = scan(&store, false, false).unwrap();
1516 assert!(
1517 !report.wrote,
1518 "a canonical, unchanged manifest must not be rewritten"
1519 );
1520 assert_eq!(
1521 std::fs::read_to_string(&abs).unwrap(),
1522 canonical,
1523 "a no-op rescan must leave the manifest byte-identical"
1524 );
1525 }
1526
1527 #[test]
1528 fn refresh_wrapper_reconciles_one_generated_asset_set_in_one_manifest_write() {
1529 let tmp = tempfile::TempDir::new().unwrap();
1530 let root = tmp.path();
1531 std::fs::create_dir_all(root.join("records/package")).unwrap();
1532 std::fs::create_dir_all(root.join("sources/package/objects")).unwrap();
1533 std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
1534 let wrapper = "records/package/current.md";
1535 std::fs::write(
1536 root.join(wrapper),
1537 "---\ntype: package\nsummary: current\nassets:\n - sources/package/objects/a.blob\n - sources/package/objects/b.blob\n---\n",
1538 )
1539 .unwrap();
1540 std::fs::write(root.join("sources/package/objects/a.blob"), b"a").unwrap();
1541 std::fs::write(root.join("sources/package/objects/b.blob"), b"bb").unwrap();
1542 let store = Store::from_root_and_config(root, crate::parser::Config::default()).unwrap();
1543
1544 let first = refresh_wrapper(&store, wrapper).unwrap();
1545 assert_eq!(first.cataloged, 2);
1546 assert_eq!(first.added, 2);
1547 assert_eq!(first.removed, 0);
1548 assert_eq!(first.hashed, 2);
1549 assert_eq!(first.bytes, 3);
1550 assert!(first.wrote);
1551
1552 let no_change = refresh_wrapper(&store, wrapper).unwrap();
1553 assert!(!no_change.wrote);
1554 assert_eq!(no_change.added, 0);
1555 assert_eq!(no_change.removed, 0);
1556
1557 std::fs::write(
1558 root.join(wrapper),
1559 "---\ntype: package\nsummary: current\nassets:\n - sources/package/objects/b.blob\n - sources/package/objects/c.blob\n---\n",
1560 )
1561 .unwrap();
1562 std::fs::write(root.join("sources/package/objects/c.blob"), b"ccc").unwrap();
1563 let changed = refresh_wrapper(&store, wrapper).unwrap();
1564 assert_eq!(changed.cataloged, 2);
1565 assert_eq!(changed.added, 1);
1566 assert_eq!(changed.removed, 1);
1567 assert_eq!(changed.bytes, 5);
1568 assert!(changed.wrote);
1569
1570 let records = read_manifest(&store).unwrap();
1571 let paths: Vec<&str> = records.iter().map(|record| record.path.as_str()).collect();
1572 assert_eq!(
1573 paths,
1574 vec![
1575 "sources/package/objects/b.blob",
1576 "sources/package/objects/c.blob"
1577 ]
1578 );
1579
1580 std::fs::write(
1581 root.join(wrapper),
1582 "---\ntype: package\nsummary: current\n---\n",
1583 )
1584 .unwrap();
1585 let cleared = refresh_wrapper(&store, wrapper).unwrap();
1586 assert_eq!(cleared.cataloged, 0);
1587 assert_eq!(cleared.added, 0);
1588 assert_eq!(cleared.removed, 2);
1589 assert!(cleared.wrote);
1590 assert!(read_manifest(&store).unwrap().is_empty());
1591 }
1592
1593 #[cfg(unix)]
1594 #[test]
1595 fn manifest_membership_reads_opened_root_after_path_replacement() {
1596 use std::os::unix::fs::symlink;
1597
1598 let sandbox = tempfile::tempdir().unwrap();
1599 let root = sandbox.path().join("store");
1600 std::fs::create_dir_all(&root).unwrap();
1601 std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
1602 std::fs::write(
1603 root.join(MANIFEST_FILE),
1604 "{\"path\":\"sources/owned.pdf\",\"sha256\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"bytes\":1,\"media_type\":\"application/pdf\",\"wrappers\":[],\"required\":true}\n",
1605 )
1606 .unwrap();
1607 let store = Store::open_strict(&root).unwrap();
1608 let detached = sandbox.path().join("detached");
1609 std::fs::rename(&root, &detached).unwrap();
1610
1611 let replacement = sandbox.path().join("replacement");
1612 std::fs::create_dir_all(&replacement).unwrap();
1613 std::fs::write(replacement.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
1614 std::fs::write(
1615 replacement.join(MANIFEST_FILE),
1616 "{\"path\":\"sources/replacement-secret.pdf\",\"sha256\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"bytes\":1,\"media_type\":\"application/pdf\",\"wrappers\":[],\"required\":true}\n",
1617 )
1618 .unwrap();
1619 symlink(&replacement, &root).unwrap();
1620
1621 assert_eq!(paths(&store).unwrap(), vec!["sources/owned.pdf"]);
1622 }
1623}