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::projection::ProjectionPolicy;
47use crate::store::Store;
48
49pub const MANIFEST_FILE: &str = "assets.jsonl";
51
52pub const SUPERSEDES_ASSET_KEY: &str = "supersedes-asset";
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct AssetRecord {
64 pub path: String,
67 pub sha256: String,
70 pub bytes: u64,
72 pub media_type: String,
74 pub wrappers: Vec<String>,
77 pub required: bool,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct Declaration {
85 pub path: String,
87 pub required: bool,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct AssetSupersession {
95 pub original: String,
97 pub replacement: String,
99}
100
101#[derive(Debug, Serialize)]
107pub struct ScanReport {
108 pub manifest: String,
109 pub cataloged: usize,
110 pub hashed: usize,
111 pub preserved: usize,
112 pub bytes: u64,
113 pub wrote: bool,
114 pub dry_run: bool,
115 pub warnings: Vec<String>,
116 pub untracked: Vec<String>,
117}
118
119#[derive(Debug, Serialize)]
123pub struct RefreshReport {
124 pub manifest: String,
125 pub path: String,
126 pub sha256: String,
127 pub bytes: u64,
128 pub wrappers: Vec<String>,
129 pub required: bool,
130 pub superseded_assets: Vec<String>,
132 pub wrote: bool,
133}
134
135#[derive(Debug, Serialize)]
139pub struct RefreshWrapperReport {
140 pub manifest: String,
141 pub wrapper: String,
142 pub cataloged: usize,
143 pub added: usize,
144 pub removed: usize,
145 pub hashed: usize,
146 pub preserved: usize,
147 pub bytes: u64,
148 pub wrote: bool,
149}
150
151#[derive(Debug, Serialize)]
153pub struct AssetState {
154 pub path: String,
155 pub sha256: String,
156 pub bytes: u64,
157 pub required: bool,
158 pub state: String,
160}
161
162#[derive(Debug, Serialize)]
164pub struct StatusReport {
165 pub total: usize,
166 pub present: usize,
167 pub missing: usize,
168 pub required_missing: usize,
169 pub optional_missing: usize,
170 pub bytes_total: u64,
171 pub bytes_missing: u64,
172 pub assets: Vec<AssetState>,
173}
174
175#[derive(Debug, Serialize)]
177pub struct VerifyReport {
178 pub mode: String,
179 pub checked: usize,
180 pub ok: usize,
181 pub missing: Vec<String>,
182 pub corrupt: Vec<String>,
183 #[serde(skip_serializing_if = "Vec::is_empty")]
185 pub projected_missing: Vec<String>,
186 #[serde(skip_serializing_if = "Option::is_none")]
190 pub projection_complete: Option<bool>,
191 pub complete: bool,
193}
194
195pub fn read_manifest(store: &Store) -> crate::Result<Vec<AssetRecord>> {
204 let text = match store
205 .read_text_bounded(Path::new(MANIFEST_FILE), crate::parser::MAX_DBMD_FILE_BYTES)
206 {
207 Ok(text) => text,
208 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
209 Err(error) => return Err(error.into()),
210 };
211 let mut by_path: BTreeMap<String, AssetRecord> = BTreeMap::new();
212 for (i, line) in text.lines().enumerate() {
213 if line.trim().is_empty() {
214 continue;
215 }
216 let rec: AssetRecord = serde_json::from_str(line).map_err(|e| {
217 std::io::Error::new(
218 std::io::ErrorKind::InvalidData,
219 format!("{MANIFEST_FILE} line {}: {e}", i + 1),
220 )
221 })?;
222 by_path.insert(rec.path.clone(), rec);
223 }
224 Ok(by_path.into_values().collect())
225}
226
227fn serialize_manifest(records: &[AssetRecord]) -> String {
234 if records.is_empty() {
235 return String::new();
236 }
237 let mut sorted = records.to_vec();
238 sorted.sort_by(|a, b| a.path.cmp(&b.path));
239 let mut out = String::new();
240 for rec in &sorted {
241 let line = serde_json::to_string(rec).expect("AssetRecord serializes");
242 out.push_str(&line);
243 out.push('\n');
244 }
245 out
246}
247
248pub fn write_manifest(store: &Store, records: &[AssetRecord]) -> crate::Result<()> {
252 let abs = Path::new(MANIFEST_FILE);
253 let out = serialize_manifest(records);
254 if out.is_empty() {
255 match store.remove_file(abs) {
256 Ok(()) => {}
257 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
258 Err(error) => return Err(error.into()),
259 }
260 return Ok(());
261 }
262 store.write_atomic(abs, out.as_bytes())?;
263 Ok(())
264}
265
266pub fn scan(store: &Store, dry_run: bool, untracked: bool) -> crate::Result<ScanReport> {
279 let existing_by_path: BTreeMap<String, AssetRecord> = read_manifest(store)
283 .unwrap_or_default()
284 .into_iter()
285 .map(|r| (r.path.clone(), r))
286 .collect();
287
288 let mut wrappers_by_path: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
290 let mut required_by_path: BTreeMap<String, bool> = BTreeMap::new();
291 let mut declared_paths: BTreeSet<String> = BTreeSet::new();
292 let mut supersessions: BTreeMap<String, (String, String)> = BTreeMap::new();
293 let mut ambiguous_supersessions: BTreeSet<String> = BTreeSet::new();
294 let mut warnings: Vec<String> = Vec::new();
295
296 for rel in store.walk()? {
297 let text = match store.read_text_bounded(&rel, parser::MAX_DBMD_FILE_BYTES) {
298 Ok(text) => text,
299 Err(_) => continue,
300 };
301 let parsed = match parser::split_frontmatter(&text, &rel) {
302 Ok(parsed) => parsed,
303 Err(_) => continue,
304 };
305 let fm = match parser::Frontmatter::parse(&parsed.frontmatter_yaml, &rel) {
306 Ok(frontmatter) => frontmatter,
307 Err(_) => continue, };
309 let wrapper = rel_to_string(&rel);
310 for decl in declared_assets(&fm) {
311 let norm = match normalize_asset_path(&decl.path) {
312 Ok(n) => n,
313 Err(e) => {
314 warnings.push(format!("{wrapper}: {e}"));
315 continue;
316 }
317 };
318 wrappers_by_path
319 .entry(norm.clone())
320 .or_default()
321 .insert(wrapper.clone());
322 let req = required_by_path.entry(norm.clone()).or_insert(false);
323 *req = *req || decl.required;
324 declared_paths.insert(norm);
325 }
326 match asset_supersession(&fm) {
327 Ok(Some(supersession)) => {
328 if let Some((prior, prior_wrapper)) = supersessions.get(&supersession.original) {
329 if prior != &supersession.replacement {
330 ambiguous_supersessions.insert(supersession.original.clone());
331 warnings.push(format!(
332 "{wrapper}: `{SUPERSEDES_ASSET_KEY}` conflicts with {prior_wrapper} for {}",
333 supersession.original
334 ));
335 }
336 } else {
337 supersessions.insert(
338 supersession.original,
339 (supersession.replacement, wrapper.clone()),
340 );
341 }
342 }
343 Ok(None) => {}
344 Err(error) => warnings.push(format!("{wrapper}: {error}")),
345 }
346 }
347
348 let cyclic_supersessions = supersession_cycle_members(&supersessions);
349 for original in &cyclic_supersessions {
350 if let Some((_, wrapper)) = supersessions.get(original) {
351 warnings.push(format!(
352 "{wrapper}: `{SUPERSEDES_ASSET_KEY}` participates in a replacement cycle at {original}"
353 ));
354 }
355 }
356 for (original, (replacement, wrapper)) in supersessions {
357 if ambiguous_supersessions.contains(&original) {
358 continue;
359 }
360 if cyclic_supersessions.contains(&original) {
361 continue;
362 }
363 if !wrappers_by_path.contains_key(&replacement) {
364 warnings.push(format!(
365 "{wrapper}: replacement asset `{replacement}` is not declared"
366 ));
367 continue;
368 }
369 if !wrappers_by_path.contains_key(&original) && !existing_by_path.contains_key(&original) {
370 warnings.push(format!(
371 "{wrapper}: superseded asset `{original}` is neither declared nor cataloged"
372 ));
373 continue;
374 }
375 wrappers_by_path
376 .entry(original.clone())
377 .or_default()
378 .insert(wrapper);
379 required_by_path.insert(original.clone(), false);
380 declared_paths.insert(original);
381 }
382
383 let mut records: Vec<AssetRecord> = Vec::new();
385 let mut hashed = 0usize;
386 let mut preserved = 0usize;
387 for (path, wrappers) in &wrappers_by_path {
388 let required = *required_by_path.get(path).unwrap_or(&true);
389 let wrappers: Vec<String> = wrappers.iter().cloned().collect();
390
391 let abs = match store.capability_relative(Path::new(path)) {
393 Ok(p) => p,
394 Err(_) => {
395 warnings.push(format!("{path}: escapes the store root; skipped"));
396 continue;
397 }
398 };
399
400 match store.open_regular(abs) {
401 Ok(file) => {
402 let (sha256, bytes) = sha256_file(file)?;
403 records.push(AssetRecord {
404 path: path.clone(),
405 sha256,
406 bytes,
407 media_type: media_type_for(path),
408 wrappers,
409 required,
410 });
411 hashed += 1;
412 }
413 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
414 if let Some(prev) = existing_by_path.get(path) {
415 records.push(AssetRecord {
418 path: path.clone(),
419 sha256: prev.sha256.clone(),
420 bytes: prev.bytes,
421 media_type: media_type_for(path),
422 wrappers,
423 required,
424 });
425 preserved += 1;
426 } else {
427 warnings.push(format!(
428 "{path}: declared but absent and never cataloged; cannot hash (skipped)"
429 ));
430 }
431 }
432 Err(error) => {
433 warnings.push(format!(
434 "{path}: is not a readable regular in-store file: {error}"
435 ));
436 }
437 }
438 }
439 records.sort_by(|a, b| a.path.cmp(&b.path));
440
441 let bytes: u64 = records.iter().fold(0u64, |a, r| a.saturating_add(r.bytes));
444 let cataloged = records.len();
445
446 let untracked_list = if untracked {
447 find_untracked(store, &declared_paths)?
448 } else {
449 Vec::new()
450 };
451
452 let mut wrote = false;
462 if !dry_run {
463 let canonical = serialize_manifest(&records);
464 let on_disk = match store
465 .read_bounded(Path::new(MANIFEST_FILE), crate::parser::MAX_DBMD_FILE_BYTES)
466 {
467 Ok(bytes) => bytes,
468 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
469 Err(error) => return Err(error.into()),
470 };
471 if on_disk != canonical.as_bytes() {
472 write_manifest(store, &records)?;
473 wrote = true;
474 }
475 }
476
477 Ok(ScanReport {
478 manifest: MANIFEST_FILE.to_string(),
479 cataloged,
480 hashed,
481 preserved,
482 bytes,
483 wrote,
484 dry_run,
485 warnings,
486 untracked: untracked_list,
487 })
488}
489
490fn supersession_cycle_members(
491 supersessions: &BTreeMap<String, (String, String)>,
492) -> BTreeSet<String> {
493 let mut cyclic = BTreeSet::new();
494 for origin in supersessions.keys() {
495 let mut order = Vec::new();
496 let mut positions = BTreeMap::new();
497 let mut current = origin.as_str();
498 while let Some((next, _)) = supersessions.get(current) {
499 if let Some(start) = positions.get(current).copied() {
500 cyclic.extend(order[start..].iter().cloned());
501 break;
502 }
503 positions.insert(current.to_string(), order.len());
504 order.push(current.to_string());
505 current = next;
506 }
507 }
508 cyclic
509}
510
511pub fn refresh(store: &Store, raw_path: &str, raw_wrapper: &str) -> crate::Result<RefreshReport> {
519 let path = normalize_asset_path(raw_path)
520 .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?;
521
522 let wrapper_path = normalize_asset_path(raw_wrapper)
523 .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?;
524 if !is_markdown(&wrapper_path)
525 || !(wrapper_path.starts_with("sources/") || wrapper_path.starts_with("records/"))
526 {
527 return Err(std::io::Error::new(
528 std::io::ErrorKind::InvalidInput,
529 "wrapper must be a sources/ or records/ markdown content path",
530 )
531 .into());
532 }
533
534 let declaration = |wrapper: &str| -> crate::Result<Option<bool>> {
535 let text =
536 store.read_text_bounded(Path::new(wrapper), crate::parser::MAX_DBMD_FILE_BYTES)?;
537 let parsed = parser::split_frontmatter(&text, Path::new(wrapper))?;
538 let fm = parser::Frontmatter::parse(&parsed.frontmatter_yaml, Path::new(wrapper))?;
539 let mut found = false;
540 let mut required = false;
541 for declaration in declared_assets(&fm) {
542 let declared = normalize_asset_path(&declaration.path).map_err(|message| {
543 std::io::Error::new(std::io::ErrorKind::InvalidInput, message)
544 })?;
545 if declared == path {
546 found = true;
547 required |= declaration.required;
548 }
549 }
550 Ok(found.then_some(required))
551 };
552
553 let Some(requested_required) = declaration(&wrapper_path)? else {
554 return Err(std::io::Error::new(
555 std::io::ErrorKind::InvalidInput,
556 format!("wrapper `{wrapper_path}` does not declare asset `{path}`"),
557 )
558 .into());
559 };
560 let requested_supersession = {
561 let text = store
562 .read_text_bounded(Path::new(&wrapper_path), crate::parser::MAX_DBMD_FILE_BYTES)?;
563 let parsed = parser::split_frontmatter(&text, Path::new(&wrapper_path))?;
564 let fm = parser::Frontmatter::parse(&parsed.frontmatter_yaml, Path::new(&wrapper_path))?;
565 asset_supersession(&fm)
566 .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?
567 };
568 if requested_supersession
569 .as_ref()
570 .is_some_and(|supersession| supersession.replacement != path)
571 {
572 return Err(std::io::Error::new(
573 std::io::ErrorKind::InvalidInput,
574 format!("wrapper `{wrapper_path}` supersedes an asset with a different replacement"),
575 )
576 .into());
577 }
578
579 let existing = read_manifest(store)?;
580 let mut wrappers = BTreeSet::from([wrapper_path.clone()]);
581 if let Some(record) = existing.iter().find(|record| record.path == path) {
582 wrappers.extend(record.wrappers.iter().cloned());
583 }
584 let mut live_wrappers = Vec::new();
585 let mut required = requested_required;
586 for wrapper in wrappers {
587 if wrapper != wrapper_path && !store.regular_file_exists(Path::new(&wrapper))? {
588 continue;
592 }
593 match declaration(&wrapper) {
594 Ok(Some(wrapper_required)) => {
595 required |= wrapper_required;
596 live_wrappers.push(wrapper);
597 }
598 Ok(None) => {}
599 Err(error) => return Err(error),
600 }
601 }
602 live_wrappers.sort();
603
604 let asset_path = store.capability_relative(Path::new(&path))?;
605 let file = store.open_regular(asset_path)?;
606 let (sha256, bytes) = sha256_file(file)?;
607 let record = AssetRecord {
608 path: path.clone(),
609 sha256: sha256.clone(),
610 bytes,
611 media_type: media_type_for(&path),
612 wrappers: live_wrappers.clone(),
613 required,
614 };
615 let mut next = existing;
616 next.retain(|candidate| candidate.path != path);
617 next.push(record);
618 let mut superseded_assets = Vec::new();
619 if let Some(supersession) = requested_supersession {
620 let original = next
621 .iter_mut()
622 .find(|candidate| candidate.path == supersession.original)
623 .ok_or_else(|| {
624 std::io::Error::new(
625 std::io::ErrorKind::InvalidInput,
626 format!(
627 "superseded asset `{}` has no existing manifest row; run `dbmd assets scan` first",
628 supersession.original
629 ),
630 )
631 })?;
632 original.required = false;
633 if !original.wrappers.contains(&wrapper_path) {
634 original.wrappers.push(wrapper_path.clone());
635 original.wrappers.sort();
636 }
637 superseded_assets.push(supersession.original);
638 }
639 next.sort_by(|left, right| left.path.cmp(&right.path));
640
641 let canonical = serialize_manifest(&next);
642 let on_disk =
643 match store.read_bounded(Path::new(MANIFEST_FILE), crate::parser::MAX_DBMD_FILE_BYTES) {
644 Ok(bytes) => bytes,
645 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
646 Err(error) => return Err(error.into()),
647 };
648 let wrote = on_disk != canonical.as_bytes();
649 if wrote {
650 write_manifest(store, &next)?;
651 }
652
653 Ok(RefreshReport {
654 manifest: MANIFEST_FILE.to_string(),
655 path,
656 sha256,
657 bytes,
658 wrappers: live_wrappers,
659 required,
660 superseded_assets,
661 wrote,
662 })
663}
664
665pub fn refresh_wrapper(store: &Store, raw_wrapper: &str) -> crate::Result<RefreshWrapperReport> {
677 let wrapper_path = normalize_asset_path(raw_wrapper)
678 .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?;
679 if !is_markdown(&wrapper_path)
680 || !(wrapper_path.starts_with("sources/") || wrapper_path.starts_with("records/"))
681 {
682 return Err(std::io::Error::new(
683 std::io::ErrorKind::InvalidInput,
684 "wrapper must be a sources/ or records/ markdown content path",
685 )
686 .into());
687 }
688
689 let wrapper_declarations = |wrapper: &str| -> crate::Result<BTreeMap<String, bool>> {
690 let text =
691 store.read_text_bounded(Path::new(wrapper), crate::parser::MAX_DBMD_FILE_BYTES)?;
692 let parsed = parser::split_frontmatter(&text, Path::new(wrapper))?;
693 let fm = parser::Frontmatter::parse(&parsed.frontmatter_yaml, Path::new(wrapper))?;
694 if asset_supersession(&fm)
695 .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?
696 .is_some()
697 {
698 return Err(std::io::Error::new(
699 std::io::ErrorKind::InvalidInput,
700 "refresh-wrapper does not accept supersedes-asset; use assets refresh for that replacement",
701 )
702 .into());
703 }
704 let mut declarations = BTreeMap::new();
705 for declaration in declared_assets(&fm) {
706 let path = normalize_asset_path(&declaration.path).map_err(|message| {
707 std::io::Error::new(std::io::ErrorKind::InvalidInput, message)
708 })?;
709 let required = declarations.entry(path).or_insert(false);
710 *required |= declaration.required;
711 }
712 Ok(declarations)
713 };
714
715 let requested = wrapper_declarations(&wrapper_path)?;
719
720 let existing = read_manifest(store)?;
721 let existing_by_path: BTreeMap<String, AssetRecord> = existing
722 .iter()
723 .cloned()
724 .map(|record| (record.path.clone(), record))
725 .collect();
726 let old_paths: BTreeSet<String> = existing
727 .iter()
728 .filter(|record| record.wrappers.contains(&wrapper_path))
729 .map(|record| record.path.clone())
730 .collect();
731 let requested_paths: BTreeSet<String> = requested.keys().cloned().collect();
732
733 let mut wrapper_cache: BTreeMap<String, Option<BTreeMap<String, bool>>> = BTreeMap::new();
734 let mut live_other_declaration = |wrapper: &str, path: &str| -> crate::Result<Option<bool>> {
735 if !wrapper_cache.contains_key(wrapper) {
736 let declarations = if store.regular_file_exists(Path::new(wrapper))? {
737 Some(wrapper_declarations(wrapper)?)
738 } else {
739 None
740 };
741 wrapper_cache.insert(wrapper.to_string(), declarations);
742 }
743 Ok(wrapper_cache
744 .get(wrapper)
745 .and_then(Option::as_ref)
746 .and_then(|declarations| declarations.get(path).copied()))
747 };
748
749 let mut next = Vec::new();
750 for mut record in existing.iter().cloned() {
751 if requested.contains_key(&record.path) {
752 continue;
753 }
754 if record.wrappers.contains(&wrapper_path) {
755 let mut live_wrappers = Vec::new();
756 let mut required = false;
757 for wrapper in &record.wrappers {
758 if wrapper == &wrapper_path {
759 continue;
760 }
761 if let Some(wrapper_required) = live_other_declaration(wrapper, &record.path)? {
762 live_wrappers.push(wrapper.clone());
763 required |= wrapper_required;
764 }
765 }
766 if live_wrappers.is_empty() {
767 continue;
768 }
769 live_wrappers.sort();
770 record.wrappers = live_wrappers;
771 record.required = required;
772 }
773 next.push(record);
774 }
775
776 let mut hashed = 0usize;
777 let mut preserved = 0usize;
778 let mut bytes_total = 0u64;
779 for (path, requested_required) in &requested {
780 let mut wrappers = vec![wrapper_path.clone()];
781 let mut required = *requested_required;
782 if let Some(existing_record) = existing_by_path.get(path) {
783 for wrapper in &existing_record.wrappers {
784 if wrapper == &wrapper_path {
785 continue;
786 }
787 if let Some(wrapper_required) = live_other_declaration(wrapper, path)? {
788 wrappers.push(wrapper.clone());
789 required |= wrapper_required;
790 }
791 }
792 }
793 wrappers.sort();
794 wrappers.dedup();
795
796 let (sha256, bytes, media_type) = match store.open_regular(Path::new(path)) {
797 Ok(file) => {
798 let (sha256, bytes) = sha256_file(file)?;
799 hashed += 1;
800 (sha256, bytes, media_type_for(path))
801 }
802 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
803 let existing_record = existing_by_path.get(path).ok_or_else(|| {
804 std::io::Error::new(
805 std::io::ErrorKind::NotFound,
806 format!(
807 "declared asset `{path}` is absent and has no manifest row to preserve"
808 ),
809 )
810 })?;
811 preserved += 1;
812 (
813 existing_record.sha256.clone(),
814 existing_record.bytes,
815 existing_record.media_type.clone(),
816 )
817 }
818 Err(error) => return Err(error.into()),
819 };
820 bytes_total = bytes_total.saturating_add(bytes);
821 next.push(AssetRecord {
822 path: path.clone(),
823 sha256,
824 bytes,
825 media_type,
826 wrappers,
827 required,
828 });
829 }
830
831 next.sort_by(|left, right| left.path.cmp(&right.path));
832 let canonical = serialize_manifest(&next);
833 let on_disk =
834 match store.read_bounded(Path::new(MANIFEST_FILE), crate::parser::MAX_DBMD_FILE_BYTES) {
835 Ok(bytes) => bytes,
836 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
837 Err(error) => return Err(error.into()),
838 };
839 let wrote = on_disk != canonical.as_bytes();
840 if wrote {
841 write_manifest(store, &next)?;
842 }
843
844 Ok(RefreshWrapperReport {
845 manifest: MANIFEST_FILE.to_string(),
846 wrapper: wrapper_path,
847 cataloged: requested.len(),
848 added: requested_paths.difference(&old_paths).count(),
849 removed: old_paths.difference(&requested_paths).count(),
850 hashed,
851 preserved,
852 bytes: bytes_total,
853 wrote,
854 })
855}
856
857pub fn verify(store: &Store, include_optional: bool, quick: bool) -> crate::Result<VerifyReport> {
867 verify_inner(store, include_optional, quick, None)
868}
869
870pub fn verify_projection(
875 store: &Store,
876 include_optional: bool,
877 quick: bool,
878 projection: &ProjectionPolicy,
879) -> crate::Result<VerifyReport> {
880 verify_inner(store, include_optional, quick, Some(projection))
881}
882
883fn verify_inner(
884 store: &Store,
885 include_optional: bool,
886 quick: bool,
887 projection: Option<&ProjectionPolicy>,
888) -> crate::Result<VerifyReport> {
889 let records = read_manifest(store)?;
890 let mut missing = Vec::new();
891 let mut corrupt = Vec::new();
892 let mut projected_missing = Vec::new();
893 let mut checked = 0usize;
894
895 for rec in &records {
896 if !rec.required && !include_optional {
897 continue;
898 }
899 let abs = match store.capability_relative(Path::new(&rec.path)) {
900 Ok(p) => p,
901 Err(_) => {
902 checked += 1;
904 corrupt.push(rec.path.clone());
905 continue;
906 }
907 };
908 let file = match store.open_regular(abs) {
909 Ok(file) => file,
910 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
911 if projection.is_some_and(|policy| policy.excludes_path(&rec.path)) {
912 projected_missing.push(rec.path.clone());
913 } else {
914 checked += 1;
915 missing.push(rec.path.clone());
916 }
917 continue;
918 }
919 Err(_) => {
920 checked += 1;
921 corrupt.push(rec.path.clone());
922 continue;
923 }
924 };
925 checked += 1;
926 if quick {
927 let len = file.metadata()?.len();
928 if len != rec.bytes {
929 corrupt.push(rec.path.clone());
930 }
931 } else {
932 let (sha, bytes) = sha256_file(file)?;
933 if sha != rec.sha256 || bytes != rec.bytes {
934 corrupt.push(rec.path.clone());
935 }
936 }
937 }
938
939 let ok = checked - missing.len() - corrupt.len();
940 let projection_complete = projection.map(|_| missing.is_empty() && corrupt.is_empty());
941 let complete = missing.is_empty() && corrupt.is_empty() && projected_missing.is_empty();
942 Ok(VerifyReport {
943 mode: if quick { "quick" } else { "deep" }.to_string(),
944 checked,
945 ok,
946 missing,
947 corrupt,
948 projected_missing,
949 projection_complete,
950 complete,
951 })
952}
953
954pub fn status(store: &Store) -> crate::Result<StatusReport> {
962 let records = read_manifest(store)?;
963 let mut present = 0usize;
964 let mut missing = 0usize;
965 let mut required_missing = 0usize;
966 let mut optional_missing = 0usize;
967 let mut bytes_total = 0u64;
968 let mut bytes_missing = 0u64;
969 let mut assets = Vec::with_capacity(records.len());
970
971 for rec in &records {
972 bytes_total = bytes_total.saturating_add(rec.bytes);
977 let is_present = store.open_regular(Path::new(&rec.path)).is_ok();
985 let state = if is_present {
986 present += 1;
987 "present"
988 } else {
989 missing += 1;
990 bytes_missing = bytes_missing.saturating_add(rec.bytes);
991 if rec.required {
992 required_missing += 1;
993 } else {
994 optional_missing += 1;
995 }
996 "missing"
997 };
998 assets.push(AssetState {
999 path: rec.path.clone(),
1000 sha256: rec.sha256.clone(),
1001 bytes: rec.bytes,
1002 required: rec.required,
1003 state: state.to_string(),
1004 });
1005 }
1006
1007 Ok(StatusReport {
1008 total: records.len(),
1009 present,
1010 missing,
1011 required_missing,
1012 optional_missing,
1013 bytes_total,
1014 bytes_missing,
1015 assets,
1016 })
1017}
1018
1019pub fn paths(store: &Store) -> crate::Result<Vec<String>> {
1042 Ok(read_manifest(store)?
1043 .into_iter()
1044 .filter(|r| store.capability_relative(Path::new(&r.path)).is_ok())
1045 .filter(|r| !is_markdown(&r.path))
1046 .map(|r| r.path)
1047 .collect())
1048}
1049
1050pub fn declared_assets(fm: &parser::Frontmatter) -> Vec<Declaration> {
1060 let mut out = Vec::new();
1061 if let Some(v) = fm.get("asset") {
1062 collect_declarations(&v, &mut out);
1063 }
1064 if let Some(v) = fm.get("assets") {
1065 collect_declarations(&v, &mut out);
1066 }
1067 out
1068}
1069
1070pub fn declarations_from_yaml_map(map: &BTreeMap<String, Value>) -> Vec<Declaration> {
1074 let mut out = Vec::new();
1075 if let Some(v) = map.get("asset") {
1076 collect_declarations(v, &mut out);
1077 }
1078 if let Some(v) = map.get("assets") {
1079 collect_declarations(v, &mut out);
1080 }
1081 out
1082}
1083
1084pub fn asset_supersession(fm: &parser::Frontmatter) -> Result<Option<AssetSupersession>, String> {
1088 asset_supersession_from_parts(fm.get(SUPERSEDES_ASSET_KEY).as_ref(), declared_assets(fm))
1089}
1090
1091pub fn asset_supersession_from_yaml_map(
1093 map: &BTreeMap<String, Value>,
1094) -> Result<Option<AssetSupersession>, String> {
1095 asset_supersession_from_parts(
1096 map.get(SUPERSEDES_ASSET_KEY),
1097 declarations_from_yaml_map(map),
1098 )
1099}
1100
1101fn asset_supersession_from_parts(
1102 value: Option<&Value>,
1103 declarations: Vec<Declaration>,
1104) -> Result<Option<AssetSupersession>, String> {
1105 let Some(value) = value else {
1106 return Ok(None);
1107 };
1108 let Value::String(original) = value else {
1109 return Err(format!("`{SUPERSEDES_ASSET_KEY}` must be one asset path"));
1110 };
1111 if declarations.len() != 1 || !declarations[0].required {
1112 return Err(format!(
1113 "a `{SUPERSEDES_ASSET_KEY}` wrapper must declare exactly one required replacement asset"
1114 ));
1115 }
1116 let original = normalize_asset_path(original)?;
1117 let replacement = normalize_asset_path(&declarations[0].path)?;
1118 if original == replacement {
1119 return Err(format!(
1120 "`{SUPERSEDES_ASSET_KEY}` cannot name the wrapper's replacement asset"
1121 ));
1122 }
1123 Ok(Some(AssetSupersession {
1124 original,
1125 replacement,
1126 }))
1127}
1128
1129fn collect_declarations(v: &Value, out: &mut Vec<Declaration>) {
1130 match v {
1131 Value::String(s) => out.push(Declaration {
1132 path: s.clone(),
1133 required: true,
1134 }),
1135 Value::Sequence(items) => {
1136 for item in items {
1137 match item {
1138 Value::String(s) => out.push(Declaration {
1139 path: s.clone(),
1140 required: true,
1141 }),
1142 Value::Mapping(m) => {
1143 let path = m
1144 .get(Value::String("path".to_string()))
1145 .and_then(|x| x.as_str())
1146 .map(|s| s.to_string());
1147 if let Some(path) = path {
1148 let required = m
1149 .get(Value::String("required".to_string()))
1150 .and_then(|x| x.as_bool())
1151 .unwrap_or(true);
1152 out.push(Declaration { path, required });
1153 }
1154 }
1155 _ => {}
1156 }
1157 }
1158 }
1159 _ => {}
1160 }
1161}
1162
1163pub fn normalize_asset_path(raw: &str) -> Result<String, String> {
1179 let trimmed = raw.trim();
1180 if trimmed.is_empty() {
1181 return Err("empty asset path".to_string());
1182 }
1183 let p = Path::new(trimmed);
1184 if p.is_absolute() {
1185 return Err(format!("absolute asset path not allowed: {raw}"));
1186 }
1187 let mut normal: Vec<&std::ffi::OsStr> = Vec::new();
1188 for c in p.components() {
1189 match c {
1190 Component::ParentDir => return Err(format!("`..` not allowed in asset path: {raw}")),
1191 Component::Prefix(_) | Component::RootDir => {
1192 return Err(format!("asset path escapes the store: {raw}"))
1193 }
1194 Component::CurDir => {}
1197 Component::Normal(seg) => normal.push(seg),
1198 }
1199 }
1200 if normal.is_empty() {
1201 return Err(format!("asset path names no file: {raw}"));
1203 }
1204 let joined: PathBuf = normal.into_iter().collect();
1205 Ok(joined.to_string_lossy().replace('\\', "/"))
1206}
1207
1208fn is_markdown(path: &str) -> bool {
1209 Path::new(path)
1210 .extension()
1211 .and_then(|e| e.to_str())
1212 .map(|e| e.eq_ignore_ascii_case("md"))
1213 .unwrap_or(false)
1214}
1215
1216fn rel_to_string(p: &Path) -> String {
1217 p.to_string_lossy().replace('\\', "/")
1218}
1219
1220fn sha256_file(mut f: std::fs::File) -> std::io::Result<(String, u64)> {
1223 let mut hasher = Sha256::new();
1224 let mut buf = [0u8; 65536];
1225 let mut total: u64 = 0;
1226 loop {
1227 let n = f.read(&mut buf)?;
1228 if n == 0 {
1229 break;
1230 }
1231 hasher.update(&buf[..n]);
1232 total += n as u64;
1233 }
1234 let digest = hasher.finalize();
1235 let mut hex = String::with_capacity(64);
1236 for b in digest.iter() {
1237 let _ = write!(hex, "{b:02x}");
1238 }
1239 Ok((hex, total))
1240}
1241
1242fn media_type_for(path: &str) -> String {
1246 let ext = Path::new(path)
1247 .extension()
1248 .and_then(|e| e.to_str())
1249 .unwrap_or("")
1250 .to_ascii_lowercase();
1251 let mt = match ext.as_str() {
1252 "pdf" => "application/pdf",
1253 "png" => "image/png",
1254 "jpg" | "jpeg" => "image/jpeg",
1255 "gif" => "image/gif",
1256 "webp" => "image/webp",
1257 "svg" => "image/svg+xml",
1258 "tiff" | "tif" => "image/tiff",
1259 "mp4" => "video/mp4",
1260 "mov" => "video/quicktime",
1261 "webm" => "video/webm",
1262 "mkv" => "video/x-matroska",
1263 "mp3" => "audio/mpeg",
1264 "wav" => "audio/wav",
1265 "m4a" => "audio/mp4",
1266 "flac" => "audio/flac",
1267 "zip" => "application/zip",
1268 "gz" | "tgz" => "application/gzip",
1269 "tar" => "application/x-tar",
1270 "csv" => "text/csv",
1271 "tsv" => "text/tab-separated-values",
1272 "md" | "markdown" => "text/markdown",
1273 "json" => "application/json",
1274 "xml" => "application/xml",
1275 "txt" => "text/plain",
1276 "vtt" => "text/vtt",
1277 "srt" => "application/x-subrip",
1278 "html" | "htm" => "text/html",
1279 "epub" => "application/epub+zip",
1280 "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
1281 "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
1282 "pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
1283 "doc" => "application/msword",
1284 "xls" => "application/vnd.ms-excel",
1285 "ppt" => "application/vnd.ms-powerpoint",
1286 _ => "application/octet-stream",
1287 };
1288 mt.to_string()
1289}
1290
1291fn find_untracked(store: &Store, declared: &BTreeSet<String>) -> crate::Result<Vec<String>> {
1295 let mut out = Vec::new();
1296 let paths = match store.walk_regular_files(Path::new("sources")) {
1297 Ok(paths) => paths,
1298 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(out),
1299 Err(error) => return Err(error.into()),
1300 };
1301 for path in paths {
1302 let name = match path.file_name().and_then(|name| name.to_str()) {
1303 Some(name) => name,
1304 None => continue,
1305 };
1306 if is_markdown(name) || name == "index.jsonl" {
1307 continue;
1308 }
1309 let rel = rel_to_string(&path);
1310 if !declared.contains(&rel) {
1311 out.push(rel);
1312 }
1313 }
1314 out.sort();
1315 Ok(out)
1316}
1317
1318#[cfg(test)]
1319mod tests {
1320 use super::*;
1321
1322 #[test]
1323 fn supersession_cycles_exclude_only_cycle_members() {
1324 let supersessions = BTreeMap::from([
1325 ("a".to_string(), ("b".to_string(), "a.md".to_string())),
1326 ("b".to_string(), ("a".to_string(), "b.md".to_string())),
1327 (
1328 "before".to_string(),
1329 ("a".to_string(), "before.md".to_string()),
1330 ),
1331 (
1332 "clean".to_string(),
1333 ("next".to_string(), "clean.md".to_string()),
1334 ),
1335 ]);
1336 assert_eq!(
1337 supersession_cycle_members(&supersessions),
1338 BTreeSet::from(["a".to_string(), "b".to_string()])
1339 );
1340 }
1341
1342 #[test]
1348 fn normalize_asset_path_folds_curdir_and_rejects_traversal() {
1349 assert_eq!(
1350 normalize_asset_path("./sources/x.pdf").unwrap(),
1351 "sources/x.pdf"
1352 );
1353 assert_eq!(
1354 normalize_asset_path("sources/x.pdf").unwrap(),
1355 "sources/x.pdf"
1356 );
1357 assert_eq!(
1358 normalize_asset_path("sources/./x.pdf").unwrap(),
1359 "sources/x.pdf"
1360 );
1361 assert_eq!(
1362 normalize_asset_path("sources/x.pdf/").unwrap(),
1363 "sources/x.pdf"
1364 );
1365
1366 assert!(normalize_asset_path("../outside.txt").is_err());
1368 assert!(normalize_asset_path("sources/../../etc/passwd").is_err());
1369 assert!(normalize_asset_path("/abs/x.pdf").is_err());
1370 assert!(normalize_asset_path(".").is_err());
1372 assert!(normalize_asset_path("./").is_err());
1373 assert!(normalize_asset_path("").is_err());
1374 }
1375
1376 #[test]
1381 fn status_and_scan_saturate_on_overflowing_manifest_bytes() {
1382 let tmp = tempfile::TempDir::new().unwrap();
1383 let root = tmp.path();
1384 std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n# store\n").unwrap();
1385 std::fs::write(
1387 root.join("assets.jsonl"),
1388 "{\"path\":\"records/a.bin\",\"sha256\":\"x\",\"bytes\":18446744073709551615,\
1389\"media_type\":\"application/octet-stream\",\"wrappers\":[\"records/w.md\"],\"required\":true}\n\
1390{\"path\":\"records/b.bin\",\"sha256\":\"y\",\"bytes\":1,\
1391\"media_type\":\"application/octet-stream\",\"wrappers\":[\"records/w.md\"],\"required\":true}\n",
1392 )
1393 .unwrap();
1394 let store = Store::from_root_and_config(root, crate::parser::Config::default()).unwrap();
1395
1396 let report = status(&store).expect("status is non-failing on a poisoned manifest");
1399 assert_eq!(
1400 report.bytes_total,
1401 u64::MAX,
1402 "byte total must saturate, not wrap"
1403 );
1404 assert_eq!(
1405 report.bytes_missing,
1406 u64::MAX,
1407 "missing bytes must saturate too"
1408 );
1409 assert_eq!(report.total, 2);
1410
1411 scan(&store, true, false).expect("scan must not overflow on a poisoned manifest");
1413 }
1414
1415 fn store_with_one_asset() -> (tempfile::TempDir, Store, String) {
1418 let tmp = tempfile::TempDir::new().unwrap();
1419 let root = tmp.path();
1420 std::fs::create_dir_all(root.join("sources")).unwrap();
1421 std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n# store\n").unwrap();
1422 std::fs::write(
1423 root.join("sources/a.pdf.md"),
1424 "---\ntype: pdf-source\nsummary: x\nasset: sources/a.pdf\n---\nbody\n",
1425 )
1426 .unwrap();
1427 std::fs::write(root.join("sources/a.pdf"), b"PDFBYTES").unwrap();
1428 let store = Store::from_root_and_config(root, crate::parser::Config::default()).unwrap();
1429 let report = scan(&store, false, false).unwrap();
1430 assert!(
1431 report.wrote,
1432 "first scan writes the manifest; report: {report:?}"
1433 );
1434 let canonical = std::fs::read_to_string(root.join(MANIFEST_FILE)).unwrap();
1435 (tmp, store, canonical)
1436 }
1437
1438 #[test]
1447 fn scan_recompacts_duplicate_line_manifest() {
1448 let (_tmp, store, canonical) = store_with_one_asset();
1449 let abs = store.root.join(MANIFEST_FILE);
1450
1451 std::fs::write(&abs, format!("{canonical}{canonical}")).unwrap();
1453 assert_eq!(std::fs::read_to_string(&abs).unwrap().lines().count(), 2);
1454
1455 let report = scan(&store, false, false).unwrap();
1456 assert!(
1457 report.wrote,
1458 "a non-canonical (duplicate-line) manifest must be recompacted and reported as updated"
1459 );
1460 let after = std::fs::read_to_string(&abs).unwrap();
1461 assert_eq!(
1462 after.lines().count(),
1463 1,
1464 "duplicate lines must collapse to the single canonical line"
1465 );
1466 assert_eq!(
1467 after, canonical,
1468 "scan must restore the exact canonical bytes"
1469 );
1470 }
1471
1472 #[test]
1476 fn scan_recompacts_noncanonical_byte_layout() {
1477 let (_tmp, store, canonical) = store_with_one_asset();
1478 let abs = store.root.join(MANIFEST_FILE);
1479
1480 std::fs::write(&abs, canonical.trim_end_matches('\n')).unwrap();
1482 let report = scan(&store, false, false).unwrap();
1483 assert!(
1484 report.wrote,
1485 "a manifest missing its trailing newline must be recompacted"
1486 );
1487 assert_eq!(
1488 std::fs::read_to_string(&abs).unwrap(),
1489 canonical,
1490 "scan must restore the canonical trailing newline"
1491 );
1492 }
1493
1494 #[test]
1504 fn paths_omits_store_escaping_records() {
1505 let tmp = tempfile::TempDir::new().unwrap();
1506 let root = tmp.path();
1507 std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n# store\n").unwrap();
1508 std::fs::write(
1510 root.join("assets.jsonl"),
1511 "{\"path\":\"sources/legit.pdf\",\"sha256\":\"a\",\"bytes\":9,\
1512\"media_type\":\"application/pdf\",\"wrappers\":[\"sources/legit.pdf.md\"],\"required\":true}\n\
1513{\"path\":\"../../../../../../etc/passwd\",\"sha256\":\"b\",\"bytes\":4096,\
1514\"media_type\":\"text/plain\",\"wrappers\":[\"sources/legit.pdf.md\"],\"required\":false}\n\
1515{\"path\":\"/etc/hosts\",\"sha256\":\"c\",\"bytes\":4096,\
1516\"media_type\":\"text/plain\",\"wrappers\":[\"sources/legit.pdf.md\"],\"required\":false}\n",
1517 )
1518 .unwrap();
1519 let store = Store::from_root_and_config(root, crate::parser::Config::default()).unwrap();
1520
1521 let out = paths(&store).expect("paths is non-failing on a poisoned manifest");
1522 assert_eq!(
1523 out,
1524 vec!["sources/legit.pdf".to_string()],
1525 "only the safe in-store path is emitted; escaping paths are omitted"
1526 );
1527 assert!(
1528 !out.iter().any(|p| p.starts_with('/') || p.contains("..")),
1529 "no absolute or `..` path may ever leak from `paths`: {out:?}"
1530 );
1531 }
1532
1533 #[test]
1536 fn paths_passes_a_clean_manifest_through_unchanged() {
1537 let (_tmp, store, _canonical) = store_with_one_asset();
1538 let out = paths(&store).expect("paths over a clean manifest");
1539 assert_eq!(out, vec!["sources/a.pdf".to_string()]);
1540 }
1541
1542 #[test]
1548 fn markdown_content_files_are_accepted_as_assets_and_omitted_from_paths() {
1549 let tmp = tempfile::TempDir::new().unwrap();
1550 let root = tmp.path();
1551 std::fs::create_dir_all(root.join("sources")).unwrap();
1552 std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n# store\n").unwrap();
1553 std::fs::write(
1554 root.join("sources/bundle.md"),
1555 "---\ntype: pdf-source\nsummary: bundle wrapper\nassets:\n - sources/notes.md\n - sources/a.pdf\n---\nbody\n",
1556 )
1557 .unwrap();
1558 std::fs::write(
1559 root.join("sources/notes.md"),
1560 "---\ntype: pdf-source\nsummary: a content file doubling as an asset\n---\nnotes\n",
1561 )
1562 .unwrap();
1563 std::fs::write(root.join("sources/a.pdf"), b"PDFBYTES").unwrap();
1564 let store = Store::from_root_and_config(root, crate::parser::Config::default()).unwrap();
1565
1566 let report = scan(&store, false, false).unwrap();
1567 assert!(
1568 report.warnings.is_empty(),
1569 "a markdown asset must not be skipped or warned about: {:?}",
1570 report.warnings
1571 );
1572 assert_eq!(
1573 report.cataloged, 2,
1574 "both the pdf and the markdown asset are cataloged"
1575 );
1576
1577 let refreshed = refresh(&store, "sources/notes.md", "sources/bundle.md")
1578 .expect("refresh accepts a markdown asset coordinate");
1579 assert_eq!(refreshed.path, "sources/notes.md");
1580 let reconciled = refresh_wrapper(&store, "sources/bundle.md")
1581 .expect("refresh-wrapper accepts a wrapper declaring a markdown asset");
1582 assert_eq!(reconciled.cataloged, 2);
1583
1584 let manifest = read_manifest(&store).unwrap();
1585 let md_row = manifest
1586 .iter()
1587 .find(|record| record.path == "sources/notes.md")
1588 .expect("the markdown asset has a manifest row");
1589 assert_eq!(md_row.media_type, "text/markdown");
1590
1591 let listed = paths(&store).expect("paths over the mixed manifest");
1592 assert_eq!(
1593 listed,
1594 vec!["sources/a.pdf".to_string()],
1595 "markdown assets are omitted from the ignore-feed list"
1596 );
1597 }
1598
1599 #[test]
1603 fn scan_canonical_manifest_is_left_untouched() {
1604 let (_tmp, store, canonical) = store_with_one_asset();
1605 let abs = store.root.join(MANIFEST_FILE);
1606
1607 let report = scan(&store, false, false).unwrap();
1608 assert!(
1609 !report.wrote,
1610 "a canonical, unchanged manifest must not be rewritten"
1611 );
1612 assert_eq!(
1613 std::fs::read_to_string(&abs).unwrap(),
1614 canonical,
1615 "a no-op rescan must leave the manifest byte-identical"
1616 );
1617 }
1618
1619 #[test]
1620 fn refresh_wrapper_reconciles_one_generated_asset_set_in_one_manifest_write() {
1621 let tmp = tempfile::TempDir::new().unwrap();
1622 let root = tmp.path();
1623 std::fs::create_dir_all(root.join("records/package")).unwrap();
1624 std::fs::create_dir_all(root.join("sources/package/objects")).unwrap();
1625 std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
1626 let wrapper = "records/package/current.md";
1627 std::fs::write(
1628 root.join(wrapper),
1629 "---\ntype: package\nsummary: current\nassets:\n - sources/package/objects/a.blob\n - sources/package/objects/b.blob\n---\n",
1630 )
1631 .unwrap();
1632 std::fs::write(root.join("sources/package/objects/a.blob"), b"a").unwrap();
1633 std::fs::write(root.join("sources/package/objects/b.blob"), b"bb").unwrap();
1634 let store = Store::from_root_and_config(root, crate::parser::Config::default()).unwrap();
1635
1636 let first = refresh_wrapper(&store, wrapper).unwrap();
1637 assert_eq!(first.cataloged, 2);
1638 assert_eq!(first.added, 2);
1639 assert_eq!(first.removed, 0);
1640 assert_eq!(first.hashed, 2);
1641 assert_eq!(first.bytes, 3);
1642 assert!(first.wrote);
1643
1644 let no_change = refresh_wrapper(&store, wrapper).unwrap();
1645 assert!(!no_change.wrote);
1646 assert_eq!(no_change.added, 0);
1647 assert_eq!(no_change.removed, 0);
1648
1649 std::fs::write(
1650 root.join(wrapper),
1651 "---\ntype: package\nsummary: current\nassets:\n - sources/package/objects/b.blob\n - sources/package/objects/c.blob\n---\n",
1652 )
1653 .unwrap();
1654 std::fs::write(root.join("sources/package/objects/c.blob"), b"ccc").unwrap();
1655 let changed = refresh_wrapper(&store, wrapper).unwrap();
1656 assert_eq!(changed.cataloged, 2);
1657 assert_eq!(changed.added, 1);
1658 assert_eq!(changed.removed, 1);
1659 assert_eq!(changed.bytes, 5);
1660 assert!(changed.wrote);
1661
1662 let records = read_manifest(&store).unwrap();
1663 let paths: Vec<&str> = records.iter().map(|record| record.path.as_str()).collect();
1664 assert_eq!(
1665 paths,
1666 vec![
1667 "sources/package/objects/b.blob",
1668 "sources/package/objects/c.blob"
1669 ]
1670 );
1671
1672 std::fs::write(
1673 root.join(wrapper),
1674 "---\ntype: package\nsummary: current\n---\n",
1675 )
1676 .unwrap();
1677 let cleared = refresh_wrapper(&store, wrapper).unwrap();
1678 assert_eq!(cleared.cataloged, 0);
1679 assert_eq!(cleared.added, 0);
1680 assert_eq!(cleared.removed, 2);
1681 assert!(cleared.wrote);
1682 assert!(read_manifest(&store).unwrap().is_empty());
1683 }
1684
1685 #[cfg(unix)]
1686 #[test]
1687 fn manifest_membership_reads_opened_root_after_path_replacement() {
1688 use std::os::unix::fs::symlink;
1689
1690 let sandbox = tempfile::tempdir().unwrap();
1691 let root = sandbox.path().join("store");
1692 std::fs::create_dir_all(&root).unwrap();
1693 std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
1694 std::fs::write(
1695 root.join(MANIFEST_FILE),
1696 "{\"path\":\"sources/owned.pdf\",\"sha256\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"bytes\":1,\"media_type\":\"application/pdf\",\"wrappers\":[],\"required\":true}\n",
1697 )
1698 .unwrap();
1699 let store = Store::open_strict(&root).unwrap();
1700 let detached = sandbox.path().join("detached");
1701 std::fs::rename(&root, &detached).unwrap();
1702
1703 let replacement = sandbox.path().join("replacement");
1704 std::fs::create_dir_all(&replacement).unwrap();
1705 std::fs::write(replacement.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
1706 std::fs::write(
1707 replacement.join(MANIFEST_FILE),
1708 "{\"path\":\"sources/replacement-secret.pdf\",\"sha256\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"bytes\":1,\"media_type\":\"application/pdf\",\"wrappers\":[],\"required\":true}\n",
1709 )
1710 .unwrap();
1711 symlink(&replacement, &root).unwrap();
1712
1713 assert_eq!(paths(&store).unwrap(), vec!["sources/owned.pdf"]);
1714 }
1715}