Skip to main content

omy_core/
unpack.rs

1//! 把容器载荷展开到磁盘。
2//!
3//! # 为什么这一层在 core
4//!
5//! [`crate::pack`] 的模块文档写过同一件事:CLI 先实现了遍历逻辑,GUI 要做
6//! 文件夹加密时面临「复制一份还是提上来」,复制的代价不是多写几行,而是
7//! **两份实现迟早分歧**。
8//!
9//! 解包这边的分歧代价更高,因为它包含两类安全相关的判断:
10//!
11//! - **路径逃逸防护**(`..`、绝对路径、盘符)。复制一份意味着以后修补一个
12//!   逃逸漏洞时,另一个入口仍然可被利用,而且没人会想起来它。
13//! - **文件名兼容处理**(Windows 非法字符与保留名)。两份实现给出不同的
14//!   替换结果,同一个容器在 CLI 和 GUI 解出来会得到不同的文件名。
15//!
16//! # 元数据还原为什么在这里面调用,而不是交给调用方
17//!
18//! [`crate::restore::restore_metadata`] 有一个顺序约束:必须在**全部条目
19//! 落盘之后**才能调用(新建目录项会更新父目录的 mtime)。把它放在本函数
20//! 内部的末尾,这个约束就由结构保证,任何调用方都不可能弄错;交给调用方
21//! 则意味着每个入口都要记住这件事,而忘记的后果是「文件时间对了、目录
22//! 时间还是现在」——只在非空目录上出现,很容易被当成偶发问题。
23
24use crate::container::{ContainerIndex, EntryKind};
25use crate::error::{Error, Result};
26use crate::restore::RestoreReport;
27use std::path::{Path, PathBuf};
28
29/// 未能还原的条目及原因。
30#[derive(Debug, Clone)]
31pub struct SkippedEntry {
32    /// 条目路径(相对容器根,用 `/` 连接)。
33    pub path: String,
34    /// 稳定代号,同时是 JSON 字段值与翻译键后缀。
35    pub reason: &'static str,
36}
37
38/// 解包结果。
39#[derive(Debug)]
40pub struct ExtractReport {
41    /// 容器根目录在磁盘上的实际位置。
42    pub root: PathBuf,
43    /// 写出的文件数。
44    pub files: usize,
45    /// 创建的目录数。
46    pub dirs: usize,
47    /// 成功创建的符号链接数。
48    pub links: usize,
49    /// 因平台限制被改名的条目(原路径)。
50    ///
51    /// 必须报告:文件名被悄悄改掉的话,用户按原名找不到文件,
52    /// 只会以为解包漏了东西(决策 N3,文档 05 §4.3)。
53    pub adjusted: Vec<String>,
54    /// 未能还原的条目。
55    pub skipped: Vec<SkippedEntry>,
56    /// 元数据还原报告。
57    pub metadata: RestoreReport,
58}
59
60/// 把容器载荷展开到 `target` 目录下。
61///
62/// 会在 `target` 里建一层以容器根名命名的目录,条目落在其中——与 CLI
63/// 既有行为一致。容器根名来自索引,不是密文文件名。
64///
65/// # Errors
66///
67/// 目录创建失败、路径逃逸、载荷区间越界或逐文件哈希不匹配时返回错误。
68/// 单个符号链接无法还原**不算错误**,记进 `skipped` 继续——为一个链接
69/// 让整个解包失败,代价与收益不成比例。
70pub 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            // 归到 MalformedHeader 而不是载荷类错误:容器索引在头部
81            // TLV 里,「区间超出载荷」说明索引本身不对
82            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
90/// 每次从载荷里取多少明文。足够大以摊薄解密开销,又小到与文件大小无关。
91pub const STREAM_WINDOW: u64 = 4 * 1024 * 1024;
92
93/// 与 [`extract_container`] 相同,但按需读取载荷区间,而不是要求整份明文在内存里。
94///
95/// `read(offset, len)` 返回容器明文载荷中 `[offset, offset + len)` 的字节,
96/// 调用方通常用 [`crate::source::read_source_range`] 直接从密文按块解密。
97/// 每个条目按 [`STREAM_WINDOW`] 分段写盘,内存占用与容器大小无关——原来
98/// 要先把整个容器解密进内存,几十 GB 的文件夹在手机上根本还原不了。
99///
100/// # Errors
101///
102/// 与 [`extract_container`] 一致;`read` 的错误原样返回。
103pub 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    // 平台不允许建链接的条目数。累计计数而不是逐条记录:报告要说
115    // 「3 个链接因平台限制未还原」,不是重复三行同样的话。
116    let mut unsupported_links = 0usize;
117    // 建链接时的真实失败(区别于平台不支持),带路径和原因。
118    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    // 先建所有目录,含空目录——空目录必须显式还原,否则会丢失
123    // (文档 05 §2.2 为此在索引里显式记录 dir 条目)
124    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                    // 父目录可能因为改名而与上面建的那个不同名,补一次
144                    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    // 链接单独走一遍,必须在所有文件和目录都落盘之后。
154    //
155    // 原因是目标类型:Windows 分 symlink_file 与 symlink_dir 两个不同的
156    // 系统调用,选错会建出类型不符的链接。判断依据是目标当前是不是目录,
157    // 而容器内的链接完全可能指向索引里排在它后面的条目——放在同一个循环里
158    // 就会在目标还没落盘时去判断,把指向目录的链接建成文件型。
159    //
160    // 顺带也让「链接指向同容器内的文件」这种常见情况不再是悬空链接。
161    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        // 目标缺失的链接条目在 container 层就被拒了(见
174        // `symlink_without_target_rejected`),这里再兜一次:
175        // 拿不到目标就没法建,如实报告而不是建一个空链接。
176        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        // 指向容器之外的链接不还原。
185        //
186        // 这是一个真实的逃逸口:`safe_join` 只管住了链接**自身**要落在哪,
187        // 完全没看它**指向**哪。一个恶意容器可以放 `link -> C:\Windows` 或
188        // `link -> ../../../etc/passwd`,解开后用户在文件管理器里点进去,
189        // 就在容器外面操作了——而他以为自己还在解出来的目录里。
190        //
191        // 拒绝而不是改写成安全路径:改写会得到一个指向别处的链接,看着正常
192        // 却指错地方,比不建更难发现。如实报告,让用户知道容器里有这么一项。
193        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                // 平台不允许建链接(Windows 未开开发者模式时必然如此)。
205                // 归 unsupported 而不是 failure:目标仍在加密文件里,换
206                // 环境能还原,报成失败会让人反复重试一件不可能成功的事。
207                unsupported_links = unsupported_links.saturating_add(1);
208            }
209            Err(err) => {
210                // 其他失败是这次操作的问题(同名文件已占位、磁盘问题),
211                // 重试可能就好了,要如实报错。
212                link_failures.push((e.display_path(), err.to_string()));
213            }
214        }
215    }
216
217    // 必须在这里——所有条目都已落盘。见模块文档
218    let mut metadata = crate::restore::restore_metadata(&root, idx);
219
220    // 链接的结果并进同一份报告,而不是在 ExtractReport 上另开两个字段:
221    // 对用户来说「链接没能还原」和「权限位没能还原」是同一类信息——这次
222    // 拿不到但值还在加密文件里,展示与翻译都该走同一条路径。
223    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
242/// 把一个容器条目分段写到 `path`,边写边算哈希。
243///
244/// 逐文件哈希校验:容器里某个文件坏了要能精确指出是哪个。整体 AEAD 已经
245/// 保证了完整性,但它只能说「这个容器坏了」。流式写入时哈希要到最后才知道,
246/// 所以不匹配时删掉已写出的文件再报错,不留半成品。
247fn 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/// 创建一个符号链接,`target` 是链接内容,`link` 是要创建的路径。
290///
291/// # 为什么 Windows 要分两个调用
292///
293/// Windows 的 `CreateSymbolicLinkW` 要在创建时就声明目标是文件还是目录
294/// (Unix 只有一个 `symlink`,不关心目标类型)。声明错了会得到一个类型
295/// 不符的链接:资源管理器里点开报错,`std::fs::metadata` 跟随后也拿不到
296/// 正确的类型——是个能建成功但不可用的产物,比直接失败更难查。
297///
298/// 判断依据是**目标当前是不是目录**。目标不存在(悬空链接)时只能猜,
299/// 这里按文件处理:文件型是更常见的情况,而且猜错的代价对悬空链接来说
300/// 暂时不显现(等目标出现后才可能暴露)。
301///
302/// # 实测结论(开发者模式下)
303///
304/// 这段判断**不是**多余的保险,类型选错的后果已经实测过:把两个调用对调
305/// 之后,链接照样建得出来(`is_symlink` 仍为 true),但 `metadata` 跟随
306/// 时 `dlink` 报 permission denied、`flink` 报 not a directory,读文件
307/// 直接「拒绝访问」——正是那种「能创建但不可用」的产物。
308///
309/// 另一个实测得到的细节:Windows 沿链接**路径**穿越时并不校验这个类型
310/// 标记,所以即便类型选错,「透过 dlink 读 sub/inside.txt」照样成功。
311/// 判断链接是否健康只能对链接自身取 `metadata`,不能靠能不能穿过去读文件
312/// (`link_to_dir_inside_container_gets_right_type` 的注释里记了这一点,
313/// 因为拿后者当判据的测试是抓不到缺陷的)。
314///
315/// 悬空链接在有特权时也能正常创建,所以不需要为它推迟或跳过。
316///
317/// # 分隔符必须换成反斜杠
318///
319/// 容器里的路径一律用 `/` 分隔(跨平台的容器格式只能这样),但 Windows
320/// **不接受目标里的正斜杠**。这个错误的形态很坏:链接照样**创建成功**,
321/// `is_symlink` 为 true、`read_link` 也读得回来,只有跟随时才报
322/// `winerror 123`(`InvalidFilename`)。也就是说不转换的话,`links` 会
323/// 报成功,磁盘上却是一个点开就报错的坏链接。
324///
325/// 受影响的是任何目标带子目录的链接(`../lib/libfoo.so` 这类很常见),
326/// 尤其是在 Unix 上打包、拿到 Windows 解开的容器。
327#[cfg(windows)]
328fn create_symlink(target: &str, link: &Path) -> std::io::Result<()> {
329    // 见上:正斜杠会让链接建得出来却跟随不了
330    let target = target.replace('/', "\\");
331
332    // 相对目标要相对链接所在目录解析,不是相对进程当前目录——
333    // 用当前目录判断会在链接不在 cwd 下时得到错误的类型判断。
334    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    // Unix 不区分目标类型,一个调用搞定
347    std::os::unix::fs::symlink(target, link)
348}
349
350/// 链接目标解析后是否仍落在 `root` 内。
351///
352/// `link` 是链接自身的落盘路径,`target` 是要写进链接的内容。
353///
354/// # 为什么不用 `canonicalize`
355///
356/// 目标此刻可能还不存在(悬空链接是合法的),`canonicalize` 会直接报错,
357/// 于是所有悬空链接都会被误判成逃逸。这里改用纯路径推演:不碰文件系统,
358/// 只按组件消解 `.` 与 `..`。
359///
360/// 顺带的好处是不受符号链接自身影响——`canonicalize` 会跟随路径上已存在的
361/// 链接,攻击者可以借此让「检查时」和「使用时」看到不同的结果。
362fn link_target_stays_inside(root: &Path, link: &Path, target: &str) -> bool {
363    let t = Path::new(target);
364
365    // 绝对路径(含 Windows 的盘符与 UNC)一律拒绝。就算它字面上落在 root
366    // 里,也是一条写死了本机位置的链接:换台机器解开必然指向不存在的地方,
367    // 还把原机器的目录结构泄进了容器
368    if t.is_absolute() || t.has_root() {
369        return false;
370    }
371
372    // 相对目标以链接**所在目录**为基准解析,不是以进程当前目录
373    let Some(base) = link.parent() else {
374        return false;
375    };
376
377    // 按组件消解。用 `pop` 处理 `..` 而不是留给系统:留着 `..` 的话
378    // `starts_with` 会把 `root/a/../../x` 判成在 root 内
379    let mut acc = base.to_path_buf();
380    for comp in t.components() {
381        match comp {
382            std::path::Component::ParentDir => {
383                // 已经退到 root 就不能再退。这里必须先判断再 pop:
384                // pop 到 root 之上再比较就已经晚了
385                if acc == root {
386                    return false;
387                }
388                if !acc.pop() {
389                    return false;
390                }
391            }
392            std::path::Component::CurDir => {}
393            // 绝对路径成分在上面已经挡掉,剩下的只可能是普通名字
394            other => acc.push(other.as_os_str()),
395        }
396    }
397
398    acc.starts_with(root)
399}
400
401/// 这个错误是不是「平台/权限不允许建符号链接」。
402///
403/// 决定报 unsupported 还是 failure,两者对用户的含义完全不同(见
404/// [`crate::restore::RestoreReport`] 的文档)。
405///
406/// Windows 上不能只看 [`std::io::ErrorKind`]:无特权时返回的是
407/// `ERROR_PRIVILEGE_NOT_HELD`(1314),而它并**不**映射成
408/// `ErrorKind::PermissionDenied`,只看 kind 会把这个必然失败的情况
409/// 报成普通失败,用户于是反复重试。实测本机(未开发者模式、未提权)
410/// 建任何链接都得到这个错误。
411#[cfg(windows)]
412fn is_privilege_error(e: &std::io::Error) -> bool {
413    /// `ERROR_PRIVILEGE_NOT_HELD`:调用方没有 `SeCreateSymbolicLinkPrivilege`。
414    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    // Unix 上建链接不需要特权,权限不足是真的目录权限问题。
423    // 仍归 unsupported:换个可写位置就能还原,和 Windows 一致。
424    e.kind() == std::io::ErrorKind::PermissionDenied
425}
426
427/// 安全拼接路径,拒绝任何逃出 `root` 的结果,并报告是否改过名。
428///
429/// # Errors
430///
431/// 路径组件非法或拼接结果逃出 `root` 时返回错误。
432///
433/// # 为什么校验两遍
434///
435/// `ContainerIndex::parse` 已经校验过组件,这里再查一次是纵深防御:
436/// 解析与落盘之间隔着好几层调用,将来任何一处改动引入回归,都会在
437/// 真正写盘之前被这一道拦住。
438pub 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/// 把文件名调整为当前平台可用形式。
458///
459/// Windows 禁止 `\ / : * ? " < > |`、控制字符,以及一批设备保留名。
460/// **绝不静默丢弃**:调用方会把改过名的项报告给用户(决策 N3)。
461#[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        // 结尾的点与空格在 Windows 上会被吞掉,留着会导致
482        // 「写进去的名字和读出来的不一样」
483        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        // Unix 只需处理 / 与 NUL
498        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        // 空目录必须还原。不显式建的话它会静默消失,
547        // 而用户是有意留着它的
548        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        // 顺序约束由结构保证:调用方不需要(也不能)自己记得在落盘之后
556        // 再调 restore_metadata。这条锁住「解包即还原元数据」。
557        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        // 查磁盘上的真实值,不只看计数——计数是自己报的
576        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        // 整体 AEAD 只能说「这个容器坏了」,逐文件哈希才能指出是哪个。
595        // 不校验的话坏内容会静默落盘
596        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        // 索引声称的区间超出实际载荷。不检查就是越界读
611        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        // 这条测试在两种环境下都必须有意义,所以按实际能力分支断言:
623        // 能建链接(Unix,或开了开发者模式的 Windows)就要求真的建出来;
624        // 不能建(本机实测:未提权的 Windows 必然失败)就要求归到
625        // unsupported 而不是 failures。
626        //
627        // 无论哪种,都不允许「既没建出来又没报告」——那会让用户以为容器
628        // 里本来就没有这个链接。
629        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            // 建成功了:查磁盘上的真实类型,不只看计数——计数是自己报的。
654            // 用 symlink_metadata,metadata 会跟随链接看到目标的类型
655            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        // 不管链接建没建成,同容器里的普通文件都要正常落盘——
683        // 链接失败不该拖累其他条目
684        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        // 锁住两件事:链接类型选对了,以及「链接在所有文件目录落盘之后
697        // 才创建」这个顺序约束。
698        //
699        // 索引里链接排在它的目标**之前**(add_symlink 先调用)。如果实现
700        // 把链接和文件放在同一个循环里处理,创建链接时目标还不存在,
701        // Windows 上 `is_dir()` 对不存在的路径返回 false,于是指向目录的
702        // 链接被建成文件型——能建成功但不可用。
703        //
704        // # 判据为什么是「对链接自身取 metadata」
705        //
706        // 变异测试实测过(把 symlink_dir / symlink_file 对调):
707        //
708        // - `metadata(dlink)` 报 permission denied、`metadata(flink)` 报
709        //   not a directory——**能抓到**。
710        // - 而「透过 dlink 读 sub/inside.txt」在变异后**照样成功**:Windows
711        //   沿链接路径穿越时并不校验这个类型标记。拿它当判据等于没测。
712        //
713        // 所以查的是链接自身跟随后的类型,不是能不能穿过去读到文件。
714        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        // 建不出链接的环境(未开开发者模式的 Windows)跳过后半段——那里由
737        // symlinks_are_restored_or_reported_never_silently_dropped 覆盖,
738        // 本条测的是「建出来之后类型对不对」
739        if rep.links == 0 {
740            let _ = std::fs::remove_dir_all(&root);
741            return;
742        }
743        assert_eq!(rep.links, 2, "两个链接都该建出来");
744
745        // 目录链接跟随后必须是目录。类型选错时这里直接报错而不是返回 false
746        let dmd = std::fs::metadata(rep.root.join("dlink"))
747            .expect("目录链接跟随失败,说明类型选错了");
748        assert!(dmd.is_dir(), "dlink 跟随后该是目录");
749
750        // 文件链接跟随后必须是文件,且内容读得出来
751        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        // 正常路径下 container 层就拒了没目标的链接条目,所以这里要手工
766        // 构造。仍要测:损坏或手工拼的容器不该让我们建出一个空链接,
767        // 也不该静默忽略
768        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        // 绕过 builder 校验,模拟坏容器
774        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        // 纯函数级别地把逃逸判断的边界钉死。`safe_join` 只管链接自身落在
794        // 哪,管不到它指向哪,所以这道检查是唯一的防线。
795        let root = Path::new("/vault/root");
796        let link = root.join("sub").join("l");
797
798        // 该放行的:容器内的相对目标
799        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        // 该拒绝的:爬出去、绝对路径、盘符、UNC
807        for bad in [
808            "../../outside.txt",
809            "../../../../../../etc/passwd",
810            // 先进子目录再爬出去,抵消掉的层数刚好越界。
811            // 只做字符串前缀匹配的实现会漏掉这个
812            "d/../../../outside.txt",
813        ] {
814            assert!(
815                !link_target_stays_inside(root, &link, bad),
816                "{bad} 爬出了容器,必须拒绝"
817            );
818        }
819
820        // 绝对路径一律拒绝,即便字面上指回容器内——它写死了本机位置
821        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        // Windows 特有的几种绝对形式。`Path::is_absolute` 对 `\foo` 这种
833        // 「有根但无盘符」的路径返回 false,所以实现里还查了 has_root——
834        // 漏掉它就会放行一条指向当前盘根目录的链接
835        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        // 端到端确认:恶意容器里指向外面的链接不落盘,且如实报告。
853        // 静默忽略会让用户以为容器里本来就没这项
854        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        // 路径逃逸是本模块最重要的一道防线:容器索引来自文件,
901        // 而文件内容是不可信输入
902        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        // 名字被悄悄改掉的话,用户按原名找不到文件,只会以为解包漏了东西
915        let root = tmp("adjust");
916        let mut b = ContainerBuilder::new("f");
917        // 问号在 Windows 上非法,但不像盘符——**不能**用 `a:b.txt` 试:
918        // validate_component 会先把它按「路径组件形如盘符」拒掉(那道
919        // 检查是对的,`C:` 这类组件在 Windows 上会被当成盘符,是真实的
920        // 逃逸手段),于是根本走不到 sanitize_filename 这一步
921        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}