1use crate::error::{Error, Result};
20use crate::tlv::types::FOLDER_INDEX;
21use std::collections::BTreeMap;
22
23const fn bad(reason: &'static str) -> Error {
29 Error::MalformedTlv {
30 tlv_type: FOLDER_INDEX,
31 reason,
32 }
33}
34
35pub const INDEX_VERSION: u16 = 1;
37
38pub const RECOMMENDED_MAX_ENTRIES: usize = 50_000;
40
41pub const MAX_COMPONENT_LEN: usize = 255;
43
44pub const MAX_INDEX_SIZE: usize = 64 * 1024 * 1024;
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum EntryKind {
50 File,
52 Dir,
54 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
79pub struct EntryMeta {
80 pub mtime_ns: Option<i128>,
82 pub btime_ns: Option<i128>,
84 pub mode: Option<u32>,
86 pub uid: Option<u32>,
88 pub gid: Option<u32>,
90 pub xattrs: BTreeMap<String, Vec<u8>>,
92}
93
94impl EntryMeta {
95 #[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#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct ContainerEntry {
110 pub path: Vec<String>,
113 pub kind: EntryKind,
115 pub offset: u64,
117 pub size: u64,
119 pub hash: Option<[u8; 32]>,
121 pub target: Option<String>,
123 pub meta: EntryMeta,
125}
126
127impl ContainerEntry {
128 #[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 #[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 #[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 #[must_use]
172 pub fn display_path(&self) -> String {
173 self.path.join("/")
174 }
175
176 #[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
187pub 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 {
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 if c.contains(':') {
227 return Err(bad("路径组件含冒号"));
228 }
229 Ok(())
230}
231
232fn truncate_utf8(s: &str, max: usize) -> &str {
237 if s.len() <= max {
238 return s;
239 }
240 let mut end = max;
241 while end > 0 && !s.is_char_boundary(end) {
245 end = end.saturating_sub(1);
246 }
247 s.get(..end).unwrap_or("")
248}
249
250#[derive(Debug, Clone, Default, PartialEq, Eq)]
252pub struct ContainerIndex {
253 pub root: String,
255 pub entries: Vec<ContainerEntry>,
257}
258
259impl ContainerIndex {
260 #[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 #[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 #[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 #[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 #[must_use]
317 pub fn encode(&self) -> Vec<u8> {
318 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 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 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 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 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 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 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 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
519struct 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
572fn 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 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 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#[derive(Debug, Default)]
671pub struct ContainerBuilder {
672 index: ContainerIndex,
673 cursor: u64,
674}
675
676impl ContainerBuilder {
677 #[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 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 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 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 #[must_use]
755 pub const fn payload_len(&self) -> u64 {
756 self.cursor
757 }
758
759 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 for bad in [
845 "C:", "z:", "a.txt:stream", "中文:流", "报告.docx:evil", ":leading", "trailing:", ] {
853 assert!(
854 validate_component(bad).is_err(),
855 "组件 {bad:?} 含冒号,本应被拒绝"
856 );
857 }
858 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 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 assert_eq!(truncate_utf8("中", 1), "");
875 assert_eq!(truncate_utf8("中", 2), "");
876 assert_eq!(truncate_utf8("中", 3), "中");
877 assert_eq!(truncate_utf8("abcdef", 3), "abc");
879 assert_eq!(truncate_utf8("ab", 10), "ab");
881 }
882
883 #[test]
884 fn oversized_root_encodes_without_panic() {
885 let long_root = "工".repeat(30_000); assert!(long_root.len() > u16::MAX as usize);
889 let idx = ContainerIndex::new(long_root);
890 let enc = idx.encode(); 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(); assert!(ContainerIndex::parse(&enc).is_ok());
912 }
913
914 #[test]
915 fn rejects_traversal_in_parsed_data() {
916 let mut idx = ContainerIndex::new("r");
918 idx.entries.push(ContainerEntry::file(vec!["ok".into()], 0, 5));
919 let mut enc = idx.encode();
920 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; 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 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 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; 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}