Skip to main content

omy_core/
restore.rs

1//! 把容器里保存的元数据还原到落盘的文件上。
2//!
3//! # 为什么单独一个模块
4//!
5//! [`crate::pack`] 负责采集,这里负责还原,两边共用
6//! [`crate::container::EntryMeta`]。放在 core 而不是各前端里的理由与
7//! pack 相同:CLI 和 GUI 都要解容器,两份实现迟早分歧——尤其还原策略
8//! 本身有大量平台分支,复制一份等于把平台差异也复制一遍。
9//!
10//! # 为什么「尽力还原」而不是「失败即报错」
11//!
12//! 文档 05 §4.1 定的策略是**按目标平台能力尽力还原,不支持的项明确
13//! 报告**。这两半都不能少:
14//!
15//! - 只要有一项设不上就整个解密失败,等于让用户为了一个时间戳丢掉
16//!   全部文件内容——内容是主体,元数据是附属。
17//! - 但静默丢弃更糟:用户以为完整还原了,直到某天发现所有文件的
18//!   修改时间都是解密那天。备份场景下这是实质的数据丢失,而且
19//!   **不可逆**——原目录往往已经删了。
20//!
21//! 所以每一项都试,失败的记进 [`RestoreReport`] 交给调用方展示。
22//!
23//! # 平台差异不猜,按实际能力分支
24//!
25//! | 项 | Windows | Unix |
26//! |---|---|---|
27//! | mtime | 支持(100ns 精度) | 支持(1ns) |
28//! | btime(创建时间) | 支持 | Linux 无法设置,报告为不支持 |
29//! | POSIX 权限位 | 无此概念,报告为不支持 | 支持 |
30//! | uid / gid | 无此概念 | **有意不还原**,见下 |
31//!
32//! uid/gid 在两个平台上都不还原:跨机器的数字 uid 指向的是完全不同的
33//! 用户,还原它要么无效要么危险(把文件判给本机的另一个账号)。设置
34//! 属主还需要 root,而为了元数据要求提权是本末倒置。仅记录、不还原,
35//! 与文档 §4.2 一致。
36//!
37//! # 目录 mtime:只有新建条目会冲掉它
38//!
39//! 我起初以为「设置子项的时间会更新父目录的 mtime」,据此把目录拆出来
40//! 按深度倒序处理。实测四种操作后发现那是错的:
41//!
42//! | 操作 | 父目录 mtime 是否改变 |
43//! |---|---|
44//! | 设置子目录的 mtime | 否 |
45//! | 设置子文件的 mtime | 否 |
46//! | 在目录里**新建**文件 | 是 |
47//! | 在目录里**新建**子目录 | 是 |
48//!
49//! 也就是说只有目录项的增删会动父目录的 mtime,这与 POSIX 对目录 mtime
50//! 的定义一致。真正的约束因此不在本模块内部的处理顺序上,而在调用时机:
51//! 必须等全部条目落盘之后再来设时间。排序那段复杂度已删除——留着一段
52//! 理由已被证伪的代码,比没有它更糟,下一个人会以为这里有个已知陷阱。
53
54use crate::container::{ContainerIndex, EntryMeta};
55use std::path::Path;
56
57/// 一项元数据未能还原的原因。
58///
59/// 用枚举而不是字符串:这些值要进 JSON 契约、要做翻译键,
60/// 字符串散落在各处迟早写错一个,而拼错的键在界面上显示为原文,
61/// 没有任何测试会因此变红。
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum UnsupportedKind {
64    /// POSIX 权限位(Windows 无此概念)。
65    Mode,
66    /// 创建时间(Linux 无法设置)。
67    Btime,
68    /// 属主 uid / gid(跨机器无意义,有意不还原)。
69    Owner,
70    /// 扩展属性。
71    Xattr,
72    /// 符号链接(当前平台不允许创建)。
73    ///
74    /// Windows 上创建符号链接需要 `SeCreateSymbolicLinkPrivilege`,普通
75    /// 账号未开开发者模式时**必然失败**(实测报「需要管理员权限」)。这属于
76    /// 环境的既有事实而不是这次操作出错,所以归 unsupported:报成 failure
77    /// 会让人反复重试一件不可能成功的事。链接目标仍完整保存在加密文件里,
78    /// 换台机器或开了开发者模式就能还原。
79    Symlink,
80}
81
82impl UnsupportedKind {
83    /// 稳定的字符串代号。
84    ///
85    /// **改动即破坏契约**:这些值同时是 `--json` 输出的字段值和前端的
86    /// 翻译键后缀。
87    #[must_use]
88    pub const fn code(self) -> &'static str {
89        match self {
90            Self::Mode => "mode",
91            Self::Btime => "btime",
92            Self::Owner => "owner",
93            Self::Xattr => "xattr",
94            Self::Symlink => "symlink",
95        }
96    }
97}
98
99/// 单项还原失败的记录。
100#[derive(Debug, Clone)]
101pub struct RestoreFailure {
102    /// 条目路径(相对容器根,用 `/` 连接)。
103    pub path: String,
104    /// 具体是哪一项没设上。
105    pub item: &'static str,
106    /// 操作系统给出的原因。
107    pub reason: String,
108}
109
110/// 还原报告。
111///
112/// # 为什么把「平台不支持」和「尝试了但失败」分开
113///
114/// 两者对用户的含义完全不同,合并会误导:
115///
116/// - 不支持是**平台的既有事实**,换台 Linux 解开就能拿到,不是出错,
117///   所以文档要求配一句「完整元数据仍保存在加密文件中」。
118/// - 失败是**这次操作出了问题**(文件被占用、权限不足、路径有问题),
119///   同一台机器重试可能就好了,需要用户处理。
120///
121/// 把前者报成错误会让人以为文件坏了;把后者报成"平台限制"会让人
122/// 放弃重试,白丢元数据。
123#[derive(Debug, Clone, Default)]
124pub struct RestoreReport {
125    /// 成功还原修改时间的条目数。
126    pub mtime_restored: usize,
127    /// 成功还原权限位的条目数。
128    pub mode_restored: usize,
129    /// 当前平台不支持的项,及涉及的条目数。
130    ///
131    /// 用 `Vec` 而不是 `HashMap`:项数固定只有四个,顺序稳定便于测试
132    /// 与展示,也免得引入哈希遍历顺序不定的问题。
133    pub unsupported: Vec<(UnsupportedKind, usize)>,
134    /// 尝试过但失败的项。
135    pub failures: Vec<RestoreFailure>,
136}
137
138impl RestoreReport {
139    /// 是否有任何需要告知用户的内容。
140    #[must_use]
141    pub fn has_notes(&self) -> bool {
142        !self.unsupported.is_empty() || !self.failures.is_empty()
143    }
144
145    /// 记一项平台不支持,计数累加。
146    fn note_unsupported(&mut self, kind: UnsupportedKind) {
147        if let Some(slot) = self.unsupported.iter_mut().find(|(k, _)| *k == kind) {
148            slot.1 = slot.1.saturating_add(1);
149        } else {
150            self.unsupported.push((kind, 1));
151        }
152    }
153
154    /// 一次记入 `n` 项平台不支持。
155    ///
156    /// 供 [`crate::unpack`] 汇报符号链接:链接是在元数据还原之前创建的
157    /// (目标类型判断依赖文件已落盘),所以那边先自己计数,再合并到本
158    /// 报告里,而不是每建一个链接就回调一次。
159    ///
160    /// `n` 为 0 时不产生任何条目——否则报告里会出现「0 个链接不支持」
161    /// 这种让人以为出了问题的空条目。
162    pub fn note_unsupported_n(&mut self, kind: UnsupportedKind, n: usize) {
163        for _ in 0..n {
164            self.note_unsupported(kind);
165        }
166    }
167
168    /// 追加一条还原失败记录。
169    ///
170    /// 同样供 [`crate::unpack`] 汇报链接创建失败,理由见
171    /// [`Self::note_unsupported_n`]。
172    pub fn push_failure(&mut self, path: String, item: &'static str, reason: String) {
173        self.failures.push(RestoreFailure { path, item, reason });
174    }
175}
176
177/// 把索引里记录的元数据还原到 `root` 下已落盘的条目上。
178///
179/// `root` 是容器根目录在磁盘上的实际位置(即 `idx.root` 对应的那个
180/// 目录)。条目相对路径由索引给出。
181///
182/// # 必须在全部条目落盘之后调用
183///
184/// 这是本函数唯一的顺序约束,但它在调用方那边:**新建**目录项会更新
185/// 父目录的 mtime,所以只要还有文件要写,就不能开始设目录的时间——
186/// 否则刚设好的目录时间会被随后写入的子项冲掉。症状是「文件的时间对了、
187/// 目录的时间还是现在」,且只在非空目录上出现,空目录一切正常,很容易
188/// 被当成偶发问题。
189///
190/// 反过来,**设置**子项的时间不会动父目录(实测见模块文档),所以本函数
191/// 内部处理条目的先后无所谓,不需要按深度排序。
192///
193/// # 不返回 `Result`
194///
195/// 单项失败不该中断还原,也不该让整个解密失败——内容已经写好了。
196/// 所有问题都进报告,由调用方决定怎么展示。
197#[must_use]
198pub fn restore_metadata(root: &Path, idx: &ContainerIndex) -> RestoreReport {
199    let mut rep = RestoreReport::default();
200
201    // 一个循环处理所有类型。曾经把目录拆出来按深度倒序处理,理由是
202    // 「设子项会更新父目录的 mtime」——实测证明那是错的(见模块文档),
203    // 于是删掉了那段排序
204    for e in &idx.entries {
205        let p = root.join(e.path.join(std::path::MAIN_SEPARATOR_STR));
206        if !p.exists() {
207            // 落盘阶段跳过或改名的条目,那边已经报告过,这里不重复记
208            continue;
209        }
210        apply(&p, &e.meta, &e.display_path(), &mut rep);
211    }
212
213    rep
214}
215
216/// 对单个已存在的路径应用元数据。
217fn apply(path: &Path, meta: &EntryMeta, display: &str, rep: &mut RestoreReport) {
218    if let Some(ns) = meta.mtime_ns {
219        match set_mtime(path, ns) {
220            Ok(()) => rep.mtime_restored = rep.mtime_restored.saturating_add(1),
221            Err(e) => rep.failures.push(RestoreFailure {
222                path: display.to_owned(),
223                item: "mtime",
224                reason: e.to_string(),
225            }),
226        }
227    }
228
229    if meta.btime_ns.is_some() {
230        // 创建时间:Windows 能设,但 std 没有提供接口(需要 SetFileTime
231        // 或 FileTimes::set_created,后者仍是 unstable)。Linux 根本
232        // 不允许设置。两种情况对用户是同一件事——这次拿不到,但值还在
233        // 加密文件里,换个环境或将来版本能还原,所以报告为不支持而不是
234        // 失败:报成失败会让人反复重试一件必然失败的事。
235        rep.note_unsupported(UnsupportedKind::Btime);
236    }
237
238    if let Some(mode) = meta.mode {
239        match set_mode(path, mode) {
240            Ok(true) => rep.mode_restored = rep.mode_restored.saturating_add(1),
241            Ok(false) => rep.note_unsupported(UnsupportedKind::Mode),
242            Err(e) => rep.failures.push(RestoreFailure {
243                path: display.to_owned(),
244                item: "mode",
245                reason: e.to_string(),
246            }),
247        }
248    }
249
250    if meta.uid.is_some() || meta.gid.is_some() {
251        // 有意不还原,理由见模块文档
252        rep.note_unsupported(UnsupportedKind::Owner);
253    }
254
255    if !meta.xattrs.is_empty() {
256        rep.note_unsupported(UnsupportedKind::Xattr);
257    }
258}
259
260/// 设置修改时间。
261///
262/// 用 `std::fs::FileTimes`(1.75 稳定)而不是第三方 crate:core 的依赖
263/// 只有密码学与格式相关的几个,为一个时间戳引入 `filetime` 不值得。
264fn set_mtime(path: &Path, ns: i128) -> std::io::Result<()> {
265    use std::fs::FileTimes;
266
267    let t = ns_to_system_time(ns).ok_or_else(|| {
268        std::io::Error::new(
269            std::io::ErrorKind::InvalidInput,
270            format!("时间戳 {ns} 超出可表示范围"),
271        )
272    })?;
273
274    // 必须用 OpenOptions 而不是 File::open:Windows 上设置时间需要写
275    // 权限,只读句柄会得到「拒绝访问」。而目录在 Windows 上不能用普通
276    // 的 write(true) 打开,要走 FILE_FLAG_BACKUP_SEMANTICS——所以目录
277    // 和文件的打开方式不同,见 open_for_times。
278    let f = open_for_times(path)?;
279    f.set_times(FileTimes::new().set_modified(t))
280}
281
282/// 打开一个路径以便设置时间戳。
283///
284/// 目录与文件的打开方式在 Windows 上不同,这里把差异收在一处。
285#[cfg(windows)]
286fn open_for_times(path: &Path) -> std::io::Result<std::fs::File> {
287    use std::os::windows::fs::OpenOptionsExt;
288    // FILE_FLAG_BACKUP_SEMANTICS = 0x0200_0000
289    // 不带这个标志无法获得目录的句柄,CreateFile 会返回「拒绝访问」,
290    // 于是目录的 mtime 永远设不上——而文件一切正常,很容易误判成
291    // 「目录时间还原不了是平台限制」。它不是限制,只是要换个打开方式。
292    std::fs::OpenOptions::new()
293        .write(true)
294        .custom_flags(0x0200_0000)
295        .open(path)
296        .or_else(|_| {
297            // 目录不接受 write(true),退回只读 + BACKUP_SEMANTICS
298            std::fs::OpenOptions::new()
299                .read(true)
300                .custom_flags(0x0200_0000)
301                .open(path)
302        })
303}
304
305/// 见 windows 版本的说明。Unix 上目录可以直接用只读句柄设时间。
306#[cfg(not(windows))]
307fn open_for_times(path: &Path) -> std::io::Result<std::fs::File> {
308    std::fs::OpenOptions::new()
309        .write(true)
310        .open(path)
311        .or_else(|_| std::fs::File::open(path))
312}
313
314/// 设置 POSIX 权限位。
315///
316/// 返回 `Ok(false)` 表示当前平台没有这个概念——与「设置失败」是两件
317/// 不同的事,调用方要分开报告。
318#[cfg(unix)]
319fn set_mode(path: &Path, mode: u32) -> std::io::Result<bool> {
320    use std::os::unix::fs::PermissionsExt;
321    std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))?;
322    Ok(true)
323}
324
325/// 见 unix 版本。Windows 没有 POSIX 权限位。
326///
327/// **不做「只读位近似」**:把 `0o444` 映射成 Windows 只读属性看着贴心,
328/// 实际是用一个语义不同的东西冒充还原成功——用户会以为权限回来了。
329/// 如实报告不支持,并告诉他值还在文件里。
330#[cfg(not(unix))]
331fn set_mode(_path: &Path, _mode: u32) -> std::io::Result<bool> {
332    Ok(false)
333}
334
335/// Unix 纳秒转 `SystemTime`。
336///
337/// 负数(1970 年之前)也要能处理:老文件的时间戳确实可能早于纪元,
338/// 直接当成 0 会把「1965 年」显示成「1970 年」,是在编造数据。
339fn ns_to_system_time(ns: i128) -> Option<std::time::SystemTime> {
340    use std::time::{Duration, UNIX_EPOCH};
341    let secs = ns.div_euclid(1_000_000_000);
342    let sub = ns.rem_euclid(1_000_000_000);
343    let sub_ns = u32::try_from(sub).ok()?;
344    if secs >= 0 {
345        let s = u64::try_from(secs).ok()?;
346        UNIX_EPOCH.checked_add(Duration::new(s, sub_ns))
347    } else {
348        // rem_euclid 保证 sub 非负,所以这里的秒数要往前多减一秒再补回。
349        // 用 checked_neg 而不是 -secs:i128::MIN 取反会溢出,虽然这个
350        // 值不可能来自真实时间戳,但它来自文件里的字节,而文件内容是
351        // 不可信输入——core 里任何 panic 路径都是拒绝服务缺陷
352        let s = u64::try_from(secs.checked_neg()?).ok()?;
353        UNIX_EPOCH.checked_sub(Duration::new(s, 0))?.checked_add(
354            Duration::from_nanos(u64::from(sub_ns)),
355        )
356    }
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362    use crate::container::{ContainerBuilder, EntryMeta};
363
364    fn tmp(tag: &str) -> std::path::PathBuf {
365        let p = std::env::temp_dir().join(format!("omy-restore-{tag}"));
366        let _ = std::fs::remove_dir_all(&p);
367        std::fs::create_dir_all(&p).unwrap();
368        p
369    }
370
371    /// 一个具体的、非"现在"的时间:2019-03-07 14:25:36.123456789 UTC
372    const T: i128 = 1_551_968_736_123_456_789;
373
374    #[test]
375    fn mtime_is_actually_written_to_disk() {
376        // 这条抓的是本轮修的缺陷本体:元数据一直被采集并编码进容器,
377        // 但解包时无人读取,解出来的文件时间是"现在"。
378        // 不这样测就只能看到"函数返回了 Ok",而那与磁盘上的时间无关。
379        let root = tmp("mtime");
380        std::fs::write(root.join("a.txt"), b"x").unwrap();
381
382        let mut b = ContainerBuilder::new("r");
383        b.add_file(vec!["a.txt".into()], 1, None, EntryMeta {
384            mtime_ns: Some(T),
385            ..EntryMeta::default()
386        })
387        .unwrap();
388        let idx = b.finish().unwrap();
389
390        let rep = restore_metadata(&root, &idx);
391        assert_eq!(rep.mtime_restored, 1);
392
393        let got = std::fs::metadata(root.join("a.txt"))
394            .unwrap()
395            .modified()
396            .unwrap();
397        let want = ns_to_system_time(T).unwrap();
398        let d = got
399            .duration_since(want)
400            .unwrap_or_else(|e| e.duration())
401            .as_secs();
402        assert!(d < 2, "磁盘上的 mtime 应当接近 {want:?},实际 {got:?}");
403
404        let _ = std::fs::remove_dir_all(&root);
405    }
406
407    #[test]
408    fn dir_and_file_times_both_restored_in_real_extract_order() {
409        // 模拟真实解包流程:先把所有条目写到盘上,再统一还原元数据。
410        // 这是 extract 实际的调用方式,也是唯一被支持的顺序。
411        let root = tmp("dirorder");
412        std::fs::create_dir_all(root.join("sub")).unwrap();
413        std::fs::write(root.join("sub/c.txt"), b"c").unwrap();
414
415        let mut b = ContainerBuilder::new("r");
416        b.add_dir(vec!["sub".into()], EntryMeta {
417            mtime_ns: Some(T),
418            ..EntryMeta::default()
419        })
420        .unwrap();
421        b.add_file(vec!["sub".into(), "c.txt".into()], 1, None, EntryMeta {
422            mtime_ns: Some(T),
423            ..EntryMeta::default()
424        })
425        .unwrap();
426        let idx = b.finish().unwrap();
427
428        let rep = restore_metadata(&root, &idx);
429        assert_eq!(rep.mtime_restored, 2, "目录和文件都该设上");
430
431        let want = ns_to_system_time(T).unwrap();
432        for rel in ["sub", "sub/c.txt"] {
433            let p = root.join(rel.replace('/', std::path::MAIN_SEPARATOR_STR));
434            let got = std::fs::metadata(&p).unwrap().modified().unwrap();
435            let d = got
436                .duration_since(want)
437                .unwrap_or_else(|e| e.duration())
438                .as_secs();
439            assert!(d < 2, "{rel} 的 mtime 不对:{got:?}");
440        }
441
442        let _ = std::fs::remove_dir_all(&root);
443    }
444
445    #[test]
446    fn creating_entries_after_restore_clobbers_dir_mtime() {
447        // 这条是上面那条的对照,锁住「必须在全部条目落盘之后调用」这个
448        // 约束**真的存在**。没有它,那句文档就只是一句声明,谁也不知道
449        // 违反了会怎样,将来有人为了「边解边设省一次遍历」把调用挪进
450        // 落盘循环里,测试不会有任何反应。
451        //
452        // 顺带修正一个我自己写错的注释:起初以为设置子项时间也会冲掉
453        // 父目录,据此加了按深度排序。实测证明只有**新建**条目才会。
454        let root = tmp("clobber");
455        std::fs::create_dir_all(root.join("sub")).unwrap();
456
457        let mut b = ContainerBuilder::new("r");
458        b.add_dir(vec!["sub".into()], EntryMeta {
459            mtime_ns: Some(T),
460            ..EntryMeta::default()
461        })
462        .unwrap();
463        let idx = b.finish().unwrap();
464
465        let _ = restore_metadata(&root, &idx);
466        let want = ns_to_system_time(T).unwrap();
467        let after_restore = std::fs::metadata(root.join("sub"))
468            .unwrap()
469            .modified()
470            .unwrap();
471        assert!(
472            after_restore
473                .duration_since(want)
474                .unwrap_or_else(|e| e.duration())
475                .as_secs()
476                < 2,
477            "还原后目录时间应当已设好"
478        );
479
480        // 顺序颠倒:还原之后又新建了一个条目
481        std::fs::write(root.join("sub/late.txt"), b"x").unwrap();
482        let after_write = std::fs::metadata(root.join("sub"))
483            .unwrap()
484            .modified()
485            .unwrap();
486        assert_ne!(
487            after_restore, after_write,
488            "新建条目会冲掉目录 mtime——这正是必须最后还原的原因"
489        );
490
491        // 而设置子项的时间不会:这是当初那段排序复杂度被删掉的依据
492        let before = after_write;
493        let mut b2 = ContainerBuilder::new("r");
494        b2.add_file(vec!["sub".into(), "late.txt".into()], 1, None, EntryMeta {
495            mtime_ns: Some(T),
496            ..EntryMeta::default()
497        })
498        .unwrap();
499        let _ = restore_metadata(&root, &b2.finish().unwrap());
500        let after_child_times = std::fs::metadata(root.join("sub"))
501            .unwrap()
502            .modified()
503            .unwrap();
504        assert_eq!(
505            before, after_child_times,
506            "设置子项时间不该改变父目录 mtime;若这条失败,按深度排序的\
507             复杂度就有必要重新加回来"
508        );
509
510        let _ = std::fs::remove_dir_all(&root);
511    }
512
513    #[test]
514    fn owner_is_reported_unsupported_not_silently_dropped() {
515        // uid/gid 有意不还原。但"有意不做"和"忘了做"在代码里长得一样,
516        // 区别只体现在有没有告诉用户。这条锁住"必须告诉用户"。
517        let root = tmp("owner");
518        std::fs::write(root.join("a.txt"), b"x").unwrap();
519
520        let mut b = ContainerBuilder::new("r");
521        b.add_file(vec!["a.txt".into()], 1, None, EntryMeta {
522            uid: Some(1000),
523            gid: Some(1000),
524            ..EntryMeta::default()
525        })
526        .unwrap();
527        let idx = b.finish().unwrap();
528
529        let rep = restore_metadata(&root, &idx);
530        assert!(rep.has_notes(), "不还原就必须报告");
531        assert_eq!(
532            rep.unsupported,
533            vec![(UnsupportedKind::Owner, 1)],
534            "应当只报 owner 一项"
535        );
536
537        let _ = std::fs::remove_dir_all(&root);
538    }
539
540    #[cfg(not(unix))]
541    #[test]
542    fn mode_reported_unsupported_on_windows_not_faked() {
543        // Windows 上不能把 mode 映射成只读属性冒充成功。
544        // 这条确保它进的是 unsupported(可换平台还原)而不是
545        // failures(像是出了错),也不是 mode_restored(谎报成功)。
546        let root = tmp("mode-win");
547        std::fs::write(root.join("a.txt"), b"x").unwrap();
548
549        let mut b = ContainerBuilder::new("r");
550        b.add_file(vec!["a.txt".into()], 1, None, EntryMeta {
551            mode: Some(0o644),
552            ..EntryMeta::default()
553        })
554        .unwrap();
555        let idx = b.finish().unwrap();
556
557        let rep = restore_metadata(&root, &idx);
558        assert_eq!(rep.mode_restored, 0, "不得谎报还原成功");
559        assert!(rep.failures.is_empty(), "平台不支持不是失败");
560        assert_eq!(rep.unsupported, vec![(UnsupportedKind::Mode, 1)]);
561
562        let _ = std::fs::remove_dir_all(&root);
563    }
564
565    #[cfg(unix)]
566    #[test]
567    fn mode_is_actually_applied_on_unix() {
568        use std::os::unix::fs::PermissionsExt;
569        let root = tmp("mode-unix");
570        std::fs::write(root.join("a.txt"), b"x").unwrap();
571
572        let mut b = ContainerBuilder::new("r");
573        b.add_file(vec!["a.txt".into()], 1, None, EntryMeta {
574            mode: Some(0o600),
575            ..EntryMeta::default()
576        })
577        .unwrap();
578        let idx = b.finish().unwrap();
579
580        let rep = restore_metadata(&root, &idx);
581        assert_eq!(rep.mode_restored, 1);
582        let m = std::fs::metadata(root.join("a.txt")).unwrap();
583        assert_eq!(m.permissions().mode() & 0o777, 0o600);
584
585        let _ = std::fs::remove_dir_all(&root);
586    }
587
588    #[test]
589    fn missing_entries_are_skipped_without_failure() {
590        // 落盘阶段跳过或改名的条目在这里找不到。那边已经报告过了,
591        // 这里再记一次会让用户看到同一个问题的两种说法,
592        // 还会把"文件名被调整"说成"元数据还原失败"。
593        let root = tmp("missing");
594
595        let mut b = ContainerBuilder::new("r");
596        b.add_file(vec!["ghost.txt".into()], 1, None, EntryMeta {
597            mtime_ns: Some(T),
598            ..EntryMeta::default()
599        })
600        .unwrap();
601        let idx = b.finish().unwrap();
602
603        let rep = restore_metadata(&root, &idx);
604        assert_eq!(rep.mtime_restored, 0);
605        assert!(rep.failures.is_empty(), "条目不存在不算还原失败");
606
607        let _ = std::fs::remove_dir_all(&root);
608    }
609
610    #[test]
611    fn empty_meta_produces_no_notes() {
612        // 没有元数据可还原时必须安静。否则每次解密都弹一堆
613        // "未能还原",真正需要注意的报告就被淹没了。
614        let root = tmp("empty");
615        std::fs::write(root.join("a.txt"), b"x").unwrap();
616
617        let mut b = ContainerBuilder::new("r");
618        b.add_file(vec!["a.txt".into()], 1, None, EntryMeta::default())
619            .unwrap();
620        let idx = b.finish().unwrap();
621
622        let rep = restore_metadata(&root, &idx);
623        assert!(!rep.has_notes(), "无元数据时不该有任何提示");
624        assert_eq!(rep.mtime_restored, 0);
625
626        let _ = std::fs::remove_dir_all(&root);
627    }
628
629    #[test]
630    fn unsupported_counts_accumulate_across_entries() {
631        // 报告要说"1247 个文件的权限位",不是重复 1247 行。
632        // 计数不累加的话,大目录的报告会长到没人看。
633        let root = tmp("accum");
634        for n in ["a", "b", "c"] {
635            std::fs::write(root.join(format!("{n}.txt")), b"x").unwrap();
636        }
637
638        let mut b = ContainerBuilder::new("r");
639        for n in ["a", "b", "c"] {
640            b.add_file(vec![format!("{n}.txt")], 1, None, EntryMeta {
641                uid: Some(1000),
642                ..EntryMeta::default()
643            })
644            .unwrap();
645        }
646        let idx = b.finish().unwrap();
647
648        let rep = restore_metadata(&root, &idx);
649        assert_eq!(rep.unsupported.len(), 1, "同一项只占一条");
650        assert_eq!(rep.unsupported[0].1, 3, "计数应累加到 3");
651
652        let _ = std::fs::remove_dir_all(&root);
653    }
654
655    #[test]
656    fn pre_epoch_timestamps_round_trip() {
657        // 1970 年之前的时间戳确实存在于老文件上。当成 0 处理会把
658        // "1965 年"显示成"1970 年"——编造一个看起来合理的错误值,
659        // 比明确报告不知道更糟。
660        let ns: i128 = -150_000_000_000_000_000; // 1965 年左右
661        let t = ns_to_system_time(ns).expect("纪元前时间应可表示");
662        let back = std::time::UNIX_EPOCH
663            .duration_since(t)
664            .expect("应当早于纪元");
665        assert_eq!(
666            i128::try_from(back.as_nanos()).unwrap(),
667            -ns,
668            "纪元前时间戳往返后应当一致"
669        );
670    }
671
672    #[test]
673    fn sub_second_precision_is_not_dropped() {
674        // 只用整秒测的话,把纳秒部分整个丢掉的实现也能通过。
675        //
676        // 但也不能断言纳秒完全相等:Windows 的 SystemTime 底层是
677        // FILETIME,粒度 100ns,实测 123_456_789 会变成 123_456_700。
678        // 这是文档 05 §4.2 已经写明并接受的精度损失(「Windows 100ns
679        // 精度 / Unix 1ns,精度损失可接受」),不是缺陷。
680        //
681        // 所以判据是「误差在 1µs 内」:把纳秒整个丢掉的实现会得到 0,
682        // 差 123 毫秒,仍然过不了。若改成 `!= 0` 就没这个抓错能力了,
683        // 任何非零值都能糊弄过去。
684        let ns: i128 = 1_551_968_736_123_456_789;
685        let t = ns_to_system_time(ns).unwrap();
686        let d = t.duration_since(std::time::UNIX_EPOCH).unwrap();
687        let got = i64::from(d.subsec_nanos());
688        let diff = (got - 123_456_789_i64).abs();
689        assert!(
690            diff < 1_000,
691            "亚秒部分不能被丢弃:期望约 123456789ns,实际 {got}ns(差 {diff}ns)"
692        );
693    }
694
695    #[test]
696    fn unsupported_codes_are_stable() {
697        // 这些字符串是 JSON 字段值与翻译键后缀,改了会静默破坏前端:
698        // 拼错的翻译键在界面上显示为原文,没有测试会因此变红。
699        assert_eq!(UnsupportedKind::Mode.code(), "mode");
700        assert_eq!(UnsupportedKind::Btime.code(), "btime");
701        assert_eq!(UnsupportedKind::Owner.code(), "owner");
702        assert_eq!(UnsupportedKind::Xattr.code(), "xattr");
703        assert_eq!(UnsupportedKind::Symlink.code(), "symlink");
704    }
705}