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::{self, Store};
39use crate::write_atomic;
40
41pub const MANIFEST_FILE: &str = "assets.jsonl";
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct AssetRecord {
52 pub path: String,
55 pub sha256: String,
58 pub bytes: u64,
60 pub media_type: String,
62 pub wrappers: Vec<String>,
65 pub required: bool,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct Declaration {
73 pub path: String,
75 pub required: bool,
78}
79
80#[derive(Debug, Serialize)]
86pub struct ScanReport {
87 pub manifest: String,
88 pub cataloged: usize,
89 pub hashed: usize,
90 pub preserved: usize,
91 pub bytes: u64,
92 pub wrote: bool,
93 pub dry_run: bool,
94 pub warnings: Vec<String>,
95 pub untracked: Vec<String>,
96}
97
98#[derive(Debug, Serialize)]
100pub struct AssetState {
101 pub path: String,
102 pub sha256: String,
103 pub bytes: u64,
104 pub required: bool,
105 pub state: String,
107}
108
109#[derive(Debug, Serialize)]
111pub struct StatusReport {
112 pub total: usize,
113 pub present: usize,
114 pub missing: usize,
115 pub required_missing: usize,
116 pub optional_missing: usize,
117 pub bytes_total: u64,
118 pub bytes_missing: u64,
119 pub assets: Vec<AssetState>,
120}
121
122#[derive(Debug, Serialize)]
124pub struct VerifyReport {
125 pub mode: String,
126 pub checked: usize,
127 pub ok: usize,
128 pub missing: Vec<String>,
129 pub corrupt: Vec<String>,
130 pub complete: bool,
131}
132
133pub fn read_manifest(store: &Store) -> crate::Result<Vec<AssetRecord>> {
142 let abs = store.root.join(MANIFEST_FILE);
143 if !abs.exists() {
144 return Ok(Vec::new());
145 }
146 let text = std::fs::read_to_string(&abs)?;
147 let mut by_path: BTreeMap<String, AssetRecord> = BTreeMap::new();
148 for (i, line) in text.lines().enumerate() {
149 if line.trim().is_empty() {
150 continue;
151 }
152 let rec: AssetRecord = serde_json::from_str(line).map_err(|e| {
153 std::io::Error::new(
154 std::io::ErrorKind::InvalidData,
155 format!("{MANIFEST_FILE} line {}: {e}", i + 1),
156 )
157 })?;
158 by_path.insert(rec.path.clone(), rec);
159 }
160 Ok(by_path.into_values().collect())
161}
162
163fn serialize_manifest(records: &[AssetRecord]) -> String {
170 if records.is_empty() {
171 return String::new();
172 }
173 let mut sorted = records.to_vec();
174 sorted.sort_by(|a, b| a.path.cmp(&b.path));
175 let mut out = String::new();
176 for rec in &sorted {
177 let line = serde_json::to_string(rec).expect("AssetRecord serializes");
178 out.push_str(&line);
179 out.push('\n');
180 }
181 out
182}
183
184pub fn write_manifest(store: &Store, records: &[AssetRecord]) -> crate::Result<()> {
187 let abs = store.root.join(MANIFEST_FILE);
188 let out = serialize_manifest(records);
189 if out.is_empty() {
190 if abs.exists() {
191 std::fs::remove_file(&abs)?;
192 }
193 return Ok(());
194 }
195 write_atomic(&abs, out.as_bytes())?;
196 Ok(())
197}
198
199pub fn scan(store: &Store, dry_run: bool, untracked: bool) -> crate::Result<ScanReport> {
212 let existing_by_path: BTreeMap<String, AssetRecord> = read_manifest(store)
216 .unwrap_or_default()
217 .into_iter()
218 .map(|r| (r.path.clone(), r))
219 .collect();
220
221 let mut wrappers_by_path: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
223 let mut required_by_path: BTreeMap<String, bool> = BTreeMap::new();
224 let mut declared_paths: BTreeSet<String> = BTreeSet::new();
225 let mut warnings: Vec<String> = Vec::new();
226
227 for rel in store.walk()? {
228 let abs = store.abs_path(&rel);
229 let (fm, _body) = match parser::read_file(&abs) {
230 Ok(v) => v,
231 Err(_) => continue, };
233 let wrapper = rel_to_string(&rel);
234 for decl in declared_assets(&fm) {
235 let norm = match normalize_asset_path(&decl.path) {
236 Ok(n) => n,
237 Err(e) => {
238 warnings.push(format!("{wrapper}: {e}"));
239 continue;
240 }
241 };
242 if is_markdown(&norm) {
243 warnings.push(format!(
244 "{wrapper}: asset path points at a markdown content file ({norm}); skipped"
245 ));
246 continue;
247 }
248 wrappers_by_path
249 .entry(norm.clone())
250 .or_default()
251 .insert(wrapper.clone());
252 let req = required_by_path.entry(norm.clone()).or_insert(false);
253 *req = *req || decl.required;
254 declared_paths.insert(norm);
255 }
256 }
257
258 let mut records: Vec<AssetRecord> = Vec::new();
260 let mut hashed = 0usize;
261 let mut preserved = 0usize;
262 for (path, wrappers) in &wrappers_by_path {
263 let required = *required_by_path.get(path).unwrap_or(&true);
264 let wrappers: Vec<String> = wrappers.iter().cloned().collect();
265
266 let abs = match store::ensure_path_within_store(&store.root, &store.root.join(path)) {
268 Ok(p) => p,
269 Err(_) => {
270 warnings.push(format!("{path}: escapes the store root; skipped"));
271 continue;
272 }
273 };
274
275 if abs.is_dir() {
276 warnings.push(format!("{path}: is a directory, not a file; skipped"));
277 continue;
278 }
279 if abs.is_file() {
280 let (sha256, bytes) = sha256_file(&abs)?;
281 records.push(AssetRecord {
282 path: path.clone(),
283 sha256,
284 bytes,
285 media_type: media_type_for(path),
286 wrappers,
287 required,
288 });
289 hashed += 1;
290 } else if let Some(prev) = existing_by_path.get(path) {
291 records.push(AssetRecord {
294 path: path.clone(),
295 sha256: prev.sha256.clone(),
296 bytes: prev.bytes,
297 media_type: media_type_for(path),
298 wrappers,
299 required,
300 });
301 preserved += 1;
302 } else {
303 warnings.push(format!(
304 "{path}: declared but absent and never cataloged; cannot hash (skipped)"
305 ));
306 }
307 }
308 records.sort_by(|a, b| a.path.cmp(&b.path));
309
310 let bytes: u64 = records.iter().fold(0u64, |a, r| a.saturating_add(r.bytes));
313 let cataloged = records.len();
314
315 let untracked_list = if untracked {
316 find_untracked(store, &declared_paths)?
317 } else {
318 Vec::new()
319 };
320
321 let mut wrote = false;
331 if !dry_run {
332 let canonical = serialize_manifest(&records);
333 let abs = store.root.join(MANIFEST_FILE);
334 let on_disk = std::fs::read(&abs).unwrap_or_default();
335 if on_disk != canonical.as_bytes() {
336 write_manifest(store, &records)?;
337 wrote = true;
338 }
339 }
340
341 Ok(ScanReport {
342 manifest: MANIFEST_FILE.to_string(),
343 cataloged,
344 hashed,
345 preserved,
346 bytes,
347 wrote,
348 dry_run,
349 warnings,
350 untracked: untracked_list,
351 })
352}
353
354pub fn verify(store: &Store, include_optional: bool, quick: bool) -> crate::Result<VerifyReport> {
364 let records = read_manifest(store)?;
365 let mut missing = Vec::new();
366 let mut corrupt = Vec::new();
367 let mut checked = 0usize;
368
369 for rec in &records {
370 if !rec.required && !include_optional {
371 continue;
372 }
373 checked += 1;
374 let abs = match store::ensure_path_within_store(&store.root, &store.root.join(&rec.path)) {
375 Ok(p) => p,
376 Err(_) => {
377 corrupt.push(rec.path.clone());
379 continue;
380 }
381 };
382 if !abs.is_file() {
383 missing.push(rec.path.clone());
384 continue;
385 }
386 if quick {
387 let len = std::fs::metadata(&abs)?.len();
388 if len != rec.bytes {
389 corrupt.push(rec.path.clone());
390 }
391 } else {
392 let (sha, bytes) = sha256_file(&abs)?;
393 if sha != rec.sha256 || bytes != rec.bytes {
394 corrupt.push(rec.path.clone());
395 }
396 }
397 }
398
399 let ok = checked - missing.len() - corrupt.len();
400 let complete = missing.is_empty() && corrupt.is_empty();
401 Ok(VerifyReport {
402 mode: if quick { "quick" } else { "deep" }.to_string(),
403 checked,
404 ok,
405 missing,
406 corrupt,
407 complete,
408 })
409}
410
411pub fn status(store: &Store) -> crate::Result<StatusReport> {
419 let records = read_manifest(store)?;
420 let mut present = 0usize;
421 let mut missing = 0usize;
422 let mut required_missing = 0usize;
423 let mut optional_missing = 0usize;
424 let mut bytes_total = 0u64;
425 let mut bytes_missing = 0u64;
426 let mut assets = Vec::with_capacity(records.len());
427
428 for rec in &records {
429 bytes_total = bytes_total.saturating_add(rec.bytes);
434 let is_present = store::ensure_path_within_store(&store.root, &store.root.join(&rec.path))
442 .map(|p| p.is_file())
443 .unwrap_or(false);
444 let state = if is_present {
445 present += 1;
446 "present"
447 } else {
448 missing += 1;
449 bytes_missing = bytes_missing.saturating_add(rec.bytes);
450 if rec.required {
451 required_missing += 1;
452 } else {
453 optional_missing += 1;
454 }
455 "missing"
456 };
457 assets.push(AssetState {
458 path: rec.path.clone(),
459 sha256: rec.sha256.clone(),
460 bytes: rec.bytes,
461 required: rec.required,
462 state: state.to_string(),
463 });
464 }
465
466 Ok(StatusReport {
467 total: records.len(),
468 present,
469 missing,
470 required_missing,
471 optional_missing,
472 bytes_total,
473 bytes_missing,
474 assets,
475 })
476}
477
478pub fn paths(store: &Store) -> crate::Result<Vec<String>> {
496 Ok(read_manifest(store)?
497 .into_iter()
498 .filter(|r| store::ensure_path_within_store(&store.root, &store.root.join(&r.path)).is_ok())
499 .map(|r| r.path)
500 .collect())
501}
502
503pub fn declared_assets(fm: &parser::Frontmatter) -> Vec<Declaration> {
513 let mut out = Vec::new();
514 if let Some(v) = fm.get("asset") {
515 collect_declarations(&v, &mut out);
516 }
517 if let Some(v) = fm.get("assets") {
518 collect_declarations(&v, &mut out);
519 }
520 out
521}
522
523pub fn declarations_from_yaml_map(map: &BTreeMap<String, Value>) -> Vec<Declaration> {
527 let mut out = Vec::new();
528 if let Some(v) = map.get("asset") {
529 collect_declarations(v, &mut out);
530 }
531 if let Some(v) = map.get("assets") {
532 collect_declarations(v, &mut out);
533 }
534 out
535}
536
537fn collect_declarations(v: &Value, out: &mut Vec<Declaration>) {
538 match v {
539 Value::String(s) => out.push(Declaration {
540 path: s.clone(),
541 required: true,
542 }),
543 Value::Sequence(items) => {
544 for item in items {
545 match item {
546 Value::String(s) => out.push(Declaration {
547 path: s.clone(),
548 required: true,
549 }),
550 Value::Mapping(m) => {
551 let path = m
552 .get(Value::String("path".to_string()))
553 .and_then(|x| x.as_str())
554 .map(|s| s.to_string());
555 if let Some(path) = path {
556 let required = m
557 .get(Value::String("required".to_string()))
558 .and_then(|x| x.as_bool())
559 .unwrap_or(true);
560 out.push(Declaration { path, required });
561 }
562 }
563 _ => {}
564 }
565 }
566 }
567 _ => {}
568 }
569}
570
571pub fn normalize_asset_path(raw: &str) -> Result<String, String> {
587 let trimmed = raw.trim();
588 if trimmed.is_empty() {
589 return Err("empty asset path".to_string());
590 }
591 let p = Path::new(trimmed);
592 if p.is_absolute() {
593 return Err(format!("absolute asset path not allowed: {raw}"));
594 }
595 let mut normal: Vec<&std::ffi::OsStr> = Vec::new();
596 for c in p.components() {
597 match c {
598 Component::ParentDir => return Err(format!("`..` not allowed in asset path: {raw}")),
599 Component::Prefix(_) | Component::RootDir => {
600 return Err(format!("asset path escapes the store: {raw}"))
601 }
602 Component::CurDir => {}
605 Component::Normal(seg) => normal.push(seg),
606 }
607 }
608 if normal.is_empty() {
609 return Err(format!("asset path names no file: {raw}"));
611 }
612 let joined: PathBuf = normal.into_iter().collect();
613 Ok(joined.to_string_lossy().replace('\\', "/"))
614}
615
616fn is_markdown(path: &str) -> bool {
617 Path::new(path)
618 .extension()
619 .and_then(|e| e.to_str())
620 .map(|e| e.eq_ignore_ascii_case("md"))
621 .unwrap_or(false)
622}
623
624fn rel_to_string(p: &Path) -> String {
625 p.to_string_lossy().replace('\\', "/")
626}
627
628fn sha256_file(abs: &Path) -> std::io::Result<(String, u64)> {
631 let mut f = std::fs::File::open(abs)?;
632 let mut hasher = Sha256::new();
633 let mut buf = [0u8; 65536];
634 let mut total: u64 = 0;
635 loop {
636 let n = f.read(&mut buf)?;
637 if n == 0 {
638 break;
639 }
640 hasher.update(&buf[..n]);
641 total += n as u64;
642 }
643 let digest = hasher.finalize();
644 let mut hex = String::with_capacity(64);
645 for b in digest.iter() {
646 let _ = write!(hex, "{b:02x}");
647 }
648 Ok((hex, total))
649}
650
651fn media_type_for(path: &str) -> String {
655 let ext = Path::new(path)
656 .extension()
657 .and_then(|e| e.to_str())
658 .unwrap_or("")
659 .to_ascii_lowercase();
660 let mt = match ext.as_str() {
661 "pdf" => "application/pdf",
662 "png" => "image/png",
663 "jpg" | "jpeg" => "image/jpeg",
664 "gif" => "image/gif",
665 "webp" => "image/webp",
666 "svg" => "image/svg+xml",
667 "tiff" | "tif" => "image/tiff",
668 "mp4" => "video/mp4",
669 "mov" => "video/quicktime",
670 "webm" => "video/webm",
671 "mkv" => "video/x-matroska",
672 "mp3" => "audio/mpeg",
673 "wav" => "audio/wav",
674 "m4a" => "audio/mp4",
675 "flac" => "audio/flac",
676 "zip" => "application/zip",
677 "gz" | "tgz" => "application/gzip",
678 "tar" => "application/x-tar",
679 "csv" => "text/csv",
680 "tsv" => "text/tab-separated-values",
681 "json" => "application/json",
682 "xml" => "application/xml",
683 "txt" => "text/plain",
684 "vtt" => "text/vtt",
685 "srt" => "application/x-subrip",
686 "html" | "htm" => "text/html",
687 "epub" => "application/epub+zip",
688 "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
689 "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
690 "pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
691 "doc" => "application/msword",
692 "xls" => "application/vnd.ms-excel",
693 "ppt" => "application/vnd.ms-powerpoint",
694 _ => "application/octet-stream",
695 };
696 mt.to_string()
697}
698
699fn find_untracked(store: &Store, declared: &BTreeSet<String>) -> crate::Result<Vec<String>> {
703 let sources = store.root.join("sources");
704 if !sources.is_dir() {
705 return Ok(Vec::new());
706 }
707 let mut out = Vec::new();
708 for entry in walkdir::WalkDir::new(&sources)
709 .into_iter()
710 .filter_entry(|e| !is_hidden(e.file_name().to_str().unwrap_or("")))
711 {
712 let entry = match entry {
713 Ok(e) => e,
714 Err(_) => continue,
715 };
716 if !entry.file_type().is_file() {
717 continue;
718 }
719 let name = entry.file_name().to_str().unwrap_or("");
720 if is_markdown(name) || name == "index.jsonl" {
721 continue;
722 }
723 let rel = match entry.path().strip_prefix(&store.root) {
724 Ok(r) => rel_to_string(r),
725 Err(_) => continue,
726 };
727 if !declared.contains(&rel) {
728 out.push(rel);
729 }
730 }
731 out.sort();
732 Ok(out)
733}
734
735fn is_hidden(name: &str) -> bool {
736 name.starts_with('.') && name != "." && name != ".."
737}
738
739#[cfg(test)]
740mod tests {
741 use super::*;
742
743 #[test]
749 fn normalize_asset_path_folds_curdir_and_rejects_traversal() {
750 assert_eq!(
751 normalize_asset_path("./sources/x.pdf").unwrap(),
752 "sources/x.pdf"
753 );
754 assert_eq!(
755 normalize_asset_path("sources/x.pdf").unwrap(),
756 "sources/x.pdf"
757 );
758 assert_eq!(
759 normalize_asset_path("sources/./x.pdf").unwrap(),
760 "sources/x.pdf"
761 );
762 assert_eq!(
763 normalize_asset_path("sources/x.pdf/").unwrap(),
764 "sources/x.pdf"
765 );
766
767 assert!(normalize_asset_path("../outside.txt").is_err());
769 assert!(normalize_asset_path("sources/../../etc/passwd").is_err());
770 assert!(normalize_asset_path("/abs/x.pdf").is_err());
771 assert!(normalize_asset_path(".").is_err());
773 assert!(normalize_asset_path("./").is_err());
774 assert!(normalize_asset_path("").is_err());
775 }
776
777 #[test]
782 fn status_and_scan_saturate_on_overflowing_manifest_bytes() {
783 let tmp = tempfile::TempDir::new().unwrap();
784 let root = tmp.path();
785 std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n# store\n").unwrap();
786 std::fs::write(
788 root.join("assets.jsonl"),
789 "{\"path\":\"records/a.bin\",\"sha256\":\"x\",\"bytes\":18446744073709551615,\
790\"media_type\":\"application/octet-stream\",\"wrappers\":[\"records/w.md\"],\"required\":true}\n\
791{\"path\":\"records/b.bin\",\"sha256\":\"y\",\"bytes\":1,\
792\"media_type\":\"application/octet-stream\",\"wrappers\":[\"records/w.md\"],\"required\":true}\n",
793 )
794 .unwrap();
795 let store = Store {
796 root: root.to_path_buf(),
797 config: crate::parser::Config::default(),
798 };
799
800 let report = status(&store).expect("status is non-failing on a poisoned manifest");
803 assert_eq!(
804 report.bytes_total,
805 u64::MAX,
806 "byte total must saturate, not wrap"
807 );
808 assert_eq!(
809 report.bytes_missing,
810 u64::MAX,
811 "missing bytes must saturate too"
812 );
813 assert_eq!(report.total, 2);
814
815 scan(&store, true, false).expect("scan must not overflow on a poisoned manifest");
817 }
818
819 fn store_with_one_asset() -> (tempfile::TempDir, Store, String) {
822 let tmp = tempfile::TempDir::new().unwrap();
823 let root = tmp.path();
824 std::fs::create_dir_all(root.join("sources")).unwrap();
825 std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n# store\n").unwrap();
826 std::fs::write(
827 root.join("sources/a.pdf.md"),
828 "---\ntype: pdf-source\nsummary: x\nasset: sources/a.pdf\n---\nbody\n",
829 )
830 .unwrap();
831 std::fs::write(root.join("sources/a.pdf"), b"PDFBYTES").unwrap();
832 let store = Store {
833 root: root.to_path_buf(),
834 config: crate::parser::Config::default(),
835 };
836 let report = scan(&store, false, false).unwrap();
837 assert!(report.wrote, "first scan writes the manifest");
838 let canonical = std::fs::read_to_string(root.join(MANIFEST_FILE)).unwrap();
839 (tmp, store, canonical)
840 }
841
842 #[test]
851 fn scan_recompacts_duplicate_line_manifest() {
852 let (_tmp, store, canonical) = store_with_one_asset();
853 let abs = store.root.join(MANIFEST_FILE);
854
855 std::fs::write(&abs, format!("{canonical}{canonical}")).unwrap();
857 assert_eq!(std::fs::read_to_string(&abs).unwrap().lines().count(), 2);
858
859 let report = scan(&store, false, false).unwrap();
860 assert!(
861 report.wrote,
862 "a non-canonical (duplicate-line) manifest must be recompacted and reported as updated"
863 );
864 let after = std::fs::read_to_string(&abs).unwrap();
865 assert_eq!(
866 after.lines().count(),
867 1,
868 "duplicate lines must collapse to the single canonical line"
869 );
870 assert_eq!(
871 after, canonical,
872 "scan must restore the exact canonical bytes"
873 );
874 }
875
876 #[test]
880 fn scan_recompacts_noncanonical_byte_layout() {
881 let (_tmp, store, canonical) = store_with_one_asset();
882 let abs = store.root.join(MANIFEST_FILE);
883
884 std::fs::write(&abs, canonical.trim_end_matches('\n')).unwrap();
886 let report = scan(&store, false, false).unwrap();
887 assert!(
888 report.wrote,
889 "a manifest missing its trailing newline must be recompacted"
890 );
891 assert_eq!(
892 std::fs::read_to_string(&abs).unwrap(),
893 canonical,
894 "scan must restore the canonical trailing newline"
895 );
896 }
897
898 #[test]
908 fn paths_omits_store_escaping_records() {
909 let tmp = tempfile::TempDir::new().unwrap();
910 let root = tmp.path();
911 std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n# store\n").unwrap();
912 std::fs::write(
914 root.join("assets.jsonl"),
915 "{\"path\":\"sources/legit.pdf\",\"sha256\":\"a\",\"bytes\":9,\
916\"media_type\":\"application/pdf\",\"wrappers\":[\"sources/legit.pdf.md\"],\"required\":true}\n\
917{\"path\":\"../../../../../../etc/passwd\",\"sha256\":\"b\",\"bytes\":4096,\
918\"media_type\":\"text/plain\",\"wrappers\":[\"sources/legit.pdf.md\"],\"required\":false}\n\
919{\"path\":\"/etc/hosts\",\"sha256\":\"c\",\"bytes\":4096,\
920\"media_type\":\"text/plain\",\"wrappers\":[\"sources/legit.pdf.md\"],\"required\":false}\n",
921 )
922 .unwrap();
923 let store = Store {
924 root: root.to_path_buf(),
925 config: crate::parser::Config::default(),
926 };
927
928 let out = paths(&store).expect("paths is non-failing on a poisoned manifest");
929 assert_eq!(
930 out,
931 vec!["sources/legit.pdf".to_string()],
932 "only the safe in-store path is emitted; escaping paths are omitted"
933 );
934 assert!(
935 !out.iter().any(|p| p.starts_with('/') || p.contains("..")),
936 "no absolute or `..` path may ever leak from `paths`: {out:?}"
937 );
938 }
939
940 #[test]
943 fn paths_passes_a_clean_manifest_through_unchanged() {
944 let (_tmp, store, _canonical) = store_with_one_asset();
945 let out = paths(&store).expect("paths over a clean manifest");
946 assert_eq!(out, vec!["sources/a.pdf".to_string()]);
947 }
948
949 #[test]
953 fn scan_canonical_manifest_is_left_untouched() {
954 let (_tmp, store, canonical) = store_with_one_asset();
955 let abs = store.root.join(MANIFEST_FILE);
956
957 let report = scan(&store, false, false).unwrap();
958 assert!(
959 !report.wrote,
960 "a canonical, unchanged manifest must not be rewritten"
961 );
962 assert_eq!(
963 std::fs::read_to_string(&abs).unwrap(),
964 canonical,
965 "a no-op rescan must leave the manifest byte-identical"
966 );
967 }
968}