1use std::collections::{BTreeMap, BTreeSet};
29use std::fmt::Write as _;
30use std::io::Read as _;
31use std::path::{Component, Path, PathBuf};
32
33use serde::{Deserialize, Serialize};
34use serde_norway::Value;
35use sha2::{Digest, Sha256};
36
37use crate::parser;
38use crate::store::Store;
39
40pub const MANIFEST_FILE: &str = "assets.jsonl";
42
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub struct AssetRecord {
51 pub path: String,
54 pub sha256: String,
57 pub bytes: u64,
59 pub media_type: String,
61 pub wrappers: Vec<String>,
64 pub required: bool,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct Declaration {
72 pub path: String,
74 pub required: bool,
77}
78
79#[derive(Debug, Serialize)]
85pub struct ScanReport {
86 pub manifest: String,
87 pub cataloged: usize,
88 pub hashed: usize,
89 pub preserved: usize,
90 pub bytes: u64,
91 pub wrote: bool,
92 pub dry_run: bool,
93 pub warnings: Vec<String>,
94 pub untracked: Vec<String>,
95}
96
97#[derive(Debug, Serialize)]
99pub struct AssetState {
100 pub path: String,
101 pub sha256: String,
102 pub bytes: u64,
103 pub required: bool,
104 pub state: String,
106}
107
108#[derive(Debug, Serialize)]
110pub struct StatusReport {
111 pub total: usize,
112 pub present: usize,
113 pub missing: usize,
114 pub required_missing: usize,
115 pub optional_missing: usize,
116 pub bytes_total: u64,
117 pub bytes_missing: u64,
118 pub assets: Vec<AssetState>,
119}
120
121#[derive(Debug, Serialize)]
123pub struct VerifyReport {
124 pub mode: String,
125 pub checked: usize,
126 pub ok: usize,
127 pub missing: Vec<String>,
128 pub corrupt: Vec<String>,
129 pub complete: bool,
130}
131
132pub fn read_manifest(store: &Store) -> crate::Result<Vec<AssetRecord>> {
141 let text = match store
142 .read_text_bounded(Path::new(MANIFEST_FILE), crate::parser::MAX_DBMD_FILE_BYTES)
143 {
144 Ok(text) => text,
145 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
146 Err(error) => return Err(error.into()),
147 };
148 let mut by_path: BTreeMap<String, AssetRecord> = BTreeMap::new();
149 for (i, line) in text.lines().enumerate() {
150 if line.trim().is_empty() {
151 continue;
152 }
153 let rec: AssetRecord = serde_json::from_str(line).map_err(|e| {
154 std::io::Error::new(
155 std::io::ErrorKind::InvalidData,
156 format!("{MANIFEST_FILE} line {}: {e}", i + 1),
157 )
158 })?;
159 by_path.insert(rec.path.clone(), rec);
160 }
161 Ok(by_path.into_values().collect())
162}
163
164fn serialize_manifest(records: &[AssetRecord]) -> String {
171 if records.is_empty() {
172 return String::new();
173 }
174 let mut sorted = records.to_vec();
175 sorted.sort_by(|a, b| a.path.cmp(&b.path));
176 let mut out = String::new();
177 for rec in &sorted {
178 let line = serde_json::to_string(rec).expect("AssetRecord serializes");
179 out.push_str(&line);
180 out.push('\n');
181 }
182 out
183}
184
185pub fn write_manifest(store: &Store, records: &[AssetRecord]) -> crate::Result<()> {
189 let abs = Path::new(MANIFEST_FILE);
190 let out = serialize_manifest(records);
191 if out.is_empty() {
192 match store.remove_file(abs) {
193 Ok(()) => {}
194 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
195 Err(error) => return Err(error.into()),
196 }
197 return Ok(());
198 }
199 store.write_atomic(abs, out.as_bytes())?;
200 Ok(())
201}
202
203pub fn scan(store: &Store, dry_run: bool, untracked: bool) -> crate::Result<ScanReport> {
216 let existing_by_path: BTreeMap<String, AssetRecord> = read_manifest(store)
220 .unwrap_or_default()
221 .into_iter()
222 .map(|r| (r.path.clone(), r))
223 .collect();
224
225 let mut wrappers_by_path: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
227 let mut required_by_path: BTreeMap<String, bool> = BTreeMap::new();
228 let mut declared_paths: BTreeSet<String> = BTreeSet::new();
229 let mut warnings: Vec<String> = Vec::new();
230
231 for rel in store.walk()? {
232 let text = match store.read_text_bounded(&rel, parser::MAX_DBMD_FILE_BYTES) {
233 Ok(text) => text,
234 Err(_) => continue,
235 };
236 let parsed = match parser::split_frontmatter(&text, &rel) {
237 Ok(parsed) => parsed,
238 Err(_) => continue,
239 };
240 let fm = match parser::Frontmatter::parse(&parsed.frontmatter_yaml, &rel) {
241 Ok(frontmatter) => frontmatter,
242 Err(_) => continue, };
244 let wrapper = rel_to_string(&rel);
245 for decl in declared_assets(&fm) {
246 let norm = match normalize_asset_path(&decl.path) {
247 Ok(n) => n,
248 Err(e) => {
249 warnings.push(format!("{wrapper}: {e}"));
250 continue;
251 }
252 };
253 if is_markdown(&norm) {
254 warnings.push(format!(
255 "{wrapper}: asset path points at a markdown content file ({norm}); skipped"
256 ));
257 continue;
258 }
259 wrappers_by_path
260 .entry(norm.clone())
261 .or_default()
262 .insert(wrapper.clone());
263 let req = required_by_path.entry(norm.clone()).or_insert(false);
264 *req = *req || decl.required;
265 declared_paths.insert(norm);
266 }
267 }
268
269 let mut records: Vec<AssetRecord> = Vec::new();
271 let mut hashed = 0usize;
272 let mut preserved = 0usize;
273 for (path, wrappers) in &wrappers_by_path {
274 let required = *required_by_path.get(path).unwrap_or(&true);
275 let wrappers: Vec<String> = wrappers.iter().cloned().collect();
276
277 let abs = match store.capability_relative(Path::new(path)) {
279 Ok(p) => p,
280 Err(_) => {
281 warnings.push(format!("{path}: escapes the store root; skipped"));
282 continue;
283 }
284 };
285
286 match store.open_regular(abs) {
287 Ok(file) => {
288 let (sha256, bytes) = sha256_file(file)?;
289 records.push(AssetRecord {
290 path: path.clone(),
291 sha256,
292 bytes,
293 media_type: media_type_for(path),
294 wrappers,
295 required,
296 });
297 hashed += 1;
298 }
299 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
300 if let Some(prev) = existing_by_path.get(path) {
301 records.push(AssetRecord {
304 path: path.clone(),
305 sha256: prev.sha256.clone(),
306 bytes: prev.bytes,
307 media_type: media_type_for(path),
308 wrappers,
309 required,
310 });
311 preserved += 1;
312 } else {
313 warnings.push(format!(
314 "{path}: declared but absent and never cataloged; cannot hash (skipped)"
315 ));
316 }
317 }
318 Err(error) => {
319 warnings.push(format!(
320 "{path}: is not a readable regular in-store file: {error}"
321 ));
322 }
323 }
324 }
325 records.sort_by(|a, b| a.path.cmp(&b.path));
326
327 let bytes: u64 = records.iter().fold(0u64, |a, r| a.saturating_add(r.bytes));
330 let cataloged = records.len();
331
332 let untracked_list = if untracked {
333 find_untracked(store, &declared_paths)?
334 } else {
335 Vec::new()
336 };
337
338 let mut wrote = false;
348 if !dry_run {
349 let canonical = serialize_manifest(&records);
350 let on_disk = match store
351 .read_bounded(Path::new(MANIFEST_FILE), crate::parser::MAX_DBMD_FILE_BYTES)
352 {
353 Ok(bytes) => bytes,
354 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
355 Err(error) => return Err(error.into()),
356 };
357 if on_disk != canonical.as_bytes() {
358 write_manifest(store, &records)?;
359 wrote = true;
360 }
361 }
362
363 Ok(ScanReport {
364 manifest: MANIFEST_FILE.to_string(),
365 cataloged,
366 hashed,
367 preserved,
368 bytes,
369 wrote,
370 dry_run,
371 warnings,
372 untracked: untracked_list,
373 })
374}
375
376pub fn verify(store: &Store, include_optional: bool, quick: bool) -> crate::Result<VerifyReport> {
386 let records = read_manifest(store)?;
387 let mut missing = Vec::new();
388 let mut corrupt = Vec::new();
389 let mut checked = 0usize;
390
391 for rec in &records {
392 if !rec.required && !include_optional {
393 continue;
394 }
395 checked += 1;
396 let abs = match store.capability_relative(Path::new(&rec.path)) {
397 Ok(p) => p,
398 Err(_) => {
399 corrupt.push(rec.path.clone());
401 continue;
402 }
403 };
404 let file = match store.open_regular(abs) {
405 Ok(file) => file,
406 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
407 missing.push(rec.path.clone());
408 continue;
409 }
410 Err(_) => {
411 corrupt.push(rec.path.clone());
412 continue;
413 }
414 };
415 if quick {
416 let len = file.metadata()?.len();
417 if len != rec.bytes {
418 corrupt.push(rec.path.clone());
419 }
420 } else {
421 let (sha, bytes) = sha256_file(file)?;
422 if sha != rec.sha256 || bytes != rec.bytes {
423 corrupt.push(rec.path.clone());
424 }
425 }
426 }
427
428 let ok = checked - missing.len() - corrupt.len();
429 let complete = missing.is_empty() && corrupt.is_empty();
430 Ok(VerifyReport {
431 mode: if quick { "quick" } else { "deep" }.to_string(),
432 checked,
433 ok,
434 missing,
435 corrupt,
436 complete,
437 })
438}
439
440pub fn status(store: &Store) -> crate::Result<StatusReport> {
448 let records = read_manifest(store)?;
449 let mut present = 0usize;
450 let mut missing = 0usize;
451 let mut required_missing = 0usize;
452 let mut optional_missing = 0usize;
453 let mut bytes_total = 0u64;
454 let mut bytes_missing = 0u64;
455 let mut assets = Vec::with_capacity(records.len());
456
457 for rec in &records {
458 bytes_total = bytes_total.saturating_add(rec.bytes);
463 let is_present = store.open_regular(Path::new(&rec.path)).is_ok();
471 let state = if is_present {
472 present += 1;
473 "present"
474 } else {
475 missing += 1;
476 bytes_missing = bytes_missing.saturating_add(rec.bytes);
477 if rec.required {
478 required_missing += 1;
479 } else {
480 optional_missing += 1;
481 }
482 "missing"
483 };
484 assets.push(AssetState {
485 path: rec.path.clone(),
486 sha256: rec.sha256.clone(),
487 bytes: rec.bytes,
488 required: rec.required,
489 state: state.to_string(),
490 });
491 }
492
493 Ok(StatusReport {
494 total: records.len(),
495 present,
496 missing,
497 required_missing,
498 optional_missing,
499 bytes_total,
500 bytes_missing,
501 assets,
502 })
503}
504
505pub fn paths(store: &Store) -> crate::Result<Vec<String>> {
523 Ok(read_manifest(store)?
524 .into_iter()
525 .filter(|r| store.capability_relative(Path::new(&r.path)).is_ok())
526 .map(|r| r.path)
527 .collect())
528}
529
530pub fn declared_assets(fm: &parser::Frontmatter) -> Vec<Declaration> {
540 let mut out = Vec::new();
541 if let Some(v) = fm.get("asset") {
542 collect_declarations(&v, &mut out);
543 }
544 if let Some(v) = fm.get("assets") {
545 collect_declarations(&v, &mut out);
546 }
547 out
548}
549
550pub fn declarations_from_yaml_map(map: &BTreeMap<String, Value>) -> Vec<Declaration> {
554 let mut out = Vec::new();
555 if let Some(v) = map.get("asset") {
556 collect_declarations(v, &mut out);
557 }
558 if let Some(v) = map.get("assets") {
559 collect_declarations(v, &mut out);
560 }
561 out
562}
563
564fn collect_declarations(v: &Value, out: &mut Vec<Declaration>) {
565 match v {
566 Value::String(s) => out.push(Declaration {
567 path: s.clone(),
568 required: true,
569 }),
570 Value::Sequence(items) => {
571 for item in items {
572 match item {
573 Value::String(s) => out.push(Declaration {
574 path: s.clone(),
575 required: true,
576 }),
577 Value::Mapping(m) => {
578 let path = m
579 .get(Value::String("path".to_string()))
580 .and_then(|x| x.as_str())
581 .map(|s| s.to_string());
582 if let Some(path) = path {
583 let required = m
584 .get(Value::String("required".to_string()))
585 .and_then(|x| x.as_bool())
586 .unwrap_or(true);
587 out.push(Declaration { path, required });
588 }
589 }
590 _ => {}
591 }
592 }
593 }
594 _ => {}
595 }
596}
597
598pub fn normalize_asset_path(raw: &str) -> Result<String, String> {
614 let trimmed = raw.trim();
615 if trimmed.is_empty() {
616 return Err("empty asset path".to_string());
617 }
618 let p = Path::new(trimmed);
619 if p.is_absolute() {
620 return Err(format!("absolute asset path not allowed: {raw}"));
621 }
622 let mut normal: Vec<&std::ffi::OsStr> = Vec::new();
623 for c in p.components() {
624 match c {
625 Component::ParentDir => return Err(format!("`..` not allowed in asset path: {raw}")),
626 Component::Prefix(_) | Component::RootDir => {
627 return Err(format!("asset path escapes the store: {raw}"))
628 }
629 Component::CurDir => {}
632 Component::Normal(seg) => normal.push(seg),
633 }
634 }
635 if normal.is_empty() {
636 return Err(format!("asset path names no file: {raw}"));
638 }
639 let joined: PathBuf = normal.into_iter().collect();
640 Ok(joined.to_string_lossy().replace('\\', "/"))
641}
642
643fn is_markdown(path: &str) -> bool {
644 Path::new(path)
645 .extension()
646 .and_then(|e| e.to_str())
647 .map(|e| e.eq_ignore_ascii_case("md"))
648 .unwrap_or(false)
649}
650
651fn rel_to_string(p: &Path) -> String {
652 p.to_string_lossy().replace('\\', "/")
653}
654
655fn sha256_file(mut f: std::fs::File) -> std::io::Result<(String, u64)> {
658 let mut hasher = Sha256::new();
659 let mut buf = [0u8; 65536];
660 let mut total: u64 = 0;
661 loop {
662 let n = f.read(&mut buf)?;
663 if n == 0 {
664 break;
665 }
666 hasher.update(&buf[..n]);
667 total += n as u64;
668 }
669 let digest = hasher.finalize();
670 let mut hex = String::with_capacity(64);
671 for b in digest.iter() {
672 let _ = write!(hex, "{b:02x}");
673 }
674 Ok((hex, total))
675}
676
677fn media_type_for(path: &str) -> String {
681 let ext = Path::new(path)
682 .extension()
683 .and_then(|e| e.to_str())
684 .unwrap_or("")
685 .to_ascii_lowercase();
686 let mt = match ext.as_str() {
687 "pdf" => "application/pdf",
688 "png" => "image/png",
689 "jpg" | "jpeg" => "image/jpeg",
690 "gif" => "image/gif",
691 "webp" => "image/webp",
692 "svg" => "image/svg+xml",
693 "tiff" | "tif" => "image/tiff",
694 "mp4" => "video/mp4",
695 "mov" => "video/quicktime",
696 "webm" => "video/webm",
697 "mkv" => "video/x-matroska",
698 "mp3" => "audio/mpeg",
699 "wav" => "audio/wav",
700 "m4a" => "audio/mp4",
701 "flac" => "audio/flac",
702 "zip" => "application/zip",
703 "gz" | "tgz" => "application/gzip",
704 "tar" => "application/x-tar",
705 "csv" => "text/csv",
706 "tsv" => "text/tab-separated-values",
707 "json" => "application/json",
708 "xml" => "application/xml",
709 "txt" => "text/plain",
710 "vtt" => "text/vtt",
711 "srt" => "application/x-subrip",
712 "html" | "htm" => "text/html",
713 "epub" => "application/epub+zip",
714 "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
715 "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
716 "pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
717 "doc" => "application/msword",
718 "xls" => "application/vnd.ms-excel",
719 "ppt" => "application/vnd.ms-powerpoint",
720 _ => "application/octet-stream",
721 };
722 mt.to_string()
723}
724
725fn find_untracked(store: &Store, declared: &BTreeSet<String>) -> crate::Result<Vec<String>> {
729 let mut out = Vec::new();
730 let paths = match store.walk_regular_files(Path::new("sources")) {
731 Ok(paths) => paths,
732 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(out),
733 Err(error) => return Err(error.into()),
734 };
735 for path in paths {
736 let name = match path.file_name().and_then(|name| name.to_str()) {
737 Some(name) => name,
738 None => continue,
739 };
740 if is_markdown(name) || name == "index.jsonl" {
741 continue;
742 }
743 let rel = rel_to_string(&path);
744 if !declared.contains(&rel) {
745 out.push(rel);
746 }
747 }
748 out.sort();
749 Ok(out)
750}
751
752#[cfg(test)]
753mod tests {
754 use super::*;
755
756 #[test]
762 fn normalize_asset_path_folds_curdir_and_rejects_traversal() {
763 assert_eq!(
764 normalize_asset_path("./sources/x.pdf").unwrap(),
765 "sources/x.pdf"
766 );
767 assert_eq!(
768 normalize_asset_path("sources/x.pdf").unwrap(),
769 "sources/x.pdf"
770 );
771 assert_eq!(
772 normalize_asset_path("sources/./x.pdf").unwrap(),
773 "sources/x.pdf"
774 );
775 assert_eq!(
776 normalize_asset_path("sources/x.pdf/").unwrap(),
777 "sources/x.pdf"
778 );
779
780 assert!(normalize_asset_path("../outside.txt").is_err());
782 assert!(normalize_asset_path("sources/../../etc/passwd").is_err());
783 assert!(normalize_asset_path("/abs/x.pdf").is_err());
784 assert!(normalize_asset_path(".").is_err());
786 assert!(normalize_asset_path("./").is_err());
787 assert!(normalize_asset_path("").is_err());
788 }
789
790 #[test]
795 fn status_and_scan_saturate_on_overflowing_manifest_bytes() {
796 let tmp = tempfile::TempDir::new().unwrap();
797 let root = tmp.path();
798 std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n# store\n").unwrap();
799 std::fs::write(
801 root.join("assets.jsonl"),
802 "{\"path\":\"records/a.bin\",\"sha256\":\"x\",\"bytes\":18446744073709551615,\
803\"media_type\":\"application/octet-stream\",\"wrappers\":[\"records/w.md\"],\"required\":true}\n\
804{\"path\":\"records/b.bin\",\"sha256\":\"y\",\"bytes\":1,\
805\"media_type\":\"application/octet-stream\",\"wrappers\":[\"records/w.md\"],\"required\":true}\n",
806 )
807 .unwrap();
808 let store = Store::from_root_and_config(root, crate::parser::Config::default()).unwrap();
809
810 let report = status(&store).expect("status is non-failing on a poisoned manifest");
813 assert_eq!(
814 report.bytes_total,
815 u64::MAX,
816 "byte total must saturate, not wrap"
817 );
818 assert_eq!(
819 report.bytes_missing,
820 u64::MAX,
821 "missing bytes must saturate too"
822 );
823 assert_eq!(report.total, 2);
824
825 scan(&store, true, false).expect("scan must not overflow on a poisoned manifest");
827 }
828
829 fn store_with_one_asset() -> (tempfile::TempDir, Store, String) {
832 let tmp = tempfile::TempDir::new().unwrap();
833 let root = tmp.path();
834 std::fs::create_dir_all(root.join("sources")).unwrap();
835 std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n# store\n").unwrap();
836 std::fs::write(
837 root.join("sources/a.pdf.md"),
838 "---\ntype: pdf-source\nsummary: x\nasset: sources/a.pdf\n---\nbody\n",
839 )
840 .unwrap();
841 std::fs::write(root.join("sources/a.pdf"), b"PDFBYTES").unwrap();
842 let store = Store::from_root_and_config(root, crate::parser::Config::default()).unwrap();
843 let report = scan(&store, false, false).unwrap();
844 assert!(
845 report.wrote,
846 "first scan writes the manifest; report: {report:?}"
847 );
848 let canonical = std::fs::read_to_string(root.join(MANIFEST_FILE)).unwrap();
849 (tmp, store, canonical)
850 }
851
852 #[test]
861 fn scan_recompacts_duplicate_line_manifest() {
862 let (_tmp, store, canonical) = store_with_one_asset();
863 let abs = store.root.join(MANIFEST_FILE);
864
865 std::fs::write(&abs, format!("{canonical}{canonical}")).unwrap();
867 assert_eq!(std::fs::read_to_string(&abs).unwrap().lines().count(), 2);
868
869 let report = scan(&store, false, false).unwrap();
870 assert!(
871 report.wrote,
872 "a non-canonical (duplicate-line) manifest must be recompacted and reported as updated"
873 );
874 let after = std::fs::read_to_string(&abs).unwrap();
875 assert_eq!(
876 after.lines().count(),
877 1,
878 "duplicate lines must collapse to the single canonical line"
879 );
880 assert_eq!(
881 after, canonical,
882 "scan must restore the exact canonical bytes"
883 );
884 }
885
886 #[test]
890 fn scan_recompacts_noncanonical_byte_layout() {
891 let (_tmp, store, canonical) = store_with_one_asset();
892 let abs = store.root.join(MANIFEST_FILE);
893
894 std::fs::write(&abs, canonical.trim_end_matches('\n')).unwrap();
896 let report = scan(&store, false, false).unwrap();
897 assert!(
898 report.wrote,
899 "a manifest missing its trailing newline must be recompacted"
900 );
901 assert_eq!(
902 std::fs::read_to_string(&abs).unwrap(),
903 canonical,
904 "scan must restore the canonical trailing newline"
905 );
906 }
907
908 #[test]
918 fn paths_omits_store_escaping_records() {
919 let tmp = tempfile::TempDir::new().unwrap();
920 let root = tmp.path();
921 std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n# store\n").unwrap();
922 std::fs::write(
924 root.join("assets.jsonl"),
925 "{\"path\":\"sources/legit.pdf\",\"sha256\":\"a\",\"bytes\":9,\
926\"media_type\":\"application/pdf\",\"wrappers\":[\"sources/legit.pdf.md\"],\"required\":true}\n\
927{\"path\":\"../../../../../../etc/passwd\",\"sha256\":\"b\",\"bytes\":4096,\
928\"media_type\":\"text/plain\",\"wrappers\":[\"sources/legit.pdf.md\"],\"required\":false}\n\
929{\"path\":\"/etc/hosts\",\"sha256\":\"c\",\"bytes\":4096,\
930\"media_type\":\"text/plain\",\"wrappers\":[\"sources/legit.pdf.md\"],\"required\":false}\n",
931 )
932 .unwrap();
933 let store = Store::from_root_and_config(root, crate::parser::Config::default()).unwrap();
934
935 let out = paths(&store).expect("paths is non-failing on a poisoned manifest");
936 assert_eq!(
937 out,
938 vec!["sources/legit.pdf".to_string()],
939 "only the safe in-store path is emitted; escaping paths are omitted"
940 );
941 assert!(
942 !out.iter().any(|p| p.starts_with('/') || p.contains("..")),
943 "no absolute or `..` path may ever leak from `paths`: {out:?}"
944 );
945 }
946
947 #[test]
950 fn paths_passes_a_clean_manifest_through_unchanged() {
951 let (_tmp, store, _canonical) = store_with_one_asset();
952 let out = paths(&store).expect("paths over a clean manifest");
953 assert_eq!(out, vec!["sources/a.pdf".to_string()]);
954 }
955
956 #[test]
960 fn scan_canonical_manifest_is_left_untouched() {
961 let (_tmp, store, canonical) = store_with_one_asset();
962 let abs = store.root.join(MANIFEST_FILE);
963
964 let report = scan(&store, false, false).unwrap();
965 assert!(
966 !report.wrote,
967 "a canonical, unchanged manifest must not be rewritten"
968 );
969 assert_eq!(
970 std::fs::read_to_string(&abs).unwrap(),
971 canonical,
972 "a no-op rescan must leave the manifest byte-identical"
973 );
974 }
975
976 #[cfg(unix)]
977 #[test]
978 fn manifest_membership_reads_opened_root_after_path_replacement() {
979 use std::os::unix::fs::symlink;
980
981 let sandbox = tempfile::tempdir().unwrap();
982 let root = sandbox.path().join("store");
983 std::fs::create_dir_all(&root).unwrap();
984 std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
985 std::fs::write(
986 root.join(MANIFEST_FILE),
987 "{\"path\":\"sources/owned.pdf\",\"sha256\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"bytes\":1,\"media_type\":\"application/pdf\",\"wrappers\":[],\"required\":true}\n",
988 )
989 .unwrap();
990 let store = Store::open_strict(&root).unwrap();
991 let detached = sandbox.path().join("detached");
992 std::fs::rename(&root, &detached).unwrap();
993
994 let replacement = sandbox.path().join("replacement");
995 std::fs::create_dir_all(&replacement).unwrap();
996 std::fs::write(replacement.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
997 std::fs::write(
998 replacement.join(MANIFEST_FILE),
999 "{\"path\":\"sources/replacement-secret.pdf\",\"sha256\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"bytes\":1,\"media_type\":\"application/pdf\",\"wrappers\":[],\"required\":true}\n",
1000 )
1001 .unwrap();
1002 symlink(&replacement, &root).unwrap();
1003
1004 assert_eq!(paths(&store).unwrap(), vec!["sources/owned.pdf"]);
1005 }
1006}