Skip to main content

omy_core/
container.rs

1//! 目录容器(模式 A):把整个文件夹打包进单个 `.omy`。
2//!
3//! 对应设计文档 [05 号 §2](../../docs/research/05-container-and-sharding.md)
4//! 与决策 D-05。载荷是若干文件字节的顺序拼接,`TLV_FOLDER_INDEX`
5//! 记录每个条目在**明文载荷流**中的偏移与长度。
6//!
7//! # 为什么偏移是明文流偏移
8//!
9//! 与分块 AEAD 的寻址正交:给定 `offset..offset+size`,直接套用
10//! [`payload`](crate::payload) 的区间读取即可,容器内单文件预览
11//! 与单文件模式复用同一套逻辑,只多一层基址。
12//!
13//! # 路径安全
14//!
15//! 索引来自**不可信文件**。若原样拼接落盘,`..` 或绝对路径会造成
16//! 目录穿越写入。[`ContainerIndex::parse`] 在解析阶段即拒绝这类条目,
17//! 而不是留给调用方自觉检查——见 [`validate_component`]。
18
19use crate::error::{Error, Result};
20use crate::tlv::types::FOLDER_INDEX;
21use std::collections::BTreeMap;
22
23/// 构造索引解析错误。
24///
25/// 索引是 `TLV_FOLDER_INDEX` 的内容,复用既有的 `MalformedTlv`:
26/// 错误码 `MALFORMED_TLV` 与退出码 `Corrupted` 的映射都已就位,
27/// 无需为容器新增一套错误分类。
28const fn bad(reason: &'static str) -> Error {
29    Error::MalformedTlv {
30        tlv_type: FOLDER_INDEX,
31        reason,
32    }
33}
34
35/// 索引格式版本。与文件格式版本独立演进。
36pub const INDEX_VERSION: u16 = 1;
37
38/// 建议的条目数上限。超过时上层应提示改用树形模式(文档 §2.4)。
39pub const RECOMMENDED_MAX_ENTRIES: usize = 50_000;
40
41/// 单个路径组件的字节上限。多数文件系统的限制为 255。
42pub const MAX_COMPONENT_LEN: usize = 255;
43
44/// 索引自身的字节上限,防止畸形输入触发大量分配。
45pub const MAX_INDEX_SIZE: usize = 64 * 1024 * 1024;
46
47/// 条目类型。
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum EntryKind {
50    /// 普通文件,占用载荷区间。
51    File,
52    /// 目录。空目录必须显式记录,否则还原后会丢失。
53    Dir,
54    /// 符号链接,`target` 有效。
55    Symlink,
56}
57
58impl EntryKind {
59    const fn as_u8(self) -> u8 {
60        match self {
61            Self::File => 0,
62            Self::Dir => 1,
63            Self::Symlink => 2,
64        }
65    }
66
67    const fn from_u8(v: u8) -> Option<Self> {
68        match v {
69            0 => Some(Self::File),
70            1 => Some(Self::Dir),
71            2 => Some(Self::Symlink),
72            _ => None,
73        }
74    }
75}
76
77/// 跨平台元数据。不支持的项在还原时**明确报告**而非静默丢弃(决策 N3)。
78#[derive(Debug, Clone, Default, PartialEq, Eq)]
79pub struct EntryMeta {
80    /// 修改时间,Unix 纳秒。
81    pub mtime_ns: Option<i128>,
82    /// 创建时间,Unix 纳秒。Linux 上无法设置,还原时跳过并报告。
83    pub btime_ns: Option<i128>,
84    /// POSIX 权限位。Windows 无对应概念,还原时跳过并报告。
85    pub mode: Option<u32>,
86    /// 属主 uid / gid。跨机器无意义,默认不还原,仅记录。
87    pub uid: Option<u32>,
88    /// 见 [`Self::uid`]。
89    pub gid: Option<u32>,
90    /// 扩展属性。Windows 是 ADS,语义不同,跨平台时报告。
91    pub xattrs: BTreeMap<String, Vec<u8>>,
92}
93
94impl EntryMeta {
95    /// 是否完全为空——为空时可省略编码,节省索引体积。
96    #[must_use]
97    pub fn is_empty(&self) -> bool {
98        self.mtime_ns.is_none()
99            && self.btime_ns.is_none()
100            && self.mode.is_none()
101            && self.uid.is_none()
102            && self.gid.is_none()
103            && self.xattrs.is_empty()
104    }
105}
106
107/// 一个容器条目。
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct ContainerEntry {
110    /// 路径组件数组。**不是**拼接好的字符串——数组形式天然规避
111    /// 分隔符差异与路径注入(文档 §2.2)。
112    pub path: Vec<String>,
113    /// 条目类型。
114    pub kind: EntryKind,
115    /// 在明文载荷流中的起始偏移。仅 `File` 有意义。
116    pub offset: u64,
117    /// 字节长度。仅 `File` 有意义。
118    pub size: u64,
119    /// 内容哈希 BLAKE2b-256,用于逐文件完整性校验。
120    pub hash: Option<[u8; 32]>,
121    /// 符号链接目标。仅 `Symlink` 有意义。
122    pub target: Option<String>,
123    /// 元数据。
124    pub meta: EntryMeta,
125}
126
127impl ContainerEntry {
128    /// 构造一个文件条目。
129    #[must_use]
130    pub fn file(path: Vec<String>, offset: u64, size: u64) -> Self {
131        Self {
132            path,
133            kind: EntryKind::File,
134            offset,
135            size,
136            hash: None,
137            target: None,
138            meta: EntryMeta::default(),
139        }
140    }
141
142    /// 构造一个目录条目。
143    #[must_use]
144    pub fn dir(path: Vec<String>) -> Self {
145        Self {
146            path,
147            kind: EntryKind::Dir,
148            offset: 0,
149            size: 0,
150            hash: None,
151            target: None,
152            meta: EntryMeta::default(),
153        }
154    }
155
156    /// 构造一个符号链接条目。
157    #[must_use]
158    pub fn symlink(path: Vec<String>, target: String) -> Self {
159        Self {
160            path,
161            kind: EntryKind::Symlink,
162            offset: 0,
163            size: 0,
164            hash: None,
165            target: Some(target),
166            meta: EntryMeta::default(),
167        }
168    }
169
170    /// 用 `/` 连接的显示用路径。**不可**直接用于落盘。
171    #[must_use]
172    pub fn display_path(&self) -> String {
173        self.path.join("/")
174    }
175
176    /// 载荷区间。仅文件有效。
177    #[must_use]
178    pub fn range(&self) -> Option<(u64, u64)> {
179        if self.kind == EntryKind::File {
180            Some((self.offset, self.size))
181        } else {
182            None
183        }
184    }
185}
186
187/// 校验单个路径组件是否可安全落盘。
188///
189/// 拒绝空串、`.`、`..`、含分隔符或 NUL 的组件,以及超长组件。
190/// 这些若被放过,解压时会写到目标目录之外。
191///
192/// # Errors
193///
194/// 组件不安全时返回 [`bad`]。
195pub fn validate_component(c: &str) -> Result<()> {
196    if c.is_empty() {
197        return Err(bad("路径组件为空"));
198    }
199    if c == "." || c == ".." {
200        return Err(bad("路径组件为 . 或 ..,可能造成目录穿越"));
201    }
202    if c.len() > MAX_COMPONENT_LEN {
203        return Err(bad("路径组件超过 255 字节"));
204    }
205    if c.contains('/') || c.contains('\\') {
206        return Err(bad("路径组件含分隔符"));
207    }
208    if c.contains('\0') {
209        return Err(bad("路径组件含 NUL 字节"));
210    }
211    // Windows 盘符形式,如 "C:";放过会写到其它盘。
212    //
213    // 用字符而非字节下标判断:多字节 UTF-8 下 as_bytes()[1] 可能落在
214    // 某个字符的中间,既判不准也没有意义。
215    {
216        let mut ch = c.chars();
217        if let (Some(a), Some(b)) = (ch.next(), ch.next())
218            && a.is_ascii_alphabetic()
219            && b == ':'
220        {
221            return Err(bad("路径组件形如盘符"));
222        }
223    }
224    // 任何位置的冒号在 Windows 上都会被当作 NTFS 数据流分隔符
225    // (如 "a.txt:hidden"),写入会产生意料之外的隐藏流。
226    if c.contains(':') {
227        return Err(bad("路径组件含冒号"));
228    }
229    Ok(())
230}
231
232/// 把字符串安全截断到不超过 `max` 字节,且不切断 UTF-8 字符。
233///
234/// 直接 `&s[..max]` 在多字节字符中间会 **panic**——这不是理论风险:
235/// 中文文件名每字符 3 字节,任何不对齐的截断点都会命中。
236fn truncate_utf8(s: &str, max: usize) -> &str {
237    if s.len() <= max {
238        return s;
239    }
240    let mut end = max;
241    // 回退到字符边界;最坏回退 3 字节。
242    // 用 saturating_sub 而非 -=:core 禁止任何可能回绕的算术,
243    // 即便此处有 end > 0 守卫也不破例,以免后续改动引入缺陷。
244    while end > 0 && !s.is_char_boundary(end) {
245        end = end.saturating_sub(1);
246    }
247    s.get(..end).unwrap_or("")
248}
249
250/// 容器目录索引。
251#[derive(Debug, Clone, Default, PartialEq, Eq)]
252pub struct ContainerIndex {
253    /// 原始根目录名,仅用于显示。
254    pub root: String,
255    /// 全部条目。
256    pub entries: Vec<ContainerEntry>,
257}
258
259impl ContainerIndex {
260    /// 新建空索引。
261    #[must_use]
262    pub fn new(root: impl Into<String>) -> Self {
263        Self {
264            root: root.into(),
265            entries: Vec::new(),
266        }
267    }
268
269    /// 文件条目总字节数——即容器明文载荷的长度。
270    #[must_use]
271    pub fn total_size(&self) -> u64 {
272        self.entries
273            .iter()
274            .filter(|e| e.kind == EntryKind::File)
275            .map(|e| e.size)
276            .sum()
277    }
278
279    /// 文件条目数量。
280    #[must_use]
281    pub fn file_count(&self) -> usize {
282        self.entries
283            .iter()
284            .filter(|e| e.kind == EntryKind::File)
285            .count()
286    }
287
288    /// 按显示路径查找条目。
289    #[must_use]
290    pub fn find(&self, display_path: &str) -> Option<&ContainerEntry> {
291        self.entries.iter().find(|e| e.display_path() == display_path)
292    }
293
294    /// 编码为 `TLV_FOLDER_INDEX` 的明文内容。
295    ///
296    /// 自定义紧凑二进制格式而非 JSON:索引可能上到 10 MB 量级,
297    /// 且要在移动端解析,避免文本格式的体积与解析开销。
298    ///
299    /// 布局:
300    /// ```text
301    /// u16  version
302    /// u16  root_len       root 的 UTF-8 字节数
303    /// u32  entry_count
304    /// ...  root 字节
305    /// 每个条目:
306    ///   u8   kind
307    ///   u8   flags        bit0=有 hash, bit1=有 target, bit2=有 meta
308    ///   u16  component_count
309    ///   u64  offset
310    ///   u64  size
311    ///   每个组件: u16 len + 字节
312    ///   [hash 32B]
313    ///   [u16 target_len + 字节]
314    ///   [meta 块]
315    /// ```
316    #[must_use]
317    pub fn encode(&self) -> Vec<u8> {
318        // root 超长时截断:它只用于显示,不参与落盘路径。
319        // 必须按 UTF-8 边界截断,否则多字节字符会被切断。
320        let root_str = truncate_utf8(&self.root, u16::MAX as usize);
321        let root = root_str.as_bytes();
322        let mut out = Vec::with_capacity(64usize.saturating_add(self.entries.len().saturating_mul(48)));
323
324        out.extend_from_slice(&INDEX_VERSION.to_le_bytes());
325        let root_len = u16::try_from(root.len()).unwrap_or(u16::MAX);
326        out.extend_from_slice(&root_len.to_le_bytes());
327        let count = u32::try_from(self.entries.len()).unwrap_or(u32::MAX);
328        out.extend_from_slice(&count.to_le_bytes());
329        out.extend_from_slice(root);
330
331        for e in &self.entries {
332            let mut flags = 0u8;
333            if e.hash.is_some() {
334                flags |= 1 << 0;
335            }
336            if e.target.is_some() {
337                flags |= 1 << 1;
338            }
339            if !e.meta.is_empty() {
340                flags |= 1 << 2;
341            }
342
343            out.push(e.kind.as_u8());
344            out.push(flags);
345            let ncomp = u16::try_from(e.path.len()).unwrap_or(u16::MAX);
346            out.extend_from_slice(&ncomp.to_le_bytes());
347            out.extend_from_slice(&e.offset.to_le_bytes());
348            out.extend_from_slice(&e.size.to_le_bytes());
349
350            for c in e.path.iter().take(ncomp as usize) {
351                // 组件已在 add_* 时校验 ≤255 字节,此处截断只是防御性的
352                let cs = truncate_utf8(c, u16::MAX as usize);
353                let b = cs.as_bytes();
354                let l = u16::try_from(b.len()).unwrap_or(u16::MAX);
355                out.extend_from_slice(&l.to_le_bytes());
356                out.extend_from_slice(b);
357            }
358
359            if let Some(h) = &e.hash {
360                out.extend_from_slice(h);
361            }
362            if let Some(t) = &e.target {
363                let ts = truncate_utf8(t, u16::MAX as usize);
364                let b = ts.as_bytes();
365                let l = u16::try_from(b.len()).unwrap_or(u16::MAX);
366                out.extend_from_slice(&l.to_le_bytes());
367                out.extend_from_slice(b);
368            }
369            if !e.meta.is_empty() {
370                encode_meta(&e.meta, &mut out);
371            }
372        }
373
374        out
375    }
376
377    /// 从 `TLV_FOLDER_INDEX` 的明文内容解析。
378    ///
379    /// 同时完成路径安全校验与载荷区间自洽性检查——**不把这些留给调用方**。
380    ///
381    /// # Errors
382    ///
383    /// 输入畸形、路径不安全、区间越界或声明长度与实际不符时返回
384    /// [`bad`]。
385    pub fn parse(data: &[u8]) -> Result<Self> {
386        if data.len() > MAX_INDEX_SIZE {
387            return Err(bad("索引超过 64 MiB 上限"));
388        }
389        let mut p = Cursor::new(data);
390
391        let version = p.u16()?;
392        if version != INDEX_VERSION {
393            return Err(bad("索引版本不支持"));
394        }
395        let root_len = p.u16()? as usize;
396        let count = p.u32()? as usize;
397
398        // 每个条目至少 20 字节(kind+flags+ncomp+offset+size)。
399        // 先据此拒绝明显不可能的 count,避免按声明值预分配大量内存。
400        let remaining = data.len().saturating_sub(8).saturating_sub(root_len);
401        if count.saturating_mul(20) > remaining {
402            return Err(bad("条目数与数据长度不符"));
403        }
404
405        let root = String::from_utf8(p.take(root_len)?.to_vec())
406            .map_err(|_| bad("root 不是合法 UTF-8"))?;
407
408        let mut entries = Vec::with_capacity(count);
409        for _ in 0..count {
410            let kind = EntryKind::from_u8(p.u8()?)
411                .ok_or(bad("未知的条目类型"))?;
412            let flags = p.u8()?;
413            let ncomp = p.u16()? as usize;
414            let offset = p.u64()?;
415            let size = p.u64()?;
416
417            if ncomp == 0 {
418                return Err(bad("条目路径为空"));
419            }
420            let mut path = Vec::with_capacity(ncomp.min(64));
421            for _ in 0..ncomp {
422                let l = p.u16()? as usize;
423                let c = String::from_utf8(p.take(l)?.to_vec())
424                    .map_err(|_| bad("路径组件不是合法 UTF-8"))?;
425                validate_component(&c)?;
426                path.push(c);
427            }
428
429            let hash = if flags & (1 << 0) != 0 {
430                let h: [u8; 32] = p
431                    .take(32)?
432                    .try_into()
433                    .map_err(|_| bad("hash 长度不符"))?;
434                Some(h)
435            } else {
436                None
437            };
438
439            let target = if flags & (1 << 1) != 0 {
440                let l = p.u16()? as usize;
441                Some(
442                    String::from_utf8(p.take(l)?.to_vec())
443                        .map_err(|_| bad("链接目标不是合法 UTF-8"))?,
444                )
445            } else {
446                None
447            };
448
449            let meta = if flags & (1 << 2) != 0 {
450                decode_meta(&mut p)?
451            } else {
452                EntryMeta::default()
453            };
454
455            // 非文件条目不得声明载荷区间,否则可用来伪造重叠区间
456            if kind != EntryKind::File && (offset != 0 || size != 0) {
457                return Err(bad("非文件条目不应声明载荷区间"));
458            }
459            if kind == EntryKind::Symlink && target.is_none() {
460                return Err(bad("符号链接缺少目标"));
461            }
462            // offset + size 溢出检查
463            if kind == EntryKind::File && offset.checked_add(size).is_none() {
464                return Err(bad("载荷区间溢出"));
465            }
466
467            entries.push(ContainerEntry {
468                path,
469                kind,
470                offset,
471                size,
472                hash,
473                target,
474                meta,
475            });
476        }
477
478        let idx = Self { root, entries };
479        idx.validate_layout()?;
480        Ok(idx)
481    }
482
483    /// 校验文件条目的载荷区间首尾相接、无重叠、无空洞。
484    ///
485    /// 容器载荷是顺序拼接,任何重叠都意味着索引被篡改或实现有误。
486    ///
487    /// # Errors
488    ///
489    /// 区间不连续或路径重复时返回 [`bad`]。
490    pub fn validate_layout(&self) -> Result<()> {
491        let mut files: Vec<&ContainerEntry> = self
492            .entries
493            .iter()
494            .filter(|e| e.kind == EntryKind::File)
495            .collect();
496        files.sort_by_key(|e| e.offset);
497
498        let mut expect = 0u64;
499        for f in files {
500            if f.offset != expect {
501                return Err(bad("文件区间不连续或存在重叠"));
502            }
503            expect = expect
504                .checked_add(f.size)
505                .ok_or(bad("载荷区间溢出"))?;
506        }
507
508        // 路径去重:同名条目会导致还原时相互覆盖
509        let mut seen = std::collections::HashSet::with_capacity(self.entries.len());
510        for e in &self.entries {
511            if !seen.insert(e.display_path()) {
512                return Err(bad("存在重复路径"));
513            }
514        }
515        Ok(())
516    }
517}
518
519/// 顺序读取游标。所有取值都做边界检查,绝不 panic。
520struct Cursor<'a> {
521    data: &'a [u8],
522    pos: usize,
523}
524
525impl<'a> Cursor<'a> {
526    const fn new(data: &'a [u8]) -> Self {
527        Self { data, pos: 0 }
528    }
529
530    fn take(&mut self, n: usize) -> Result<&'a [u8]> {
531        let end = self
532            .pos
533            .checked_add(n)
534            .ok_or(bad("索引偏移溢出"))?;
535        let s = self
536            .data
537            .get(self.pos..end)
538            .ok_or(bad("索引数据意外结束"))?;
539        self.pos = end;
540        Ok(s)
541    }
542
543    fn u8(&mut self) -> Result<u8> {
544        self.take(1)?.first().copied().ok_or(bad("读取 u8 失败"))
545    }
546
547    fn u16(&mut self) -> Result<u16> {
548        let b: [u8; 2] = self
549            .take(2)?
550            .try_into()
551            .map_err(|_| bad("读取 u16 失败"))?;
552        Ok(u16::from_le_bytes(b))
553    }
554
555    fn u32(&mut self) -> Result<u32> {
556        let b: [u8; 4] = self
557            .take(4)?
558            .try_into()
559            .map_err(|_| bad("读取 u32 失败"))?;
560        Ok(u32::from_le_bytes(b))
561    }
562
563    fn u64(&mut self) -> Result<u64> {
564        let b: [u8; 8] = self
565            .take(8)?
566            .try_into()
567            .map_err(|_| bad("读取 u64 失败"))?;
568        Ok(u64::from_le_bytes(b))
569    }
570}
571
572/// 元数据块:`u8 flags` + 各字段 + xattr 表。
573fn encode_meta(m: &EntryMeta, out: &mut Vec<u8>) {
574    let mut flags = 0u8;
575    if m.mtime_ns.is_some() {
576        flags |= 1 << 0;
577    }
578    if m.btime_ns.is_some() {
579        flags |= 1 << 1;
580    }
581    if m.mode.is_some() {
582        flags |= 1 << 2;
583    }
584    if m.uid.is_some() || m.gid.is_some() {
585        flags |= 1 << 3;
586    }
587    if !m.xattrs.is_empty() {
588        flags |= 1 << 4;
589    }
590    out.push(flags);
591
592    if let Some(t) = m.mtime_ns {
593        out.extend_from_slice(&t.to_le_bytes());
594    }
595    if let Some(t) = m.btime_ns {
596        out.extend_from_slice(&t.to_le_bytes());
597    }
598    if let Some(v) = m.mode {
599        out.extend_from_slice(&v.to_le_bytes());
600    }
601    if m.uid.is_some() || m.gid.is_some() {
602        out.extend_from_slice(&m.uid.unwrap_or(0).to_le_bytes());
603        out.extend_from_slice(&m.gid.unwrap_or(0).to_le_bytes());
604    }
605    if !m.xattrs.is_empty() {
606        let n = u16::try_from(m.xattrs.len()).unwrap_or(u16::MAX);
607        out.extend_from_slice(&n.to_le_bytes());
608        for (k, v) in m.xattrs.iter().take(n as usize) {
609            // key 按 UTF-8 边界截断;value 是裸字节,按字节截断即可
610            let ks = truncate_utf8(k, u16::MAX as usize);
611            let kb = ks.as_bytes();
612            let kl = u16::try_from(kb.len()).unwrap_or(u16::MAX);
613            out.extend_from_slice(&kl.to_le_bytes());
614            out.extend_from_slice(kb);
615
616            let vl = u32::try_from(v.len()).unwrap_or(u32::MAX);
617            out.extend_from_slice(&vl.to_le_bytes());
618            let vend = (vl as usize).min(v.len());
619            out.extend_from_slice(v.get(..vend).unwrap_or(&[]));
620        }
621    }
622}
623
624fn decode_meta(p: &mut Cursor<'_>) -> Result<EntryMeta> {
625    let flags = p.u8()?;
626    let mut m = EntryMeta::default();
627
628    if flags & (1 << 0) != 0 {
629        let b: [u8; 16] = p
630            .take(16)?
631            .try_into()
632            .map_err(|_| bad("mtime 长度不符"))?;
633        m.mtime_ns = Some(i128::from_le_bytes(b));
634    }
635    if flags & (1 << 1) != 0 {
636        let b: [u8; 16] = p
637            .take(16)?
638            .try_into()
639            .map_err(|_| bad("btime 长度不符"))?;
640        m.btime_ns = Some(i128::from_le_bytes(b));
641    }
642    if flags & (1 << 2) != 0 {
643        m.mode = Some(p.u32()?);
644    }
645    if flags & (1 << 3) != 0 {
646        m.uid = Some(p.u32()?);
647        m.gid = Some(p.u32()?);
648    }
649    if flags & (1 << 4) != 0 {
650        let n = p.u16()? as usize;
651        // xattr 项至少 6 字节,先做粗略下界检查
652        for _ in 0..n {
653            let kl = p.u16()? as usize;
654            let k = String::from_utf8(p.take(kl)?.to_vec())
655                .map_err(|_| bad("xattr 键不是合法 UTF-8"))?;
656            let vl = p.u32()? as usize;
657            if vl > MAX_INDEX_SIZE {
658                return Err(bad("xattr 值过大"));
659            }
660            let v = p.take(vl)?.to_vec();
661            m.xattrs.insert(k, v);
662        }
663    }
664    Ok(m)
665}
666
667/// 索引构建器:顺序添加条目,自动累加载荷偏移。
668///
669/// 手工维护 `offset` 极易出错,且错了要到解压时才暴露。
670#[derive(Debug, Default)]
671pub struct ContainerBuilder {
672    index: ContainerIndex,
673    cursor: u64,
674}
675
676impl ContainerBuilder {
677    /// 新建构建器。
678    #[must_use]
679    pub fn new(root: impl Into<String>) -> Self {
680        Self {
681            index: ContainerIndex::new(root),
682            cursor: 0,
683        }
684    }
685
686    /// 添加一个文件,偏移自动取当前游标位置。
687    ///
688    /// # Errors
689    ///
690    /// 路径组件不安全,或累计偏移溢出时返回错误。
691    pub fn add_file(
692        &mut self,
693        path: Vec<String>,
694        size: u64,
695        hash: Option<[u8; 32]>,
696        meta: EntryMeta,
697    ) -> Result<()> {
698        for c in &path {
699            validate_component(c)?;
700        }
701        let offset = self.cursor;
702        self.cursor = self
703            .cursor
704            .checked_add(size)
705            .ok_or(bad("容器载荷总长溢出"))?;
706        self.index.entries.push(ContainerEntry {
707            path,
708            kind: EntryKind::File,
709            offset,
710            size,
711            hash,
712            target: None,
713            meta,
714        });
715        Ok(())
716    }
717
718    /// 添加一个目录。
719    ///
720    /// # Errors
721    ///
722    /// 路径组件不安全时返回错误。
723    pub fn add_dir(&mut self, path: Vec<String>, meta: EntryMeta) -> Result<()> {
724        for c in &path {
725            validate_component(c)?;
726        }
727        let mut e = ContainerEntry::dir(path);
728        e.meta = meta;
729        self.index.entries.push(e);
730        Ok(())
731    }
732
733    /// 添加一个符号链接。
734    ///
735    /// # Errors
736    ///
737    /// 路径组件不安全时返回错误。
738    pub fn add_symlink(
739        &mut self,
740        path: Vec<String>,
741        target: String,
742        meta: EntryMeta,
743    ) -> Result<()> {
744        for c in &path {
745            validate_component(c)?;
746        }
747        let mut e = ContainerEntry::symlink(path, target);
748        e.meta = meta;
749        self.index.entries.push(e);
750        Ok(())
751    }
752
753    /// 当前累计的明文载荷长度。
754    #[must_use]
755    pub const fn payload_len(&self) -> u64 {
756        self.cursor
757    }
758
759    /// 完成构建。
760    ///
761    /// # Errors
762    ///
763    /// 布局校验失败时返回错误。
764    pub fn finish(self) -> Result<ContainerIndex> {
765        self.index.validate_layout()?;
766        Ok(self.index)
767    }
768}
769
770#[cfg(test)]
771mod tests {
772    use super::*;
773
774    #[test]
775    fn roundtrip_basic() {
776        let mut b = ContainerBuilder::new("工作资料");
777        b.add_dir(vec!["文档".into()], EntryMeta::default()).unwrap();
778        b.add_file(
779            vec!["文档".into(), "季度报告.docx".into()],
780            2451,
781            Some([7u8; 32]),
782            EntryMeta {
783                mtime_ns: Some(1_756_425_600_000_000_000),
784                mode: Some(0o100_644),
785                ..EntryMeta::default()
786            },
787        )
788        .unwrap();
789        b.add_file(vec!["readme.txt".into()], 12, None, EntryMeta::default())
790            .unwrap();
791        b.add_symlink(
792            vec!["链接.txt".into()],
793            "文档/季度报告.docx".into(),
794            EntryMeta::default(),
795        )
796        .unwrap();
797
798        let idx = b.finish().unwrap();
799        assert_eq!(idx.file_count(), 2);
800        assert_eq!(idx.total_size(), 2463);
801
802        let enc = idx.encode();
803        let back = ContainerIndex::parse(&enc).unwrap();
804        assert_eq!(idx, back);
805        assert_eq!(back.root, "工作资料");
806    }
807
808    #[test]
809    fn offsets_are_contiguous() {
810        let mut b = ContainerBuilder::new("r");
811        b.add_file(vec!["a".into()], 100, None, EntryMeta::default())
812            .unwrap();
813        b.add_file(vec!["b".into()], 250, None, EntryMeta::default())
814            .unwrap();
815        b.add_file(vec!["c".into()], 0, None, EntryMeta::default())
816            .unwrap();
817        let idx = b.finish().unwrap();
818        assert_eq!(idx.entries[0].range(), Some((0, 100)));
819        assert_eq!(idx.entries[1].range(), Some((100, 250)));
820        assert_eq!(idx.entries[2].range(), Some((350, 0)));
821        assert_eq!(idx.total_size(), 350);
822    }
823
824    #[test]
825    fn rejects_path_traversal() {
826        for bad in ["..", ".", "", "a/b", "a\\b", "C:", "a\0b"] {
827            assert!(
828                validate_component(bad).is_err(),
829                "组件 {bad:?} 本应被拒绝"
830            );
831        }
832        let mut b = ContainerBuilder::new("r");
833        assert!(
834            b.add_file(vec!["..".into(), "etc".into()], 1, None, EntryMeta::default())
835                .is_err()
836        );
837    }
838
839    #[test]
840    fn rejects_colon_in_any_position() {
841        // Windows 上冒号是 NTFS 数据流分隔符:
842        // 写 "a.txt:hidden" 会创建隐藏的备用数据流而非普通文件。
843        // 早期实现只查第 2 字节,这些都会漏过。
844        for bad in [
845            "C:",              // 盘符
846            "z:",              // 小写盘符
847            "a.txt:stream",    // 数据流,冒号在中间
848            "中文:流",         // 多字节前缀,冒号不在下标 1
849            "报告.docx:evil",  // 真实攻击形态
850            ":leading",        // 冒号开头
851            "trailing:",       // 冒号结尾
852        ] {
853            assert!(
854                validate_component(bad).is_err(),
855                "组件 {bad:?} 含冒号,本应被拒绝"
856            );
857        }
858        // 正常的中文名不受影响
859        assert!(validate_component("季度报告.docx").is_ok());
860        assert!(validate_component("a.txt").is_ok());
861    }
862
863    #[test]
864    fn utf8_truncation_never_panics() {
865        // 直接 &s[..max] 在多字节字符中间会 panic。
866        // 中文每字符 3 字节,任何不对齐的截断点都会命中。
867        let s = "中文字符串测试";
868        for max in 0..=s.len() + 3 {
869            let t = truncate_utf8(s, max);
870            assert!(t.len() <= max.min(s.len()), "截断结果超长");
871            assert!(s.starts_with(t), "截断结果应是原串前缀");
872        }
873        // 边界:恰好落在字符中间
874        assert_eq!(truncate_utf8("中", 1), "");
875        assert_eq!(truncate_utf8("中", 2), "");
876        assert_eq!(truncate_utf8("中", 3), "中");
877        // ASCII 正常截断
878        assert_eq!(truncate_utf8("abcdef", 3), "abc");
879        // 不超长时原样返回
880        assert_eq!(truncate_utf8("ab", 10), "ab");
881    }
882
883    #[test]
884    fn oversized_root_encodes_without_panic() {
885        // root 超过 u16::MAX 字节且是多字节字符:
886        // 早期实现在 65535 处按字节切,会切断字符并 panic。
887        let long_root = "工".repeat(30_000); // 90,000 字节
888        assert!(long_root.len() > u16::MAX as usize);
889        let idx = ContainerIndex::new(long_root);
890        let enc = idx.encode(); // 不得 panic
891        // 编码结果仍可解析,root 被安全截断
892        let back = ContainerIndex::parse(&enc).unwrap();
893        assert!(back.root.len() <= u16::MAX as usize);
894        assert!(back.root.chars().all(|c| c == '工'), "截断处不应出现替换字符");
895    }
896
897    #[test]
898    fn oversized_xattr_value_encodes_without_panic() {
899        let mut b = ContainerBuilder::new("r");
900        b.add_file(
901            vec!["f".into()],
902            0,
903            None,
904            EntryMeta {
905                xattrs: [("键".repeat(100), vec![0u8; 10])].into_iter().collect(),
906                ..EntryMeta::default()
907            },
908        )
909        .unwrap();
910        let enc = b.finish().unwrap().encode(); // 不得 panic
911        assert!(ContainerIndex::parse(&enc).is_ok());
912    }
913
914    #[test]
915    fn rejects_traversal_in_parsed_data() {
916        // 手工构造含 ".." 的索引,模拟被篡改的文件
917        let mut idx = ContainerIndex::new("r");
918        idx.entries.push(ContainerEntry::file(vec!["ok".into()], 0, 5));
919        let mut enc = idx.encode();
920        // 把 "ok" 改成 ".." —— 两者都是 2 字节
921        let pos = enc.windows(2).position(|w| w == b"ok").unwrap();
922        enc[pos] = b'.';
923        enc[pos + 1] = b'.';
924        assert!(ContainerIndex::parse(&enc).is_err());
925    }
926
927    #[test]
928    fn rejects_overlapping_ranges() {
929        let mut idx = ContainerIndex::new("r");
930        idx.entries.push(ContainerEntry::file(vec!["a".into()], 0, 100));
931        idx.entries.push(ContainerEntry::file(vec!["b".into()], 50, 100));
932        assert!(idx.validate_layout().is_err());
933    }
934
935    #[test]
936    fn rejects_gap_between_ranges() {
937        let mut idx = ContainerIndex::new("r");
938        idx.entries.push(ContainerEntry::file(vec!["a".into()], 0, 100));
939        idx.entries.push(ContainerEntry::file(vec!["b".into()], 200, 10));
940        assert!(idx.validate_layout().is_err());
941    }
942
943    #[test]
944    fn rejects_duplicate_paths() {
945        let mut idx = ContainerIndex::new("r");
946        idx.entries.push(ContainerEntry::file(vec!["a".into()], 0, 10));
947        idx.entries.push(ContainerEntry::file(vec!["a".into()], 10, 10));
948        assert!(idx.validate_layout().is_err());
949    }
950
951    #[test]
952    fn rejects_dir_with_payload_range() {
953        let mut idx = ContainerIndex::new("r");
954        let mut d = ContainerEntry::dir(vec!["d".into()]);
955        d.size = 100; // 目录不该占载荷
956        idx.entries.push(d);
957        let enc = idx.encode();
958        assert!(ContainerIndex::parse(&enc).is_err());
959    }
960
961    #[test]
962    fn truncated_input_never_panics() {
963        let mut b = ContainerBuilder::new("root");
964        b.add_file(vec!["f".into()], 10, Some([1u8; 32]), EntryMeta {
965            mtime_ns: Some(1),
966            mode: Some(2),
967            uid: Some(3),
968            gid: Some(4),
969            xattrs: [("k".to_string(), vec![9u8; 4])].into_iter().collect(),
970            btime_ns: Some(5),
971        })
972        .unwrap();
973        let enc = b.finish().unwrap().encode();
974        // 任意截断都必须返回 Err 而非 panic
975        for n in 0..enc.len() {
976            let _ = ContainerIndex::parse(&enc[..n]);
977        }
978    }
979
980    #[test]
981    fn absurd_entry_count_rejected_without_huge_alloc() {
982        // version=1, root_len=0, count=u32::MAX
983        let mut data = Vec::new();
984        data.extend_from_slice(&1u16.to_le_bytes());
985        data.extend_from_slice(&0u16.to_le_bytes());
986        data.extend_from_slice(&u32::MAX.to_le_bytes());
987        assert!(ContainerIndex::parse(&data).is_err());
988    }
989
990    #[test]
991    fn meta_roundtrip_full() {
992        let meta = EntryMeta {
993            mtime_ns: Some(-12_345),
994            btime_ns: Some(i128::from(i64::MAX)),
995            mode: Some(0o777),
996            uid: Some(1000),
997            gid: Some(1000),
998            xattrs: [
999                ("user.comment".to_string(), b"hello".to_vec()),
1000                ("user.empty".to_string(), Vec::new()),
1001            ]
1002            .into_iter()
1003            .collect(),
1004        };
1005        let mut b = ContainerBuilder::new("r");
1006        b.add_file(vec!["f".into()], 1, None, meta.clone()).unwrap();
1007        let enc = b.finish().unwrap().encode();
1008        let back = ContainerIndex::parse(&enc).unwrap();
1009        assert_eq!(back.entries[0].meta, meta);
1010    }
1011
1012    #[test]
1013    fn empty_dir_is_preserved() {
1014        let mut b = ContainerBuilder::new("r");
1015        b.add_dir(vec!["空目录".into()], EntryMeta::default()).unwrap();
1016        let idx = b.finish().unwrap();
1017        assert_eq!(idx.entries.len(), 1);
1018        assert_eq!(idx.file_count(), 0);
1019        let back = ContainerIndex::parse(&idx.encode()).unwrap();
1020        assert_eq!(back.entries[0].kind, EntryKind::Dir);
1021        assert_eq!(back.entries[0].display_path(), "空目录");
1022    }
1023
1024    #[test]
1025    fn find_by_path() {
1026        let mut b = ContainerBuilder::new("r");
1027        b.add_file(vec!["dir".into(), "movie.mp4".into()], 5000, None, EntryMeta::default())
1028            .unwrap();
1029        let idx = b.finish().unwrap();
1030        let e = idx.find("dir/movie.mp4").unwrap();
1031        assert_eq!(e.range(), Some((0, 5000)));
1032        assert!(idx.find("nope").is_none());
1033    }
1034
1035    #[test]
1036    fn symlink_without_target_rejected() {
1037        let mut idx = ContainerIndex::new("r");
1038        let mut s = ContainerEntry::symlink(vec!["l".into()], "t".into());
1039        s.target = None; // 制造缺失
1040        idx.entries.push(s);
1041        let enc = idx.encode();
1042        assert!(ContainerIndex::parse(&enc).is_err());
1043    }
1044
1045    #[test]
1046    fn long_component_rejected() {
1047        let long = "a".repeat(MAX_COMPONENT_LEN + 1);
1048        assert!(validate_component(&long).is_err());
1049        let ok = "a".repeat(MAX_COMPONENT_LEN);
1050        assert!(validate_component(&ok).is_ok());
1051    }
1052}