omy-core 0.0.1

omy 加密文件格式的核心实现:格式读写、密钥体系、分块 AEAD。零 FFmpeg 依赖。
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
//! 把一个目录打包成容器载荷。
//!
//! # 为什么这一层在 core 而不在各前端里
//!
//! CLI 早先自己实现了这套遍历逻辑,GUI 要做文件夹加密时面临选择:
//! 复制一份,还是提上来共享。复制的代价不是多写几行,而是**两份实现
//! 迟早分歧**——比如哪天给 CLI 修了「跳过符号链接」的处理,GUI 那份
//! 还在按老样子打包,用户在两个入口得到不同的容器。
//!
//! core 里已经有 [`crate::fsatomic`] 在做文件系统操作,所以放这里
//! 与既有边界一致。
//!
//! # 为什么不用 walkdir
//!
//! core 的依赖目前只有密码学与格式相关的几个 crate,保持这份克制是
//! 有价值的:它让 core 能被任意项目复用,也让审计面更小。
//! 目录遍历用 `std::fs` 手写一个显式栈就够了,不值得为此引入新依赖。
//!
//! 手写还有个额外好处:**递归深度可控**。`walkdir` 默认无限深,
//! 遇到深层嵌套目录时 CLI 那份实现是靠运气不炸;这里显式设了上限。
//!
//! # 顺序必须稳定
//!
//! 条目按路径排序后写入。这不是美观问题——索引里记的是每个文件在
//! 载荷中的**区间**,遍历顺序变了,同一个目录两次打包会得到不同的
//! 布局。稳定顺序让「同样的输入产出同样的容器」成立,便于校验。

use crate::container::{ContainerBuilder, ContainerIndex, EntryMeta};
use crate::error::{Error, Result};
use std::path::{Path, PathBuf};

/// 目录递归深度上限。
///
/// 超过就拒绝,而不是一路走到栈溢出。真实目录很少超过 20 层,
/// 到 64 层基本可以断定是符号链接环或者恶意构造。
pub const MAX_DEPTH: usize = 64;

/// 打包过程中跳过的条目。
///
/// 必须如实报告给用户:静默丢弃会让人以为整个目录都进去了,
/// 等到解密时才发现少东西,那时原目录可能已经删了。
#[derive(Debug, Clone)]
pub struct SkippedEntry {
    /// 被跳过的路径(相对根目录)。
    pub path: String,
    /// 跳过原因的稳定标识,供上层翻译。
    pub reason: SkipReason,
}

/// 跳过原因。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SkipReason {
    /// 符号链接。跨平台还原语义不一致,首期不支持(文档 05 §4.2)。
    Symlink,
    /// 既不是普通文件也不是目录(设备文件、FIFO、socket 等)。
    NotRegular,
    /// 读取失败:权限不足、文件正被独占打开、或读到一半被删了。
    Unreadable,
    /// 超过递归深度上限。
    TooDeep,
}

impl SkipReason {
    /// 稳定的英文标识,用作翻译键与 JSON 字段。
    #[must_use]
    pub const fn code(self) -> &'static str {
        match self {
            Self::Symlink => "symlink",
            Self::NotRegular => "not_regular",
            Self::Unreadable => "unreadable",
            Self::TooDeep => "too_deep",
        }
    }
}

/// 打包结果。
#[derive(Debug)]
pub struct PackedFolder {
    /// 拼接后的明文载荷。
    pub payload: Vec<u8>,
    /// 容器索引。
    pub index: ContainerIndex,
    /// 根目录名,用作加密后的显示名。
    pub root_name: String,
    /// 文件数。
    pub file_count: usize,
    /// 目录数。
    pub dir_count: usize,
    /// 符号链接数。
    ///
    /// 单独计数而不是并进 `file_count`:还原时链接可能因平台限制建不出来
    /// (Windows 无特权就必然失败),报告要能说清「10 个文件都好了,2 个
    /// 链接没能还原」。混在一起就只能说「12 项里有 2 项有问题」。
    pub link_count: usize,
    /// 被跳过的条目,需如实展示给用户。
    pub skipped: Vec<SkippedEntry>,
}

/// 遍历时可选的进度回调。
///
/// 大目录打包要花不少时间,GUI 需要据此更新界面。
/// 用 `&mut dyn FnMut` 而不是泛型参数:这个函数已经够长,
/// 再加个类型参数会让调用方的错误信息变得难读。
pub type ProgressFn<'a> = &'a mut dyn FnMut(&str, u64);

/// 遍历时交给回调的一个条目。
///
/// 抽出这个类型是为了让容器模式与树形模式**共用同一套遍历判定**:
/// 按名字稳定排序、用 `symlink_metadata` 而不是 `metadata`、跳过符号链接
/// 与非普通文件、深度上限。各写一份的话,哪天给容器模式修了「跳过 socket
/// 文件」,树形模式还在按老样子处理,同一个目录用两种模式加密会得到不同的
/// 内容集合。
#[derive(Debug)]
pub struct WalkItem {
    /// 绝对路径。
    pub path: PathBuf,
    /// 相对根目录的路径组件。
    pub comps: Vec<String>,
    /// 是否是目录。
    pub is_dir: bool,
    /// 文件系统元数据,供调用方提取 mtime 等。
    pub meta: EntryMeta,
    /// 符号链接的目标;非链接时为 `None`。
    ///
    /// 由 `walk_dir` 交给调用方而不是自行跳过,是因为两种加密模式对链接
    /// 的处理能力不同:容器模式有索引可以存目标,树形模式把每个条目落成
    /// 独立 `.omy` 文件、没有地方记这个字段。让 `walk_dir` 只负责「如实
    /// 报告这是个链接」,各模式自己决定存还是跳过——遍历判定规则仍然只有
    /// 一处,符合本模块与 `tree` 共用 `walk_dir` 的约定。
    pub link_target: Option<String>,
}

/// 遍历目录树,对每个条目调用 `visit`。
///
/// **父目录保证先于其子项被访问**——树形模式依赖这一点:子项要写进父目录
/// 对应的密文目录,那个目录必须已经建好。
///
/// 跳过的条目记进 `skipped`,不中断遍历:一个打不开的文件不该让整个目录
/// 加密失败。
///
/// # Errors
///
/// 只有 `visit` 自己返回错误时才会中断。
pub fn walk_dir(
    root: &Path,
    skipped: &mut Vec<SkippedEntry>,
    visit: &mut dyn FnMut(&WalkItem) -> Result<()>,
) -> Result<()> {
    // 显式栈而不是递归:递归在深目录上会爆栈,而爆栈是不可恢复的。
    // 栈里存 (绝对路径, 相对组件, 深度)
    let mut stack: Vec<(PathBuf, Vec<String>, usize)> = vec![(root.to_path_buf(), Vec::new(), 0)];

    while let Some((dir, rel, depth)) = stack.pop() {
        if depth > MAX_DEPTH {
            skipped.push(SkippedEntry {
                path: rel.join("/"),
                reason: SkipReason::TooDeep,
            });
            continue;
        }

        let Ok(rd) = std::fs::read_dir(&dir) else {
            skipped.push(SkippedEntry {
                path: rel.join("/"),
                reason: SkipReason::Unreadable,
            });
            continue;
        };

        // 先收集再排序:read_dir 的顺序由文件系统决定,
        // 不排序的话同一目录两次打包会得到不同布局
        let mut items: Vec<_> = rd.flatten().collect();
        items.sort_by_key(std::fs::DirEntry::file_name);

        // 目录压栈时要反着来。栈是后进先出,正序压入会让遍历
        // 变成倒序——虽然索引里已排序,但 payload 的拼接顺序
        // 会跟着变,同样破坏可复现性
        let mut subdirs = Vec::new();

        for item in items {
            let path = item.path();
            let name = item.file_name().to_string_lossy().into_owned();
            let mut comps = rel.clone();
            comps.push(name);

            // 用 symlink_metadata 而不是 metadata:后者会跟随链接,
            // 于是一个指向目录的符号链接会被当成真目录递归进去,
            // 环状链接直接把我们送进死循环
            let Ok(md) = path.symlink_metadata() else {
                skipped.push(SkippedEntry {
                    path: comps.join("/"),
                    reason: SkipReason::Unreadable,
                });
                continue;
            };

            let meta = meta_from_fs(&md);

            if md.file_type().is_symlink() {
                // 读链接目标。读不出来才跳过——能读出来就交给调用方,
                // 由它按自己的存储能力决定存进索引还是记为跳过。
                //
                // 注意这里不跟随链接去看目标是文件还是目录:目标可能不
                // 存在(悬空链接)或指向环,跟随就等于把 symlink_metadata
                // 挡掉的那两个问题又放回来。类型判断留到还原时做。
                match std::fs::read_link(&path) {
                    Ok(target) => {
                        visit(&WalkItem {
                            path,
                            comps,
                            is_dir: false,
                            meta,
                            link_target: Some(target.to_string_lossy().into_owned()),
                        })?;
                    }
                    Err(_) => skipped.push(SkippedEntry {
                        path: comps.join("/"),
                        reason: SkipReason::Unreadable,
                    }),
                }
                continue;            }

            if md.is_dir() {
                visit(&WalkItem {
                    path: path.clone(),
                    comps: comps.clone(),
                    is_dir: true,
                    meta,
                    link_target: None,
                })?;
                subdirs.push((path, comps, depth.saturating_add(1)));
            } else if md.is_file() {
                visit(&WalkItem {
                    path,
                    comps,
                    is_dir: false,
                    meta,
                    link_target: None,
                })?;
            } else {
                // 设备文件、FIFO、socket 等
                skipped.push(SkippedEntry {
                    path: comps.join("/"),
                    reason: SkipReason::NotRegular,
                });
            }
        }

        subdirs.reverse();
        stack.extend(subdirs);
    }
    Ok(())
}

/// 把 `root` 目录打包成容器载荷。
///
/// # 内存
///
/// 载荷是**整个目录的内容拼接**,会全部读进内存。这与 core 其他部分
/// 按块处理的风格不一致,但容器格式本身要求先知道每个文件的区间才能
/// 写索引,而区间要等前面所有文件都读完才确定。
///
/// 调用方应对大目录做预检——`ContainerIndex` 里有
/// [`crate::container::RECOMMENDED_MAX_ENTRIES`] 可参考。
///
/// # Errors
///
/// - 根路径不是目录
/// - 索引构建失败(路径组件非法等,由 `ContainerBuilder` 校验)
///
/// 单个文件读不出来**不算错误**,会记进 `skipped` 继续——
/// 一个打不开的文件不该让整个目录加密失败。
pub fn pack_folder(root: &Path, mut progress: Option<ProgressFn<'_>>) -> Result<PackedFolder> {
    if !root.is_dir() {
        return Err(Error::Io(std::io::Error::new(
            std::io::ErrorKind::NotADirectory,
            "pack_folder expects a directory",
        )));
    }

    let root_name = root
        .file_name()
        .map(|s| s.to_string_lossy().into_owned())
        // 根目录(如 `C:\`)没有 file_name。用固定名而不是空串:
        // 空名会让解密时创建不出目录
        .unwrap_or_else(|| String::from("folder"));

    let mut builder = ContainerBuilder::new(root_name.clone());
    let mut payload = Vec::new();
    let mut skipped = Vec::new();
    let mut file_count = 0usize;
    let mut dir_count = 0usize;
    let mut link_count = 0usize;
    // 闭包里读不出来的文件单独收集:`skipped` 已被 walk_dir 可变借走,
    // 闭包不能同时碰它。遍历结束后合并
    let mut unreadable: Vec<String> = Vec::new();

    walk_dir(root, &mut skipped, &mut |item| {
        // 链接必须在读文件之前判断。否则 `std::fs::read` 会跟随链接,
        // 把**目标的内容**当成链接自身的内容存进容器——还原出来就是一个
        // 普通文件而不是链接,内容看着还对,属于静默的错误结果。
        if let Some(target) = &item.link_target {
            builder.add_symlink(item.comps.clone(), target.clone(), item.meta.clone())?;
            link_count = link_count.saturating_add(1);
        } else if item.is_dir {
            builder.add_dir(item.comps.clone(), item.meta.clone())?;
            dir_count = dir_count.saturating_add(1);
        } else {
            let Ok(data) = std::fs::read(&item.path) else {
                // 读不出来就跳过并记录,不让整个目录加密失败。
                // 常见原因:文件正被别的程序独占打开
                //
                // 注意这里不能 push 到 skipped——它已被 walk_dir 借走。
                // 交给下面的 unreadable 收集
                unreadable.push(item.comps.join("/"));
                return Ok(());
            };
            let size = data.len() as u64;
            let hash = crate::util::blake2b_256(&data);
            builder.add_file(item.comps.clone(), size, Some(hash), item.meta.clone())?;
            payload.extend_from_slice(&data);
            file_count = file_count.saturating_add(1);
            if let Some(cb) = progress.as_deref_mut() {
                cb(&item.comps.join("/"), size);
            }
        }
        Ok(())
    })?;

    let index = builder.finish()?;

    // 合并闭包里收集的读失败项
    skipped.extend(unreadable.into_iter().map(|path| SkippedEntry {
        path,
        reason: SkipReason::Unreadable,
    }));

    Ok(PackedFolder {
        payload,
        index,
        root_name,
        file_count,
        dir_count,
        link_count,
        skipped,
    })
}

/// 流式打包的计划:遍历一遍目录,只取大小,不读内容。
///
/// [`pack_folder`] 把所有文件内容拼进一个内存载荷,几十 GB 的文件夹在手机上
/// 根本加密不了。流式打包分三步:`plan_folder` 定下条目与偏移(索引长度从此
/// 确定,只差逐文件哈希)→ [`PackPlan::reader`] 按同样的顺序把文件内容串成
/// 一条 `Read` 流,边读边算哈希 → [`PackPlan::finish`] 填入哈希得到真索引。
/// 配合 [`crate::file::encrypt_stream`] 的定稿回调使用。
#[derive(Debug)]
pub struct PackPlan {
    /// 容器根名。
    pub root_name: String,
    items: Vec<PlanItem>,
    /// 普通文件数。
    pub file_count: usize,
    /// 目录数。
    pub dir_count: usize,
    /// 符号链接数。
    pub link_count: usize,
    /// 跳过的条目。
    pub skipped: Vec<SkippedEntry>,
    /// 所有文件内容的总字节数(即容器载荷的明文长度)。
    pub total_size: u64,
}

#[derive(Debug)]
enum PlanItem {
    Dir { comps: Vec<String>, meta: EntryMeta },
    Link { comps: Vec<String>, target: String, meta: EntryMeta },
    File { path: PathBuf, comps: Vec<String>, size: u64, meta: EntryMeta },
}

/// 规划流式打包。遍历判定与 [`pack_folder`] 共用 [`walk_dir`]。
///
/// 打不开的文件(常见原因:被别的程序独占)在这一步就跳过并记录,与
/// `pack_folder` 的行为一致。
///
/// # Errors
///
/// 遍历出错时返回。
pub fn plan_folder(root: &Path) -> Result<PackPlan> {
    let root_name = root
        .file_name()
        .map(|s| s.to_string_lossy().into_owned())
        .unwrap_or_else(|| String::from("folder"));
    let mut items = Vec::new();
    let mut skipped = Vec::new();
    let mut unreadable: Vec<String> = Vec::new();
    let (mut files, mut dirs, mut links) = (0usize, 0usize, 0usize);
    let mut total = 0u64;
    walk_dir(root, &mut skipped, &mut |item| {
        if let Some(target) = &item.link_target {
            items.push(PlanItem::Link {
                comps: item.comps.clone(),
                target: target.clone(),
                meta: item.meta.clone(),
            });
            links = links.saturating_add(1);
        } else if item.is_dir {
            items.push(PlanItem::Dir { comps: item.comps.clone(), meta: item.meta.clone() });
            dirs = dirs.saturating_add(1);
        } else {
            let size = std::fs::File::open(&item.path).and_then(|f| f.metadata()).map(|m| m.len());
            let Ok(size) = size else {
                unreadable.push(item.comps.join("/"));
                return Ok(());
            };
            items.push(PlanItem::File {
                path: item.path.clone(),
                comps: item.comps.clone(),
                size,
                meta: item.meta.clone(),
            });
            files = files.saturating_add(1);
            total = total.saturating_add(size);
        }
        Ok(())
    })?;
    skipped.extend(unreadable.into_iter().map(|path| SkippedEntry {
        path,
        reason: SkipReason::Unreadable,
    }));
    Ok(PackPlan {
        root_name,
        items,
        file_count: files,
        dir_count: dirs,
        link_count: links,
        skipped,
        total_size: total,
    })
}

impl PackPlan {
    fn index_with(&self, hashes: Option<&[[u8; 32]]>) -> Result<ContainerIndex> {
        let mut builder = ContainerBuilder::new(self.root_name.clone());
        let mut fi = 0usize;
        for it in &self.items {
            match it {
                PlanItem::Dir { comps, meta } => builder.add_dir(comps.clone(), meta.clone())?,
                PlanItem::Link { comps, target, meta } => {
                    builder.add_symlink(comps.clone(), target.clone(), meta.clone())?;
                }
                PlanItem::File { comps, size, meta, .. } => {
                    let hash = match hashes {
                        Some(h) => *h.get(fi).ok_or(Error::MalformedHeader {
                            reason: "pack plan hash count mismatch",
                        })?,
                        None => [0u8; 32],
                    };
                    builder.add_file(comps.clone(), *size, Some(hash), meta.clone())?;
                    fi = fi.saturating_add(1);
                }
            }
        }
        builder.finish()
    }

    /// 与最终索引等长的占位索引(哈希全零),用来给流式加密预留头部空间。
    ///
    /// # Errors
    ///
    /// 条目非法时返回。
    pub fn placeholder_index(&self) -> Result<Vec<u8>> {
        Ok(self.index_with(None)?.encode())
    }

    /// 填入逐文件哈希,得到真正的容器索引。`hashes` 来自 [`PackReader::hashes`]。
    ///
    /// # Errors
    ///
    /// 哈希数与文件数不符或条目非法时返回。
    pub fn finish(&self, hashes: &[[u8; 32]]) -> Result<ContainerIndex> {
        if hashes.len() != self.file_count {
            return Err(Error::MalformedHeader { reason: "pack plan hash count mismatch" });
        }
        self.index_with(Some(hashes))
    }

    /// 按规划顺序把文件内容串成一条流。
    #[must_use]
    pub fn reader(&self) -> PackReader {
        let files = self
            .items
            .iter()
            .filter_map(|it| match it {
                PlanItem::File { path, size, .. } => Some((path.clone(), *size)),
                _ => None,
            })
            .collect();
        PackReader {
            files,
            next: 0,
            cur: None,
            hashes: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
        }
    }
}

/// 当前正在读的文件:剩余字节数受限的句柄、还差多少字节、该文件的哈希器。
type CurrentFile = (std::io::Take<std::fs::File>, u64, blake2::Blake2b<blake2::digest::consts::U32>);

/// 把规划里的文件内容按顺序串成一条 `Read` 流,并逐文件计算哈希。
///
/// 每个文件读到的字节数必须与规划时的大小一致:文件在加密途中被改动时报错,
/// 而不是让容器索引的偏移与载荷错位。
pub struct PackReader {
    files: Vec<(PathBuf, u64)>,
    next: usize,
    cur: Option<CurrentFile>,
    hashes: std::rc::Rc<std::cell::RefCell<Vec<[u8; 32]>>>,
}

impl std::fmt::Debug for PackReader {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("PackReader")
            .field("files", &self.files.len())
            .field("next", &self.next)
            .finish_non_exhaustive()
    }
}

impl PackReader {
    /// 已读完的文件的哈希。返回共享句柄:流被加密函数借走期间,定稿回调
    /// 仍需要读到它。
    #[must_use]
    pub fn hashes(&self) -> std::rc::Rc<std::cell::RefCell<Vec<[u8; 32]>>> {
        std::rc::Rc::clone(&self.hashes)
    }
}

impl std::io::Read for PackReader {
    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
        use blake2::Digest;
        loop {
            if self.cur.is_none() {
                let Some((path, size)) = self.files.get(self.next) else {
                    return Ok(0);
                };
                let f = std::fs::File::open(path)?;
                let size = *size;
                self.cur = Some((std::io::Read::take(f, size), size, blake2::Blake2b::new()));
                self.next = self.next.saturating_add(1);
            }
            let Some((take, remaining, hasher)) = self.cur.as_mut() else {
                return Ok(0);
            };
            let n = take.read(buf)?;
            if n > 0 {
                hasher.update(buf.get(..n).unwrap_or(&[]));
                *remaining = remaining.saturating_sub(n as u64);
                return Ok(n);
            }
            // 当前文件读完:必须恰好读满规划时的大小
            if *remaining != 0 {
                return Err(std::io::Error::new(
                    std::io::ErrorKind::UnexpectedEof,
                    "file shrank while packing",
                ));
            }
            if let Some((take, _, hasher)) = self.cur.take() {
                let mut f = take.into_inner();
                let mut probe = [0u8; 1];
                if f.read(&mut probe)? != 0 {
                    return Err(std::io::Error::new(
                        std::io::ErrorKind::InvalidData,
                        "file grew while packing",
                    ));
                }
                self.hashes.borrow_mut().push(hasher.finalize().into());
            }
        }
    }
}

/// 从文件系统元数据提取可移植的元数据。
fn meta_from_fs(md: &std::fs::Metadata) -> EntryMeta {
    let mut m = EntryMeta::default();
    if let Ok(t) = md.modified() {
        m.mtime_ns = system_time_to_ns(t);
    }
    if let Ok(t) = md.created() {
        m.btime_ns = system_time_to_ns(t);
    }
    #[cfg(unix)]
    {
        use std::os::unix::fs::MetadataExt;
        m.mode = Some(md.mode());
        m.uid = Some(md.uid());
        m.gid = Some(md.gid());
    }
    m
}

/// `SystemTime` 转 Unix 纳秒。
///
/// 1970 年之前的时间戳会得到 `Err`,此时返回 `None` 而不是负数或 0——
/// 假装知道一个不知道的值,比承认不知道更糟。
fn system_time_to_ns(t: std::time::SystemTime) -> Option<i128> {
    t.duration_since(std::time::UNIX_EPOCH)
        .ok()
        .and_then(|d| i128::try_from(d.as_nanos()).ok())
}

#[cfg(test)]
mod tests {
    use super::*;

    /// 造一棵测试目录树,返回根路径。
    fn make_tree(tag: &str) -> PathBuf {
        let root = std::env::temp_dir().join(format!("omy-pack-{tag}"));
        let _ = std::fs::remove_dir_all(&root);
        std::fs::create_dir_all(root.join("sub/deeper")).unwrap();
        std::fs::write(root.join("a.txt"), b"aaa").unwrap();
        std::fs::write(root.join("b.txt"), b"bbbb").unwrap();
        std::fs::write(root.join("sub/c.txt"), b"cc").unwrap();
        std::fs::write(root.join("sub/deeper/d.txt"), b"d").unwrap();
        root
    }

    #[test]
    fn packs_all_files_and_dirs() {
        let root = make_tree("basic");
        let p = pack_folder(&root, None).unwrap();

        assert_eq!(p.file_count, 4, "四个文件都要打进去");
        assert_eq!(p.dir_count, 2, "sub 与 sub/deeper");
        assert_eq!(p.payload.len(), 3 + 4 + 2 + 1, "载荷是所有文件内容拼接");
        assert_eq!(p.root_name, "omy-pack-basic");
        assert!(p.skipped.is_empty(), "正常目录不该有跳过项");

        let _ = std::fs::remove_dir_all(&root);
    }

    #[test]
    fn payload_ranges_match_index() {
        // 这条守护容器格式的核心不变量:索引里记的区间必须真的
        // 对应载荷里那段字节。错了的话解出来的文件内容会串位——
        // 而且往往还是「能解开、内容是别的文件」这种最难查的形态
        let root = make_tree("ranges");
        let p = pack_folder(&root, None).unwrap();

        for e in &p.index.entries {
            let Some((start, len)) = e.range() else {
                continue; // 只有文件有区间
            };
            let s = usize::try_from(start).unwrap();
            let end = s + usize::try_from(len).unwrap();
            let slice = p.payload.get(s..end).expect("区间必须落在载荷内");
            assert_eq!(slice.len() as u64, len);

            // 内容要与磁盘上的原文件一致
            let disk = std::fs::read(root.join(e.path.join("/"))).unwrap();
            assert_eq!(slice, disk.as_slice(), "{} 的内容对不上", e.display_path());
        }

        let _ = std::fs::remove_dir_all(&root);
    }

    #[test]
    fn ordering_is_byte_order_not_filesystem_order() {
        // 变异测试抓出来的缺口:`ordering_is_stable` 只比较两次打包的结果,
        // 而**两次都会拿到同一个文件系统顺序**——把 sort 整行删掉,那条
        // 断言依然通过。它证明的是「确定性」,不是「按字节序排」。
        //
        // 这里改为断言一个与文件系统顺序**不同**的具体顺序:NTFS 大小写
        // 不敏感,返回 `a.txt, Z.txt`;而字节序里 `Z`(0x5A) < `a`(0x61),
        // 应得到 `Z.txt, a.txt`。漏排序时这条立刻失败。
        //
        // 为什么必须按字节序:索引里记的是每个文件在载荷中的区间,跨平台
        // 打开同一个容器时若排序规则不同,区间就对不上。ext4 按字节序、
        // NTFS 按大小写不敏感——只有显式排序才能让两边一致。
        let root = std::env::temp_dir().join("omy-pack-order");
        let _ = std::fs::remove_dir_all(&root);
        std::fs::create_dir_all(&root).unwrap();
        std::fs::write(root.join("a.txt"), b"aaa").unwrap();
        std::fs::write(root.join("Z.txt"), b"z").unwrap();

        let p = pack_folder(&root, None).unwrap();
        let names: Vec<String> = p
            .index
            .entries
            .iter()
            .filter(|e| e.range().is_some())
            .map(|e| e.display_path())
            .collect();
        assert_eq!(
            names,
            vec!["Z.txt", "a.txt"],
            "必须按字节序排列(大写 Z 在小写 a 之前),而不是沿用文件系统顺序"
        );
        // 载荷的拼接顺序也要跟着:Z 的内容在前
        assert_eq!(p.payload, b"zaaa", "载荷拼接顺序要与索引一致");

        let _ = std::fs::remove_dir_all(&root);
    }

    #[test]
    fn ordering_is_stable() {
        // 同一个目录打包两次必须得到完全相同的载荷。
        // read_dir 的顺序由文件系统决定,不排序的话这条不成立
        let root = make_tree("stable");
        let a = pack_folder(&root, None).unwrap();
        let b = pack_folder(&root, None).unwrap();
        assert_eq!(a.payload, b.payload, "两次打包的载荷必须一致");
        assert_eq!(a.index.encode(), b.index.encode(), "索引也必须一致");
        let _ = std::fs::remove_dir_all(&root);
    }

    #[test]
    fn empty_dir_is_packed_without_files() {
        let root = std::env::temp_dir().join("omy-pack-empty");
        let _ = std::fs::remove_dir_all(&root);
        std::fs::create_dir_all(root.join("nothing")).unwrap();

        let p = pack_folder(&root, None).unwrap();
        assert_eq!(p.file_count, 0);
        assert_eq!(p.dir_count, 1, "空目录本身也要记进索引");
        assert!(p.payload.is_empty());

        let _ = std::fs::remove_dir_all(&root);
    }

    #[test]
    fn rejects_non_directory() {
        let f = std::env::temp_dir().join("omy-pack-not-a-dir.txt");
        std::fs::write(&f, b"x").unwrap();
        assert!(pack_folder(&f, None).is_err(), "对文件调用必须报错");
        let _ = std::fs::remove_file(&f);
    }

    #[test]
    fn progress_reports_every_file() {
        let root = make_tree("progress");
        let mut seen = Vec::new();
        let mut cb = |name: &str, size: u64| seen.push((name.to_owned(), size));
        let p = pack_folder(&root, Some(&mut cb)).unwrap();

        assert_eq!(seen.len(), p.file_count, "每个文件都要回调一次");
        let total: u64 = seen.iter().map(|(_, s)| s).sum();
        assert_eq!(total as usize, p.payload.len(), "回调报的大小之和要等于载荷");

        let _ = std::fs::remove_dir_all(&root);
    }

    #[cfg(unix)]
    #[test]
    fn symlinks_are_stored_not_followed() {
        // 链接要被**存下来**(容器索引记得住目标),但绝不能被跟随:
        // 跟随会导致环状链接死循环,以及把目标内容当成链接自身的内容
        // 打包进去——后者最危险,因为解出来内容看着是对的,只是类型
        // 从链接变成了普通文件,不比对类型根本发现不了
        let root = std::env::temp_dir().join("omy-pack-symlink");
        let _ = std::fs::remove_dir_all(&root);
        std::fs::create_dir_all(&root).unwrap();
        std::fs::write(root.join("real.txt"), b"real").unwrap();
        std::os::unix::fs::symlink("real.txt", root.join("link.txt")).unwrap();

        let p = pack_folder(&root, None).unwrap();
        assert_eq!(p.file_count, 1, "链接不算文件,只有 real.txt 是");
        assert_eq!(p.link_count, 1, "链接要计数");
        assert!(p.skipped.is_empty(), "不再跳过,实际 {:?}", p.skipped);

        // 载荷里只有 real.txt 的内容。链接不占载荷——如果被跟随了,
        // 这里会变成 8 字节("real" 出现两次)
        assert_eq!(p.payload, b"real", "链接不该往载荷里加内容");

        let link = p
            .index
            .entries
            .iter()
            .find(|e| e.path == ["link.txt"])
            .expect("索引里要有链接条目");
        assert_eq!(link.kind, crate::container::EntryKind::Symlink);
        assert_eq!(
            link.target.as_deref(),
            Some("real.txt"),
            "目标要原样存下来,否则还原出的链接指向错误位置"
        );
        assert_eq!(link.size, 0, "链接不占载荷区间");

        let _ = std::fs::remove_dir_all(&root);
    }

    #[cfg(windows)]
    #[test]
    fn directory_junctions_are_stored_not_followed() {
        // Windows 上的补位测试。上面那条是 `#[cfg(unix)]`,于是「改用
        // metadata 会跟随链接」这个缺陷在 Windows 开发机上**压根没有
        // 测试覆盖**——变异测试把这个盲区暴露了出来。
        //
        // 创建符号链接在 Windows 上需要特权(实测 WinError 1314),但
        // **目录联接(junction)不需要**,而 Rust 同样把它报成 symlink。
        // 于是不必提权就能覆盖这条路径。
        let root = std::env::temp_dir().join("omy-pack-junction");
        let _ = std::fs::remove_dir_all(&root);
        std::fs::create_dir_all(root.join("real")).unwrap();
        std::fs::write(root.join("real/inside.txt"), b"content").unwrap();

        let link = root.join("link");
        let st = std::process::Command::new("cmd")
            .args(["/c", "mklink", "/J"])
            .arg(&link)
            .arg(root.join("real"))
            .output();
        // 建不出来就跳过:不同环境的策略可能不同,测试不该因此变红
        let Ok(o) = st else { return };
        if !o.status.success() || !link.exists() {
            return;
        }

        let p = pack_folder(&root, None).unwrap();
        assert_eq!(
            p.file_count, 1,
            "只有 real/inside.txt 该被打包;联接被跟随的话会变成 2"
        );
        assert_eq!(p.link_count, 1, "联接要作为链接存下来");
        assert_eq!(
            p.payload, b"content",
            "载荷里只该有一份内容;被跟随会变成两份"
        );

        let e = p
            .index
            .entries
            .iter()
            .find(|e| e.path == ["link"])
            .expect("索引里要有联接条目");
        assert_eq!(e.kind, crate::container::EntryKind::Symlink);
        assert!(
            e.target.as_deref().is_some_and(|t| !t.is_empty()),
            "联接的目标不能是空串,否则还原时无从下手"
        );

        let _ = std::fs::remove_dir_all(&root);
    }

    #[test]
    fn skip_reason_codes_are_stable() {
        // 这些字符串是翻译键与 JSON 字段,改了会破坏前端与脚本
        assert_eq!(SkipReason::Symlink.code(), "symlink");
        assert_eq!(SkipReason::NotRegular.code(), "not_regular");
        assert_eq!(SkipReason::Unreadable.code(), "unreadable");
        assert_eq!(SkipReason::TooDeep.code(), "too_deep");
    }
}