1use crate::container::{ContainerBuilder, ContainerIndex, EntryMeta};
29use crate::error::{Error, Result};
30use std::path::{Path, PathBuf};
31
32pub const MAX_DEPTH: usize = 64;
37
38#[derive(Debug, Clone)]
43pub struct SkippedEntry {
44 pub path: String,
46 pub reason: SkipReason,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum SkipReason {
53 Symlink,
55 NotRegular,
57 Unreadable,
59 TooDeep,
61}
62
63impl SkipReason {
64 #[must_use]
66 pub const fn code(self) -> &'static str {
67 match self {
68 Self::Symlink => "symlink",
69 Self::NotRegular => "not_regular",
70 Self::Unreadable => "unreadable",
71 Self::TooDeep => "too_deep",
72 }
73 }
74}
75
76#[derive(Debug)]
78pub struct PackedFolder {
79 pub payload: Vec<u8>,
81 pub index: ContainerIndex,
83 pub root_name: String,
85 pub file_count: usize,
87 pub dir_count: usize,
89 pub link_count: usize,
95 pub skipped: Vec<SkippedEntry>,
97}
98
99pub type ProgressFn<'a> = &'a mut dyn FnMut(&str, u64);
105
106#[derive(Debug)]
114pub struct WalkItem {
115 pub path: PathBuf,
117 pub comps: Vec<String>,
119 pub is_dir: bool,
121 pub meta: EntryMeta,
123 pub link_target: Option<String>,
131}
132
133pub fn walk_dir(
145 root: &Path,
146 skipped: &mut Vec<SkippedEntry>,
147 visit: &mut dyn FnMut(&WalkItem) -> Result<()>,
148) -> Result<()> {
149 let mut stack: Vec<(PathBuf, Vec<String>, usize)> = vec![(root.to_path_buf(), Vec::new(), 0)];
152
153 while let Some((dir, rel, depth)) = stack.pop() {
154 if depth > MAX_DEPTH {
155 skipped.push(SkippedEntry {
156 path: rel.join("/"),
157 reason: SkipReason::TooDeep,
158 });
159 continue;
160 }
161
162 let Ok(rd) = std::fs::read_dir(&dir) else {
163 skipped.push(SkippedEntry {
164 path: rel.join("/"),
165 reason: SkipReason::Unreadable,
166 });
167 continue;
168 };
169
170 let mut items: Vec<_> = rd.flatten().collect();
173 items.sort_by_key(std::fs::DirEntry::file_name);
174
175 let mut subdirs = Vec::new();
179
180 for item in items {
181 let path = item.path();
182 let name = item.file_name().to_string_lossy().into_owned();
183 let mut comps = rel.clone();
184 comps.push(name);
185
186 let Ok(md) = path.symlink_metadata() else {
190 skipped.push(SkippedEntry {
191 path: comps.join("/"),
192 reason: SkipReason::Unreadable,
193 });
194 continue;
195 };
196
197 let meta = meta_from_fs(&md);
198
199 if md.file_type().is_symlink() {
200 match std::fs::read_link(&path) {
207 Ok(target) => {
208 visit(&WalkItem {
209 path,
210 comps,
211 is_dir: false,
212 meta,
213 link_target: Some(target.to_string_lossy().into_owned()),
214 })?;
215 }
216 Err(_) => skipped.push(SkippedEntry {
217 path: comps.join("/"),
218 reason: SkipReason::Unreadable,
219 }),
220 }
221 continue; }
222
223 if md.is_dir() {
224 visit(&WalkItem {
225 path: path.clone(),
226 comps: comps.clone(),
227 is_dir: true,
228 meta,
229 link_target: None,
230 })?;
231 subdirs.push((path, comps, depth.saturating_add(1)));
232 } else if md.is_file() {
233 visit(&WalkItem {
234 path,
235 comps,
236 is_dir: false,
237 meta,
238 link_target: None,
239 })?;
240 } else {
241 skipped.push(SkippedEntry {
243 path: comps.join("/"),
244 reason: SkipReason::NotRegular,
245 });
246 }
247 }
248
249 subdirs.reverse();
250 stack.extend(subdirs);
251 }
252 Ok(())
253}
254
255pub fn pack_folder(root: &Path, mut progress: Option<ProgressFn<'_>>) -> Result<PackedFolder> {
274 if !root.is_dir() {
275 return Err(Error::Io(std::io::Error::new(
276 std::io::ErrorKind::NotADirectory,
277 "pack_folder expects a directory",
278 )));
279 }
280
281 let root_name = root
282 .file_name()
283 .map(|s| s.to_string_lossy().into_owned())
284 .unwrap_or_else(|| String::from("folder"));
287
288 let mut builder = ContainerBuilder::new(root_name.clone());
289 let mut payload = Vec::new();
290 let mut skipped = Vec::new();
291 let mut file_count = 0usize;
292 let mut dir_count = 0usize;
293 let mut link_count = 0usize;
294 let mut unreadable: Vec<String> = Vec::new();
297
298 walk_dir(root, &mut skipped, &mut |item| {
299 if let Some(target) = &item.link_target {
303 builder.add_symlink(item.comps.clone(), target.clone(), item.meta.clone())?;
304 link_count = link_count.saturating_add(1);
305 } else if item.is_dir {
306 builder.add_dir(item.comps.clone(), item.meta.clone())?;
307 dir_count = dir_count.saturating_add(1);
308 } else {
309 let Ok(data) = std::fs::read(&item.path) else {
310 unreadable.push(item.comps.join("/"));
316 return Ok(());
317 };
318 let size = data.len() as u64;
319 let hash = crate::util::blake2b_256(&data);
320 builder.add_file(item.comps.clone(), size, Some(hash), item.meta.clone())?;
321 payload.extend_from_slice(&data);
322 file_count = file_count.saturating_add(1);
323 if let Some(cb) = progress.as_deref_mut() {
324 cb(&item.comps.join("/"), size);
325 }
326 }
327 Ok(())
328 })?;
329
330 let index = builder.finish()?;
331
332 skipped.extend(unreadable.into_iter().map(|path| SkippedEntry {
334 path,
335 reason: SkipReason::Unreadable,
336 }));
337
338 Ok(PackedFolder {
339 payload,
340 index,
341 root_name,
342 file_count,
343 dir_count,
344 link_count,
345 skipped,
346 })
347}
348
349#[derive(Debug)]
357pub struct PackPlan {
358 pub root_name: String,
360 items: Vec<PlanItem>,
361 pub file_count: usize,
363 pub dir_count: usize,
365 pub link_count: usize,
367 pub skipped: Vec<SkippedEntry>,
369 pub total_size: u64,
371}
372
373#[derive(Debug)]
374enum PlanItem {
375 Dir { comps: Vec<String>, meta: EntryMeta },
376 Link { comps: Vec<String>, target: String, meta: EntryMeta },
377 File { path: PathBuf, comps: Vec<String>, size: u64, meta: EntryMeta },
378}
379
380pub fn plan_folder(root: &Path) -> Result<PackPlan> {
389 let root_name = root
390 .file_name()
391 .map(|s| s.to_string_lossy().into_owned())
392 .unwrap_or_else(|| String::from("folder"));
393 let mut items = Vec::new();
394 let mut skipped = Vec::new();
395 let mut unreadable: Vec<String> = Vec::new();
396 let (mut files, mut dirs, mut links) = (0usize, 0usize, 0usize);
397 let mut total = 0u64;
398 walk_dir(root, &mut skipped, &mut |item| {
399 if let Some(target) = &item.link_target {
400 items.push(PlanItem::Link {
401 comps: item.comps.clone(),
402 target: target.clone(),
403 meta: item.meta.clone(),
404 });
405 links = links.saturating_add(1);
406 } else if item.is_dir {
407 items.push(PlanItem::Dir { comps: item.comps.clone(), meta: item.meta.clone() });
408 dirs = dirs.saturating_add(1);
409 } else {
410 let size = std::fs::File::open(&item.path).and_then(|f| f.metadata()).map(|m| m.len());
411 let Ok(size) = size else {
412 unreadable.push(item.comps.join("/"));
413 return Ok(());
414 };
415 items.push(PlanItem::File {
416 path: item.path.clone(),
417 comps: item.comps.clone(),
418 size,
419 meta: item.meta.clone(),
420 });
421 files = files.saturating_add(1);
422 total = total.saturating_add(size);
423 }
424 Ok(())
425 })?;
426 skipped.extend(unreadable.into_iter().map(|path| SkippedEntry {
427 path,
428 reason: SkipReason::Unreadable,
429 }));
430 Ok(PackPlan {
431 root_name,
432 items,
433 file_count: files,
434 dir_count: dirs,
435 link_count: links,
436 skipped,
437 total_size: total,
438 })
439}
440
441impl PackPlan {
442 fn index_with(&self, hashes: Option<&[[u8; 32]]>) -> Result<ContainerIndex> {
443 let mut builder = ContainerBuilder::new(self.root_name.clone());
444 let mut fi = 0usize;
445 for it in &self.items {
446 match it {
447 PlanItem::Dir { comps, meta } => builder.add_dir(comps.clone(), meta.clone())?,
448 PlanItem::Link { comps, target, meta } => {
449 builder.add_symlink(comps.clone(), target.clone(), meta.clone())?;
450 }
451 PlanItem::File { comps, size, meta, .. } => {
452 let hash = match hashes {
453 Some(h) => *h.get(fi).ok_or(Error::MalformedHeader {
454 reason: "pack plan hash count mismatch",
455 })?,
456 None => [0u8; 32],
457 };
458 builder.add_file(comps.clone(), *size, Some(hash), meta.clone())?;
459 fi = fi.saturating_add(1);
460 }
461 }
462 }
463 builder.finish()
464 }
465
466 pub fn placeholder_index(&self) -> Result<Vec<u8>> {
472 Ok(self.index_with(None)?.encode())
473 }
474
475 pub fn finish(&self, hashes: &[[u8; 32]]) -> Result<ContainerIndex> {
481 if hashes.len() != self.file_count {
482 return Err(Error::MalformedHeader { reason: "pack plan hash count mismatch" });
483 }
484 self.index_with(Some(hashes))
485 }
486
487 #[must_use]
489 pub fn reader(&self) -> PackReader {
490 let files = self
491 .items
492 .iter()
493 .filter_map(|it| match it {
494 PlanItem::File { path, size, .. } => Some((path.clone(), *size)),
495 _ => None,
496 })
497 .collect();
498 PackReader {
499 files,
500 next: 0,
501 cur: None,
502 hashes: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
503 }
504 }
505}
506
507type CurrentFile = (std::io::Take<std::fs::File>, u64, blake2::Blake2b<blake2::digest::consts::U32>);
509
510pub struct PackReader {
515 files: Vec<(PathBuf, u64)>,
516 next: usize,
517 cur: Option<CurrentFile>,
518 hashes: std::rc::Rc<std::cell::RefCell<Vec<[u8; 32]>>>,
519}
520
521impl std::fmt::Debug for PackReader {
522 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
523 f.debug_struct("PackReader")
524 .field("files", &self.files.len())
525 .field("next", &self.next)
526 .finish_non_exhaustive()
527 }
528}
529
530impl PackReader {
531 #[must_use]
534 pub fn hashes(&self) -> std::rc::Rc<std::cell::RefCell<Vec<[u8; 32]>>> {
535 std::rc::Rc::clone(&self.hashes)
536 }
537}
538
539impl std::io::Read for PackReader {
540 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
541 use blake2::Digest;
542 loop {
543 if self.cur.is_none() {
544 let Some((path, size)) = self.files.get(self.next) else {
545 return Ok(0);
546 };
547 let f = std::fs::File::open(path)?;
548 let size = *size;
549 self.cur = Some((std::io::Read::take(f, size), size, blake2::Blake2b::new()));
550 self.next = self.next.saturating_add(1);
551 }
552 let Some((take, remaining, hasher)) = self.cur.as_mut() else {
553 return Ok(0);
554 };
555 let n = take.read(buf)?;
556 if n > 0 {
557 hasher.update(buf.get(..n).unwrap_or(&[]));
558 *remaining = remaining.saturating_sub(n as u64);
559 return Ok(n);
560 }
561 if *remaining != 0 {
563 return Err(std::io::Error::new(
564 std::io::ErrorKind::UnexpectedEof,
565 "file shrank while packing",
566 ));
567 }
568 if let Some((take, _, hasher)) = self.cur.take() {
569 let mut f = take.into_inner();
570 let mut probe = [0u8; 1];
571 if f.read(&mut probe)? != 0 {
572 return Err(std::io::Error::new(
573 std::io::ErrorKind::InvalidData,
574 "file grew while packing",
575 ));
576 }
577 self.hashes.borrow_mut().push(hasher.finalize().into());
578 }
579 }
580 }
581}
582
583fn meta_from_fs(md: &std::fs::Metadata) -> EntryMeta {
585 let mut m = EntryMeta::default();
586 if let Ok(t) = md.modified() {
587 m.mtime_ns = system_time_to_ns(t);
588 }
589 if let Ok(t) = md.created() {
590 m.btime_ns = system_time_to_ns(t);
591 }
592 #[cfg(unix)]
593 {
594 use std::os::unix::fs::MetadataExt;
595 m.mode = Some(md.mode());
596 m.uid = Some(md.uid());
597 m.gid = Some(md.gid());
598 }
599 m
600}
601
602fn system_time_to_ns(t: std::time::SystemTime) -> Option<i128> {
607 t.duration_since(std::time::UNIX_EPOCH)
608 .ok()
609 .and_then(|d| i128::try_from(d.as_nanos()).ok())
610}
611
612#[cfg(test)]
613mod tests {
614 use super::*;
615
616 fn make_tree(tag: &str) -> PathBuf {
618 let root = std::env::temp_dir().join(format!("omy-pack-{tag}"));
619 let _ = std::fs::remove_dir_all(&root);
620 std::fs::create_dir_all(root.join("sub/deeper")).unwrap();
621 std::fs::write(root.join("a.txt"), b"aaa").unwrap();
622 std::fs::write(root.join("b.txt"), b"bbbb").unwrap();
623 std::fs::write(root.join("sub/c.txt"), b"cc").unwrap();
624 std::fs::write(root.join("sub/deeper/d.txt"), b"d").unwrap();
625 root
626 }
627
628 #[test]
629 fn packs_all_files_and_dirs() {
630 let root = make_tree("basic");
631 let p = pack_folder(&root, None).unwrap();
632
633 assert_eq!(p.file_count, 4, "四个文件都要打进去");
634 assert_eq!(p.dir_count, 2, "sub 与 sub/deeper");
635 assert_eq!(p.payload.len(), 3 + 4 + 2 + 1, "载荷是所有文件内容拼接");
636 assert_eq!(p.root_name, "omy-pack-basic");
637 assert!(p.skipped.is_empty(), "正常目录不该有跳过项");
638
639 let _ = std::fs::remove_dir_all(&root);
640 }
641
642 #[test]
643 fn payload_ranges_match_index() {
644 let root = make_tree("ranges");
648 let p = pack_folder(&root, None).unwrap();
649
650 for e in &p.index.entries {
651 let Some((start, len)) = e.range() else {
652 continue; };
654 let s = usize::try_from(start).unwrap();
655 let end = s + usize::try_from(len).unwrap();
656 let slice = p.payload.get(s..end).expect("区间必须落在载荷内");
657 assert_eq!(slice.len() as u64, len);
658
659 let disk = std::fs::read(root.join(e.path.join("/"))).unwrap();
661 assert_eq!(slice, disk.as_slice(), "{} 的内容对不上", e.display_path());
662 }
663
664 let _ = std::fs::remove_dir_all(&root);
665 }
666
667 #[test]
668 fn ordering_is_byte_order_not_filesystem_order() {
669 let root = std::env::temp_dir().join("omy-pack-order");
681 let _ = std::fs::remove_dir_all(&root);
682 std::fs::create_dir_all(&root).unwrap();
683 std::fs::write(root.join("a.txt"), b"aaa").unwrap();
684 std::fs::write(root.join("Z.txt"), b"z").unwrap();
685
686 let p = pack_folder(&root, None).unwrap();
687 let names: Vec<String> = p
688 .index
689 .entries
690 .iter()
691 .filter(|e| e.range().is_some())
692 .map(|e| e.display_path())
693 .collect();
694 assert_eq!(
695 names,
696 vec!["Z.txt", "a.txt"],
697 "必须按字节序排列(大写 Z 在小写 a 之前),而不是沿用文件系统顺序"
698 );
699 assert_eq!(p.payload, b"zaaa", "载荷拼接顺序要与索引一致");
701
702 let _ = std::fs::remove_dir_all(&root);
703 }
704
705 #[test]
706 fn ordering_is_stable() {
707 let root = make_tree("stable");
710 let a = pack_folder(&root, None).unwrap();
711 let b = pack_folder(&root, None).unwrap();
712 assert_eq!(a.payload, b.payload, "两次打包的载荷必须一致");
713 assert_eq!(a.index.encode(), b.index.encode(), "索引也必须一致");
714 let _ = std::fs::remove_dir_all(&root);
715 }
716
717 #[test]
718 fn empty_dir_is_packed_without_files() {
719 let root = std::env::temp_dir().join("omy-pack-empty");
720 let _ = std::fs::remove_dir_all(&root);
721 std::fs::create_dir_all(root.join("nothing")).unwrap();
722
723 let p = pack_folder(&root, None).unwrap();
724 assert_eq!(p.file_count, 0);
725 assert_eq!(p.dir_count, 1, "空目录本身也要记进索引");
726 assert!(p.payload.is_empty());
727
728 let _ = std::fs::remove_dir_all(&root);
729 }
730
731 #[test]
732 fn rejects_non_directory() {
733 let f = std::env::temp_dir().join("omy-pack-not-a-dir.txt");
734 std::fs::write(&f, b"x").unwrap();
735 assert!(pack_folder(&f, None).is_err(), "对文件调用必须报错");
736 let _ = std::fs::remove_file(&f);
737 }
738
739 #[test]
740 fn progress_reports_every_file() {
741 let root = make_tree("progress");
742 let mut seen = Vec::new();
743 let mut cb = |name: &str, size: u64| seen.push((name.to_owned(), size));
744 let p = pack_folder(&root, Some(&mut cb)).unwrap();
745
746 assert_eq!(seen.len(), p.file_count, "每个文件都要回调一次");
747 let total: u64 = seen.iter().map(|(_, s)| s).sum();
748 assert_eq!(total as usize, p.payload.len(), "回调报的大小之和要等于载荷");
749
750 let _ = std::fs::remove_dir_all(&root);
751 }
752
753 #[cfg(unix)]
754 #[test]
755 fn symlinks_are_stored_not_followed() {
756 let root = std::env::temp_dir().join("omy-pack-symlink");
761 let _ = std::fs::remove_dir_all(&root);
762 std::fs::create_dir_all(&root).unwrap();
763 std::fs::write(root.join("real.txt"), b"real").unwrap();
764 std::os::unix::fs::symlink("real.txt", root.join("link.txt")).unwrap();
765
766 let p = pack_folder(&root, None).unwrap();
767 assert_eq!(p.file_count, 1, "链接不算文件,只有 real.txt 是");
768 assert_eq!(p.link_count, 1, "链接要计数");
769 assert!(p.skipped.is_empty(), "不再跳过,实际 {:?}", p.skipped);
770
771 assert_eq!(p.payload, b"real", "链接不该往载荷里加内容");
774
775 let link = p
776 .index
777 .entries
778 .iter()
779 .find(|e| e.path == ["link.txt"])
780 .expect("索引里要有链接条目");
781 assert_eq!(link.kind, crate::container::EntryKind::Symlink);
782 assert_eq!(
783 link.target.as_deref(),
784 Some("real.txt"),
785 "目标要原样存下来,否则还原出的链接指向错误位置"
786 );
787 assert_eq!(link.size, 0, "链接不占载荷区间");
788
789 let _ = std::fs::remove_dir_all(&root);
790 }
791
792 #[cfg(windows)]
793 #[test]
794 fn directory_junctions_are_stored_not_followed() {
795 let root = std::env::temp_dir().join("omy-pack-junction");
803 let _ = std::fs::remove_dir_all(&root);
804 std::fs::create_dir_all(root.join("real")).unwrap();
805 std::fs::write(root.join("real/inside.txt"), b"content").unwrap();
806
807 let link = root.join("link");
808 let st = std::process::Command::new("cmd")
809 .args(["/c", "mklink", "/J"])
810 .arg(&link)
811 .arg(root.join("real"))
812 .output();
813 let Ok(o) = st else { return };
815 if !o.status.success() || !link.exists() {
816 return;
817 }
818
819 let p = pack_folder(&root, None).unwrap();
820 assert_eq!(
821 p.file_count, 1,
822 "只有 real/inside.txt 该被打包;联接被跟随的话会变成 2"
823 );
824 assert_eq!(p.link_count, 1, "联接要作为链接存下来");
825 assert_eq!(
826 p.payload, b"content",
827 "载荷里只该有一份内容;被跟随会变成两份"
828 );
829
830 let e = p
831 .index
832 .entries
833 .iter()
834 .find(|e| e.path == ["link"])
835 .expect("索引里要有联接条目");
836 assert_eq!(e.kind, crate::container::EntryKind::Symlink);
837 assert!(
838 e.target.as_deref().is_some_and(|t| !t.is_empty()),
839 "联接的目标不能是空串,否则还原时无从下手"
840 );
841
842 let _ = std::fs::remove_dir_all(&root);
843 }
844
845 #[test]
846 fn skip_reason_codes_are_stable() {
847 assert_eq!(SkipReason::Symlink.code(), "symlink");
849 assert_eq!(SkipReason::NotRegular.code(), "not_regular");
850 assert_eq!(SkipReason::Unreadable.code(), "unreadable");
851 assert_eq!(SkipReason::TooDeep.code(), "too_deep");
852 }
853}