Skip to main content

omy_core/
pack.rs

1//! 把一个目录打包成容器载荷。
2//!
3//! # 为什么这一层在 core 而不在各前端里
4//!
5//! CLI 早先自己实现了这套遍历逻辑,GUI 要做文件夹加密时面临选择:
6//! 复制一份,还是提上来共享。复制的代价不是多写几行,而是**两份实现
7//! 迟早分歧**——比如哪天给 CLI 修了「跳过符号链接」的处理,GUI 那份
8//! 还在按老样子打包,用户在两个入口得到不同的容器。
9//!
10//! core 里已经有 [`crate::fsatomic`] 在做文件系统操作,所以放这里
11//! 与既有边界一致。
12//!
13//! # 为什么不用 walkdir
14//!
15//! core 的依赖目前只有密码学与格式相关的几个 crate,保持这份克制是
16//! 有价值的:它让 core 能被任意项目复用,也让审计面更小。
17//! 目录遍历用 `std::fs` 手写一个显式栈就够了,不值得为此引入新依赖。
18//!
19//! 手写还有个额外好处:**递归深度可控**。`walkdir` 默认无限深,
20//! 遇到深层嵌套目录时 CLI 那份实现是靠运气不炸;这里显式设了上限。
21//!
22//! # 顺序必须稳定
23//!
24//! 条目按路径排序后写入。这不是美观问题——索引里记的是每个文件在
25//! 载荷中的**区间**,遍历顺序变了,同一个目录两次打包会得到不同的
26//! 布局。稳定顺序让「同样的输入产出同样的容器」成立,便于校验。
27
28use crate::container::{ContainerBuilder, ContainerIndex, EntryMeta};
29use crate::error::{Error, Result};
30use std::path::{Path, PathBuf};
31
32/// 目录递归深度上限。
33///
34/// 超过就拒绝,而不是一路走到栈溢出。真实目录很少超过 20 层,
35/// 到 64 层基本可以断定是符号链接环或者恶意构造。
36pub const MAX_DEPTH: usize = 64;
37
38/// 打包过程中跳过的条目。
39///
40/// 必须如实报告给用户:静默丢弃会让人以为整个目录都进去了,
41/// 等到解密时才发现少东西,那时原目录可能已经删了。
42#[derive(Debug, Clone)]
43pub struct SkippedEntry {
44    /// 被跳过的路径(相对根目录)。
45    pub path: String,
46    /// 跳过原因的稳定标识,供上层翻译。
47    pub reason: SkipReason,
48}
49
50/// 跳过原因。
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum SkipReason {
53    /// 符号链接。跨平台还原语义不一致,首期不支持(文档 05 §4.2)。
54    Symlink,
55    /// 既不是普通文件也不是目录(设备文件、FIFO、socket 等)。
56    NotRegular,
57    /// 读取失败:权限不足、文件正被独占打开、或读到一半被删了。
58    Unreadable,
59    /// 超过递归深度上限。
60    TooDeep,
61}
62
63impl SkipReason {
64    /// 稳定的英文标识,用作翻译键与 JSON 字段。
65    #[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/// 打包结果。
77#[derive(Debug)]
78pub struct PackedFolder {
79    /// 拼接后的明文载荷。
80    pub payload: Vec<u8>,
81    /// 容器索引。
82    pub index: ContainerIndex,
83    /// 根目录名,用作加密后的显示名。
84    pub root_name: String,
85    /// 文件数。
86    pub file_count: usize,
87    /// 目录数。
88    pub dir_count: usize,
89    /// 符号链接数。
90    ///
91    /// 单独计数而不是并进 `file_count`:还原时链接可能因平台限制建不出来
92    /// (Windows 无特权就必然失败),报告要能说清「10 个文件都好了,2 个
93    /// 链接没能还原」。混在一起就只能说「12 项里有 2 项有问题」。
94    pub link_count: usize,
95    /// 被跳过的条目,需如实展示给用户。
96    pub skipped: Vec<SkippedEntry>,
97}
98
99/// 遍历时可选的进度回调。
100///
101/// 大目录打包要花不少时间,GUI 需要据此更新界面。
102/// 用 `&mut dyn FnMut` 而不是泛型参数:这个函数已经够长,
103/// 再加个类型参数会让调用方的错误信息变得难读。
104pub type ProgressFn<'a> = &'a mut dyn FnMut(&str, u64);
105
106/// 遍历时交给回调的一个条目。
107///
108/// 抽出这个类型是为了让容器模式与树形模式**共用同一套遍历判定**:
109/// 按名字稳定排序、用 `symlink_metadata` 而不是 `metadata`、跳过符号链接
110/// 与非普通文件、深度上限。各写一份的话,哪天给容器模式修了「跳过 socket
111/// 文件」,树形模式还在按老样子处理,同一个目录用两种模式加密会得到不同的
112/// 内容集合。
113#[derive(Debug)]
114pub struct WalkItem {
115    /// 绝对路径。
116    pub path: PathBuf,
117    /// 相对根目录的路径组件。
118    pub comps: Vec<String>,
119    /// 是否是目录。
120    pub is_dir: bool,
121    /// 文件系统元数据,供调用方提取 mtime 等。
122    pub meta: EntryMeta,
123    /// 符号链接的目标;非链接时为 `None`。
124    ///
125    /// 由 `walk_dir` 交给调用方而不是自行跳过,是因为两种加密模式对链接
126    /// 的处理能力不同:容器模式有索引可以存目标,树形模式把每个条目落成
127    /// 独立 `.omy` 文件、没有地方记这个字段。让 `walk_dir` 只负责「如实
128    /// 报告这是个链接」,各模式自己决定存还是跳过——遍历判定规则仍然只有
129    /// 一处,符合本模块与 `tree` 共用 `walk_dir` 的约定。
130    pub link_target: Option<String>,
131}
132
133/// 遍历目录树,对每个条目调用 `visit`。
134///
135/// **父目录保证先于其子项被访问**——树形模式依赖这一点:子项要写进父目录
136/// 对应的密文目录,那个目录必须已经建好。
137///
138/// 跳过的条目记进 `skipped`,不中断遍历:一个打不开的文件不该让整个目录
139/// 加密失败。
140///
141/// # Errors
142///
143/// 只有 `visit` 自己返回错误时才会中断。
144pub fn walk_dir(
145    root: &Path,
146    skipped: &mut Vec<SkippedEntry>,
147    visit: &mut dyn FnMut(&WalkItem) -> Result<()>,
148) -> Result<()> {
149    // 显式栈而不是递归:递归在深目录上会爆栈,而爆栈是不可恢复的。
150    // 栈里存 (绝对路径, 相对组件, 深度)
151    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        // 先收集再排序:read_dir 的顺序由文件系统决定,
171        // 不排序的话同一目录两次打包会得到不同布局
172        let mut items: Vec<_> = rd.flatten().collect();
173        items.sort_by_key(std::fs::DirEntry::file_name);
174
175        // 目录压栈时要反着来。栈是后进先出,正序压入会让遍历
176        // 变成倒序——虽然索引里已排序,但 payload 的拼接顺序
177        // 会跟着变,同样破坏可复现性
178        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            // 用 symlink_metadata 而不是 metadata:后者会跟随链接,
187            // 于是一个指向目录的符号链接会被当成真目录递归进去,
188            // 环状链接直接把我们送进死循环
189            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                // 读链接目标。读不出来才跳过——能读出来就交给调用方,
201                // 由它按自己的存储能力决定存进索引还是记为跳过。
202                //
203                // 注意这里不跟随链接去看目标是文件还是目录:目标可能不
204                // 存在(悬空链接)或指向环,跟随就等于把 symlink_metadata
205                // 挡掉的那两个问题又放回来。类型判断留到还原时做。
206                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                // 设备文件、FIFO、socket 等
242                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
255/// 把 `root` 目录打包成容器载荷。
256///
257/// # 内存
258///
259/// 载荷是**整个目录的内容拼接**,会全部读进内存。这与 core 其他部分
260/// 按块处理的风格不一致,但容器格式本身要求先知道每个文件的区间才能
261/// 写索引,而区间要等前面所有文件都读完才确定。
262///
263/// 调用方应对大目录做预检——`ContainerIndex` 里有
264/// [`crate::container::RECOMMENDED_MAX_ENTRIES`] 可参考。
265///
266/// # Errors
267///
268/// - 根路径不是目录
269/// - 索引构建失败(路径组件非法等,由 `ContainerBuilder` 校验)
270///
271/// 单个文件读不出来**不算错误**,会记进 `skipped` 继续——
272/// 一个打不开的文件不该让整个目录加密失败。
273pub 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        // 根目录(如 `C:\`)没有 file_name。用固定名而不是空串:
285        // 空名会让解密时创建不出目录
286        .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    // 闭包里读不出来的文件单独收集:`skipped` 已被 walk_dir 可变借走,
295    // 闭包不能同时碰它。遍历结束后合并
296    let mut unreadable: Vec<String> = Vec::new();
297
298    walk_dir(root, &mut skipped, &mut |item| {
299        // 链接必须在读文件之前判断。否则 `std::fs::read` 会跟随链接,
300        // 把**目标的内容**当成链接自身的内容存进容器——还原出来就是一个
301        // 普通文件而不是链接,内容看着还对,属于静默的错误结果。
302        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                // 读不出来就跳过并记录,不让整个目录加密失败。
311                // 常见原因:文件正被别的程序独占打开
312                //
313                // 注意这里不能 push 到 skipped——它已被 walk_dir 借走。
314                // 交给下面的 unreadable 收集
315                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    // 合并闭包里收集的读失败项
333    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/// 流式打包的计划:遍历一遍目录,只取大小,不读内容。
350///
351/// [`pack_folder`] 把所有文件内容拼进一个内存载荷,几十 GB 的文件夹在手机上
352/// 根本加密不了。流式打包分三步:`plan_folder` 定下条目与偏移(索引长度从此
353/// 确定,只差逐文件哈希)→ [`PackPlan::reader`] 按同样的顺序把文件内容串成
354/// 一条 `Read` 流,边读边算哈希 → [`PackPlan::finish`] 填入哈希得到真索引。
355/// 配合 [`crate::file::encrypt_stream`] 的定稿回调使用。
356#[derive(Debug)]
357pub struct PackPlan {
358    /// 容器根名。
359    pub root_name: String,
360    items: Vec<PlanItem>,
361    /// 普通文件数。
362    pub file_count: usize,
363    /// 目录数。
364    pub dir_count: usize,
365    /// 符号链接数。
366    pub link_count: usize,
367    /// 跳过的条目。
368    pub skipped: Vec<SkippedEntry>,
369    /// 所有文件内容的总字节数(即容器载荷的明文长度)。
370    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
380/// 规划流式打包。遍历判定与 [`pack_folder`] 共用 [`walk_dir`]。
381///
382/// 打不开的文件(常见原因:被别的程序独占)在这一步就跳过并记录,与
383/// `pack_folder` 的行为一致。
384///
385/// # Errors
386///
387/// 遍历出错时返回。
388pub 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    /// 与最终索引等长的占位索引(哈希全零),用来给流式加密预留头部空间。
467    ///
468    /// # Errors
469    ///
470    /// 条目非法时返回。
471    pub fn placeholder_index(&self) -> Result<Vec<u8>> {
472        Ok(self.index_with(None)?.encode())
473    }
474
475    /// 填入逐文件哈希,得到真正的容器索引。`hashes` 来自 [`PackReader::hashes`]。
476    ///
477    /// # Errors
478    ///
479    /// 哈希数与文件数不符或条目非法时返回。
480    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    /// 按规划顺序把文件内容串成一条流。
488    #[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
507/// 当前正在读的文件:剩余字节数受限的句柄、还差多少字节、该文件的哈希器。
508type CurrentFile = (std::io::Take<std::fs::File>, u64, blake2::Blake2b<blake2::digest::consts::U32>);
509
510/// 把规划里的文件内容按顺序串成一条 `Read` 流,并逐文件计算哈希。
511///
512/// 每个文件读到的字节数必须与规划时的大小一致:文件在加密途中被改动时报错,
513/// 而不是让容器索引的偏移与载荷错位。
514pub 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    /// 已读完的文件的哈希。返回共享句柄:流被加密函数借走期间,定稿回调
532    /// 仍需要读到它。
533    #[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            // 当前文件读完:必须恰好读满规划时的大小
562            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
583/// 从文件系统元数据提取可移植的元数据。
584fn 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
602/// `SystemTime` 转 Unix 纳秒。
603///
604/// 1970 年之前的时间戳会得到 `Err`,此时返回 `None` 而不是负数或 0——
605/// 假装知道一个不知道的值,比承认不知道更糟。
606fn 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    /// 造一棵测试目录树,返回根路径。
617    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        // 这条守护容器格式的核心不变量:索引里记的区间必须真的
645        // 对应载荷里那段字节。错了的话解出来的文件内容会串位——
646        // 而且往往还是「能解开、内容是别的文件」这种最难查的形态
647        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; // 只有文件有区间
653            };
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            // 内容要与磁盘上的原文件一致
660            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        // 变异测试抓出来的缺口:`ordering_is_stable` 只比较两次打包的结果,
670        // 而**两次都会拿到同一个文件系统顺序**——把 sort 整行删掉,那条
671        // 断言依然通过。它证明的是「确定性」,不是「按字节序排」。
672        //
673        // 这里改为断言一个与文件系统顺序**不同**的具体顺序:NTFS 大小写
674        // 不敏感,返回 `a.txt, Z.txt`;而字节序里 `Z`(0x5A) < `a`(0x61),
675        // 应得到 `Z.txt, a.txt`。漏排序时这条立刻失败。
676        //
677        // 为什么必须按字节序:索引里记的是每个文件在载荷中的区间,跨平台
678        // 打开同一个容器时若排序规则不同,区间就对不上。ext4 按字节序、
679        // NTFS 按大小写不敏感——只有显式排序才能让两边一致。
680        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        // 载荷的拼接顺序也要跟着:Z 的内容在前
700        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        // 同一个目录打包两次必须得到完全相同的载荷。
708        // read_dir 的顺序由文件系统决定,不排序的话这条不成立
709        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        // 链接要被**存下来**(容器索引记得住目标),但绝不能被跟随:
757        // 跟随会导致环状链接死循环,以及把目标内容当成链接自身的内容
758        // 打包进去——后者最危险,因为解出来内容看着是对的,只是类型
759        // 从链接变成了普通文件,不比对类型根本发现不了
760        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        // 载荷里只有 real.txt 的内容。链接不占载荷——如果被跟随了,
772        // 这里会变成 8 字节("real" 出现两次)
773        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        // Windows 上的补位测试。上面那条是 `#[cfg(unix)]`,于是「改用
796        // metadata 会跟随链接」这个缺陷在 Windows 开发机上**压根没有
797        // 测试覆盖**——变异测试把这个盲区暴露了出来。
798        //
799        // 创建符号链接在 Windows 上需要特权(实测 WinError 1314),但
800        // **目录联接(junction)不需要**,而 Rust 同样把它报成 symlink。
801        // 于是不必提权就能覆盖这条路径。
802        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        // 建不出来就跳过:不同环境的策略可能不同,测试不该因此变红
814        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        // 这些字符串是翻译键与 JSON 字段,改了会破坏前端与脚本
848        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}