1use crate::container::{ContainerIndex, EntryKind};
25use crate::error::{Error, Result};
26use crate::restore::RestoreReport;
27use std::path::{Path, PathBuf};
28
29#[derive(Debug, Clone)]
31pub struct SkippedEntry {
32 pub path: String,
34 pub reason: &'static str,
36}
37
38#[derive(Debug)]
40pub struct ExtractReport {
41 pub root: PathBuf,
43 pub files: usize,
45 pub dirs: usize,
47 pub links: usize,
49 pub adjusted: Vec<String>,
54 pub skipped: Vec<SkippedEntry>,
56 pub metadata: RestoreReport,
58}
59
60pub fn extract_container(
71 idx: &ContainerIndex,
72 payload: &[u8],
73 target: &Path,
74) -> Result<ExtractReport> {
75 extract_container_with(
76 idx,
77 &mut |off, len| {
78 let start = usize::try_from(off).unwrap_or(usize::MAX);
79 let end = start.saturating_add(usize::try_from(len).unwrap_or(usize::MAX));
80 payload.get(start..end).map(<[u8]>::to_vec).ok_or(Error::MalformedHeader {
83 reason: "container entry payload range out of bounds",
84 })
85 },
86 target,
87 )
88}
89
90pub const STREAM_WINDOW: u64 = 4 * 1024 * 1024;
92
93pub fn extract_container_with(
104 idx: &ContainerIndex,
105 read: &mut dyn FnMut(u64, u64) -> Result<Vec<u8>>,
106 target: &Path,
107) -> Result<ExtractReport> {
108 let root = target.join(sanitize_filename(&idx.root));
109 std::fs::create_dir_all(&root).map_err(Error::Io)?;
110
111 let mut files = 0usize;
112 let mut dirs = 0usize;
113 let mut links = 0usize;
114 let mut unsupported_links = 0usize;
117 let mut link_failures: Vec<(String, String)> = Vec::new();
119 let mut adjusted: Vec<String> = Vec::new();
120 let mut skipped: Vec<SkippedEntry> = Vec::new();
121
122 for e in &idx.entries {
125 if e.kind == EntryKind::Dir {
126 let (p, was_adjusted) = safe_join(&root, &e.path)?;
127 if was_adjusted {
128 adjusted.push(e.display_path());
129 }
130 std::fs::create_dir_all(&p).map_err(Error::Io)?;
131 dirs = dirs.saturating_add(1);
132 }
133 }
134
135 for e in &idx.entries {
136 match e.kind {
137 EntryKind::File => {
138 let (p, was_adjusted) = safe_join(&root, &e.path)?;
139 if was_adjusted {
140 adjusted.push(e.display_path());
141 }
142 if let Some(parent) = p.parent() {
143 std::fs::create_dir_all(parent).ok();
145 }
146 write_entry(&p, e.offset, e.size, e.hash.as_ref(), read)?;
147 files = files.saturating_add(1);
148 }
149 EntryKind::Symlink | EntryKind::Dir => {}
150 }
151 }
152
153 for e in &idx.entries {
162 if e.kind != EntryKind::Symlink {
163 continue;
164 }
165 let (p, was_adjusted) = safe_join(&root, &e.path)?;
166 if was_adjusted {
167 adjusted.push(e.display_path());
168 }
169 if let Some(parent) = p.parent() {
170 std::fs::create_dir_all(parent).ok();
171 }
172
173 let Some(target) = &e.target else {
177 skipped.push(SkippedEntry {
178 path: e.display_path(),
179 reason: "symlink_no_target",
180 });
181 continue;
182 };
183
184 if !link_target_stays_inside(&root, &p, target) {
194 skipped.push(SkippedEntry {
195 path: e.display_path(),
196 reason: "symlink_escapes_root",
197 });
198 continue;
199 }
200
201 match create_symlink(target, &p) {
202 Ok(()) => links = links.saturating_add(1),
203 Err(err) if is_privilege_error(&err) => {
204 unsupported_links = unsupported_links.saturating_add(1);
208 }
209 Err(err) => {
210 link_failures.push((e.display_path(), err.to_string()));
213 }
214 }
215 }
216
217 let mut metadata = crate::restore::restore_metadata(&root, idx);
219
220 metadata.note_unsupported_n(
224 crate::restore::UnsupportedKind::Symlink,
225 unsupported_links,
226 );
227 for (path, reason) in link_failures {
228 metadata.push_failure(path, "symlink", reason);
229 }
230
231 Ok(ExtractReport {
232 root,
233 files,
234 dirs,
235 links,
236 adjusted,
237 skipped,
238 metadata,
239 })
240}
241
242fn write_entry(
248 path: &Path,
249 offset: u64,
250 size: u64,
251 expect: Option<&[u8; 32]>,
252 read: &mut dyn FnMut(u64, u64) -> Result<Vec<u8>>,
253) -> Result<()> {
254 use blake2::digest::{Digest, consts::U32};
255 use std::io::Write as _;
256
257 let mut out = std::io::BufWriter::new(std::fs::File::create(path).map_err(Error::Io)?);
258 let mut hasher = blake2::Blake2b::<U32>::new();
259 let mut done = 0u64;
260 let result = (|| {
261 while done < size {
262 let n = size.saturating_sub(done).min(STREAM_WINDOW);
263 let bytes = read(offset.saturating_add(done), n)?;
264 if bytes.len() as u64 != n {
265 return Err(Error::MalformedHeader {
266 reason: "container entry payload range out of bounds",
267 });
268 }
269 hasher.update(&bytes);
270 out.write_all(&bytes).map_err(Error::Io)?;
271 done = done.saturating_add(n);
272 }
273 out.flush().map_err(Error::Io)?;
274 if let Some(expect) = expect {
275 let actual: [u8; 32] = hasher.clone().finalize().into();
276 if &actual != expect {
277 return Err(Error::ContentHashMismatch);
278 }
279 }
280 Ok(())
281 })();
282 if result.is_err() {
283 drop(out);
284 let _ = std::fs::remove_file(path);
285 }
286 result
287}
288
289#[cfg(windows)]
328fn create_symlink(target: &str, link: &Path) -> std::io::Result<()> {
329 let target = target.replace('/', "\\");
331
332 let base = link.parent().unwrap_or(Path::new("."));
335 let resolved = base.join(&target);
336
337 if resolved.is_dir() {
338 std::os::windows::fs::symlink_dir(&target, link)
339 } else {
340 std::os::windows::fs::symlink_file(&target, link)
341 }
342}
343
344#[cfg(unix)]
345fn create_symlink(target: &str, link: &Path) -> std::io::Result<()> {
346 std::os::unix::fs::symlink(target, link)
348}
349
350fn link_target_stays_inside(root: &Path, link: &Path, target: &str) -> bool {
363 let t = Path::new(target);
364
365 if t.is_absolute() || t.has_root() {
369 return false;
370 }
371
372 let Some(base) = link.parent() else {
374 return false;
375 };
376
377 let mut acc = base.to_path_buf();
380 for comp in t.components() {
381 match comp {
382 std::path::Component::ParentDir => {
383 if acc == root {
386 return false;
387 }
388 if !acc.pop() {
389 return false;
390 }
391 }
392 std::path::Component::CurDir => {}
393 other => acc.push(other.as_os_str()),
395 }
396 }
397
398 acc.starts_with(root)
399}
400
401#[cfg(windows)]
412fn is_privilege_error(e: &std::io::Error) -> bool {
413 const ERROR_PRIVILEGE_NOT_HELD: i32 = 1314;
415
416 e.raw_os_error() == Some(ERROR_PRIVILEGE_NOT_HELD)
417 || e.kind() == std::io::ErrorKind::PermissionDenied
418}
419
420#[cfg(not(windows))]
421fn is_privilege_error(e: &std::io::Error) -> bool {
422 e.kind() == std::io::ErrorKind::PermissionDenied
425}
426
427pub fn safe_join(root: &Path, comps: &[String]) -> Result<(PathBuf, bool)> {
439 let mut p = root.to_path_buf();
440 let mut adjusted = false;
441 for c in comps {
442 crate::container::validate_component(c)?;
443 let s = sanitize_filename(c);
444 if s != *c {
445 adjusted = true;
446 }
447 p.push(s);
448 }
449 if !p.starts_with(root) {
450 return Err(Error::MalformedHeader {
451 reason: "container entry path escapes target directory",
452 });
453 }
454 Ok((p, adjusted))
455}
456
457#[must_use]
462pub fn sanitize_filename(name: &str) -> String {
463 #[cfg(windows)]
464 {
465 const BAD: &[char] = &['<', '>', ':', '"', '/', '\\', '|', '?', '*'];
466 const RESERVED: &[&str] = &[
467 "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7",
468 "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8",
469 "LPT9",
470 ];
471 let mut s: String = name
472 .chars()
473 .map(|c| {
474 if BAD.contains(&c) || (c as u32) < 0x20 {
475 '_'
476 } else {
477 c
478 }
479 })
480 .collect();
481 while s.ends_with('.') || s.ends_with(' ') {
484 s.pop();
485 }
486 let stem = s.split('.').next().unwrap_or("").to_ascii_uppercase();
487 if RESERVED.contains(&stem.as_str()) {
488 s = format!("_{s}");
489 }
490 if s.is_empty() {
491 s = String::from("_");
492 }
493 s
494 }
495 #[cfg(not(windows))]
496 {
497 let s: String = name
499 .chars()
500 .map(|c| if c == '/' || c == '\0' { '_' } else { c })
501 .collect();
502 if s.is_empty() { String::from("_") } else { s }
503 }
504}
505
506#[cfg(test)]
507mod tests {
508 use super::*;
509 use crate::container::{ContainerBuilder, EntryMeta};
510
511 fn tmp(tag: &str) -> PathBuf {
512 let p = std::env::temp_dir().join(format!("omy-unpack-{tag}"));
513 let _ = std::fs::remove_dir_all(&p);
514 std::fs::create_dir_all(&p).unwrap();
515 p
516 }
517
518 #[test]
519 fn extracts_files_dirs_and_empty_dirs() {
520 let root = tmp("basic");
521 let mut b = ContainerBuilder::new("myfolder");
522 b.add_dir(vec!["sub".into()], EntryMeta::default()).unwrap();
523 b.add_dir(vec!["empty".into()], EntryMeta::default())
524 .unwrap();
525 b.add_file(vec!["a.txt".into()], 3, None, EntryMeta::default())
526 .unwrap();
527 b.add_file(
528 vec!["sub".into(), "b.txt".into()],
529 4,
530 None,
531 EntryMeta::default(),
532 )
533 .unwrap();
534 let idx = b.finish().unwrap();
535
536 let rep = extract_container(&idx, b"aaabbbb", &root).unwrap();
537 assert_eq!(rep.files, 2);
538 assert_eq!(rep.dirs, 2);
539 assert!(rep.root.ends_with("myfolder"), "根名取自索引");
540 assert_eq!(
541 std::fs::read(rep.root.join("a.txt")).unwrap(),
542 b"aaa",
543 "区间必须对上:错了会解出别的文件的内容"
544 );
545 assert_eq!(std::fs::read(rep.root.join("sub/b.txt")).unwrap(), b"bbbb");
546 assert!(rep.root.join("empty").is_dir(), "空目录必须还原");
549
550 let _ = std::fs::remove_dir_all(&root);
551 }
552
553 #[test]
554 fn metadata_is_restored_without_caller_doing_anything() {
555 let root = tmp("meta");
558 const T: i128 = 1_551_968_736_000_000_000;
559 let mut b = ContainerBuilder::new("f");
560 b.add_dir(vec!["sub".into()], EntryMeta {
561 mtime_ns: Some(T),
562 ..EntryMeta::default()
563 })
564 .unwrap();
565 b.add_file(vec!["sub".into(), "a.txt".into()], 1, None, EntryMeta {
566 mtime_ns: Some(T),
567 ..EntryMeta::default()
568 })
569 .unwrap();
570 let idx = b.finish().unwrap();
571
572 let rep = extract_container(&idx, b"x", &root).unwrap();
573 assert_eq!(rep.metadata.mtime_restored, 2, "文件和目录都该设上");
574
575 for rel in ["sub", "sub/a.txt"] {
577 let got = std::fs::metadata(rep.root.join(rel))
578 .unwrap()
579 .modified()
580 .unwrap();
581 let want = std::time::UNIX_EPOCH + std::time::Duration::from_nanos(T as u64);
582 let d = got
583 .duration_since(want)
584 .unwrap_or_else(|e| e.duration())
585 .as_secs();
586 assert!(d < 2, "{rel} 的 mtime 应当已还原,实际 {got:?}");
587 }
588
589 let _ = std::fs::remove_dir_all(&root);
590 }
591
592 #[test]
593 fn hash_mismatch_is_caught_per_file() {
594 let root = tmp("hash");
597 let mut b = ContainerBuilder::new("f");
598 b.add_file(vec!["a.txt".into()], 3, Some([0u8; 32]), EntryMeta::default())
599 .unwrap();
600 let idx = b.finish().unwrap();
601 assert!(
602 extract_container(&idx, b"aaa", &root).is_err(),
603 "哈希不匹配必须报错,不能静默写出坏内容"
604 );
605 let _ = std::fs::remove_dir_all(&root);
606 }
607
608 #[test]
609 fn payload_range_out_of_bounds_is_rejected() {
610 let root = tmp("oob");
612 let mut b = ContainerBuilder::new("f");
613 b.add_file(vec!["a.txt".into()], 999, None, EntryMeta::default())
614 .unwrap();
615 let idx = b.finish().unwrap();
616 assert!(extract_container(&idx, b"aaa", &root).is_err());
617 let _ = std::fs::remove_dir_all(&root);
618 }
619
620 #[test]
621 fn symlinks_are_restored_or_reported_never_silently_dropped() {
622 let root = tmp("symlink");
630 let mut b = ContainerBuilder::new("f");
631 b.add_file(vec!["target.txt".into()], 3, None, EntryMeta::default())
632 .unwrap();
633 b.add_symlink(
634 vec!["link".into()],
635 "target.txt".into(),
636 EntryMeta::default(),
637 )
638 .unwrap();
639 let idx = b.finish().unwrap();
640
641 let rep = extract_container(&idx, b"abc", &root).unwrap();
642 let link = rep.root.join("link");
643
644 let unsupported_links = rep
645 .metadata
646 .unsupported
647 .iter()
648 .find(|(k, _)| *k == crate::restore::UnsupportedKind::Symlink)
649 .map(|(_, n)| *n)
650 .unwrap_or(0);
651
652 if rep.links == 1 {
653 let md = std::fs::symlink_metadata(&link).expect("链接该在磁盘上");
656 assert!(
657 md.file_type().is_symlink(),
658 "报告说建了链接,磁盘上却不是链接"
659 );
660 assert_eq!(
661 std::fs::read_link(&link).unwrap().to_string_lossy(),
662 "target.txt",
663 "目标要和存进去的一致,指错地方等于没还原"
664 );
665 assert_eq!(unsupported_links, 0, "建成功了就不该报不支持");
666 assert!(rep.metadata.failures.is_empty(), "建成功了不该有失败");
667 } else {
668 assert_eq!(rep.links, 0, "要么建成 1 个,要么 0 个");
669 assert_eq!(
670 unsupported_links, 1,
671 "建不出来必须报成 unsupported(换环境能还原),\
672 而不是 failures(让人反复重试)或干脆不报;实际 {:?} / {:?}",
673 rep.metadata.unsupported, rep.metadata.failures
674 );
675 assert!(
676 rep.metadata.failures.is_empty(),
677 "平台限制不该记进 failures,实际 {:?}",
678 rep.metadata.failures
679 );
680 }
681
682 assert_eq!(rep.files, 1);
685 assert_eq!(
686 std::fs::read(rep.root.join("target.txt")).unwrap(),
687 b"abc",
688 "链接的成败不该影响文件内容"
689 );
690
691 let _ = std::fs::remove_dir_all(&root);
692 }
693
694 #[test]
695 fn link_to_dir_inside_container_gets_right_type() {
696 let root = tmp("linkorder");
715 let mut b = ContainerBuilder::new("f");
716 b.add_symlink(vec!["dlink".into()], "sub".into(), EntryMeta::default())
717 .unwrap();
718 b.add_symlink(
719 vec!["flink".into()],
720 "sub/inside.txt".into(),
721 EntryMeta::default(),
722 )
723 .unwrap();
724 b.add_dir(vec!["sub".into()], EntryMeta::default()).unwrap();
725 b.add_file(
726 vec!["sub".into(), "inside.txt".into()],
727 2,
728 None,
729 EntryMeta::default(),
730 )
731 .unwrap();
732 let idx = b.finish().unwrap();
733
734 let rep = extract_container(&idx, b"hi", &root).unwrap();
735
736 if rep.links == 0 {
740 let _ = std::fs::remove_dir_all(&root);
741 return;
742 }
743 assert_eq!(rep.links, 2, "两个链接都该建出来");
744
745 let dmd = std::fs::metadata(rep.root.join("dlink"))
747 .expect("目录链接跟随失败,说明类型选错了");
748 assert!(dmd.is_dir(), "dlink 跟随后该是目录");
749
750 let fmd = std::fs::metadata(rep.root.join("flink"))
752 .expect("文件链接跟随失败,说明类型选错了");
753 assert!(fmd.is_file(), "flink 跟随后该是文件");
754 assert_eq!(
755 std::fs::read(rep.root.join("flink")).expect("读文件链接失败"),
756 b"hi",
757 "透过文件链接该读到目标内容"
758 );
759
760 let _ = std::fs::remove_dir_all(&root);
761 }
762
763 #[test]
764 fn symlink_entry_without_target_is_reported() {
765 let root = tmp("notarget");
769 let mut b = ContainerBuilder::new("f");
770 b.add_symlink(vec!["link".into()], "t".into(), EntryMeta::default())
771 .unwrap();
772 let mut idx = b.finish().unwrap();
773 for e in &mut idx.entries {
775 if e.kind == EntryKind::Symlink {
776 e.target = None;
777 }
778 }
779
780 let rep = extract_container(&idx, b"", &root).unwrap();
781 assert_eq!(rep.links, 0, "没目标建不出链接");
782 assert!(
783 rep.skipped.iter().any(|s| s.reason == "symlink_no_target"),
784 "必须如实报告,实际 {:?}",
785 rep.skipped
786 );
787
788 let _ = std::fs::remove_dir_all(&root);
789 }
790
791 #[test]
792 fn link_targets_pointing_outside_are_refused() {
793 let root = Path::new("/vault/root");
796 let link = root.join("sub").join("l");
797
798 for ok in ["a.txt", "./a.txt", "d/e.txt", "../sibling.txt", "../a/../b"] {
800 assert!(
801 link_target_stays_inside(root, &link, ok),
802 "{ok} 落在容器内,该放行"
803 );
804 }
805
806 for bad in [
808 "../../outside.txt",
809 "../../../../../../etc/passwd",
810 "d/../../../outside.txt",
813 ] {
814 assert!(
815 !link_target_stays_inside(root, &link, bad),
816 "{bad} 爬出了容器,必须拒绝"
817 );
818 }
819
820 for abs in ["/etc/passwd", "/vault/root/a.txt"] {
822 assert!(
823 !link_target_stays_inside(root, &link, abs),
824 "{abs} 是绝对路径,必须拒绝"
825 );
826 }
827 }
828
829 #[cfg(windows)]
830 #[test]
831 fn windows_absolute_link_targets_are_refused() {
832 let root = Path::new(r"C:\vault\root");
836 let link = root.join("l");
837 for bad in [
838 r"C:\Windows\System32",
839 r"\\server\share\x",
840 r"\Windows",
841 r"C:\vault\root\a.txt",
842 ] {
843 assert!(
844 !link_target_stays_inside(root, &link, bad),
845 "{bad} 必须拒绝"
846 );
847 }
848 }
849
850 #[test]
851 fn escaping_link_is_reported_not_created() {
852 let root = tmp("escape");
855 let mut b = ContainerBuilder::new("f");
856 b.add_symlink(
857 vec!["evil".into()],
858 "../../../../outside.txt".into(),
859 EntryMeta::default(),
860 )
861 .unwrap();
862 let idx = b.finish().unwrap();
863
864 let rep = extract_container(&idx, b"", &root).unwrap();
865 assert_eq!(rep.links, 0, "逃逸的链接不该被创建");
866 assert!(
867 rep.skipped
868 .iter()
869 .any(|s| s.reason == "symlink_escapes_root"),
870 "必须如实报告,实际 {:?}",
871 rep.skipped
872 );
873 assert!(
874 !rep.root.join("evil").exists(),
875 "磁盘上不该出现这个链接"
876 );
877
878 let _ = std::fs::remove_dir_all(&root);
879 }
880
881 #[test]
882 fn sanitize_keeps_normal_names() {
883 assert_eq!(sanitize_filename("report.docx"), "report.docx");
884 assert_eq!(sanitize_filename("中文文件名.txt"), "中文文件名.txt");
885 }
886
887 #[cfg(windows)]
888 #[test]
889 fn sanitize_handles_windows_restrictions() {
890 assert_eq!(sanitize_filename("report:2026?.txt"), "report_2026_.txt");
891 assert_eq!(sanitize_filename("a<b>c"), "a_b_c");
892 assert_eq!(sanitize_filename("trailing."), "trailing");
893 assert_eq!(sanitize_filename("CON"), "_CON");
894 assert_eq!(sanitize_filename("con.txt"), "_con.txt");
895 assert_eq!(sanitize_filename(""), "_");
896 }
897
898 #[test]
899 fn safe_join_rejects_traversal() {
900 let root = Path::new("/tmp/root");
903 assert!(safe_join(root, &["..".to_string()]).is_err());
904 assert!(safe_join(root, &["a".to_string(), "..".to_string()]).is_err());
905 let (ok, adjusted) =
906 safe_join(root, &["a".to_string(), "b.txt".to_string()]).unwrap();
907 assert!(ok.starts_with(root));
908 assert!(!adjusted, "正常名字不该被标记为已调整");
909 }
910
911 #[cfg(windows)]
912 #[test]
913 fn adjusted_names_are_reported() {
914 let root = tmp("adjust");
916 let mut b = ContainerBuilder::new("f");
917 b.add_file(vec!["a?b.txt".into()], 1, None, EntryMeta::default())
922 .unwrap();
923 let idx = b.finish().unwrap();
924
925 let rep = extract_container(&idx, b"x", &root).unwrap();
926 assert_eq!(rep.adjusted, vec!["a?b.txt"], "改名必须报告");
927 assert!(rep.root.join("a_b.txt").exists(), "落盘用的是调整后的名字");
928
929 let _ = std::fs::remove_dir_all(&root);
930 }
931}