Skip to main content

onelf_format/
manifest.rs

1use std::collections::HashMap;
2use std::io::{self, Cursor, Read, Write};
3use std::path::PathBuf;
4
5use crate::entry::{ENTRY_HEADER_SIZE, ENTRYPOINT_SIZE, Entry, EntryKind, EntryPoint};
6
7/// Returns true if `name` is a single safe path component: non-empty,
8/// not `.` or `..`, and free of `/` and NUL. Used to reject entry names
9/// that would let a crafted package escape the extraction root.
10pub fn is_safe_component(name: &str) -> bool {
11    !name.is_empty() && name != "." && name != ".." && !name.contains('/') && !name.contains('\0')
12}
13
14/// Lexically check that a symlink placed at `link_rel` (a relative path
15/// under the extraction root) with `target` still resolves inside the
16/// root. Absolute targets and targets that walk above the root are
17/// rejected. Purely lexical, so it cannot be defeated by filesystem
18/// races. Shared by the runtime and the packer's extractor.
19pub fn symlink_target_within_root(link_rel: &std::path::Path, target: &str) -> bool {
20    use std::ffi::OsString;
21    use std::path::Component;
22
23    if target.is_empty() {
24        return false;
25    }
26    let tpath = std::path::Path::new(target);
27    if tpath.is_absolute() {
28        return false;
29    }
30    let mut stack: Vec<OsString> = Vec::new();
31    let mut walk = |path: &std::path::Path| -> bool {
32        for c in path.components() {
33            match c {
34                Component::Normal(s) => stack.push(s.to_os_string()),
35                Component::ParentDir => {
36                    if stack.pop().is_none() {
37                        return false;
38                    }
39                }
40                Component::CurDir => {}
41                _ => return false,
42            }
43        }
44        true
45    };
46    // Begin at the directory containing the symlink, then apply target.
47    let parent_ok = link_rel.parent().map(&mut walk).unwrap_or(true);
48    parent_ok && walk(tpath)
49}
50
51/// Manifest version this build writes.
52///
53/// Version 2 added a BLAKE3 per payload block, so a reader can verify what
54/// it serves without reassembling the whole entry. Version 3 added the two
55/// structure hashes in the header: `prefix_hash` over the runtime ELF the
56/// package prepends, and `footer_hash` over the file's footer. Together
57/// they make the whole container verifiable, which version 1 and 2
58/// manifests lack. Both remain readable; their hash fields read as zeros.
59pub const MANIFEST_VERSION: u16 = 3;
60
61/// Byte range of the `footer_hash` header field inside a serialized
62/// version-3-or-later manifest. The manifest checksum is computed over the
63/// manifest with this range zeroed, because the field is pinned by the
64/// footer hash instead; see [`crate::footer::Footer::structure_hash`].
65pub const FOOTER_HASH_RANGE: std::ops::Range<usize> = 82..114;
66
67/// Manifest header size at [`MANIFEST_VERSION`]: the 50-byte version 1
68/// header plus the two 32-byte structure hashes.
69pub const MANIFEST_HEADER_SIZE: usize = 114;
70
71/// Size of the version 1/2 header, which carried no structure hashes.
72const V1_HEADER_SIZE: usize = 50;
73
74#[derive(Debug, Clone)]
75pub struct ManifestHeader {
76    /// Manifest format version.
77    pub version: u16,
78    /// Total number of filesystem entries in the manifest.
79    pub entry_count: u32,
80    /// Size of the string table in bytes.
81    pub string_table_size: u32,
82    /// Number of entrypoints defined in this package.
83    pub entrypoint_count: u16,
84    /// Index of the default entrypoint to use when none is specified.
85    pub default_entrypoint: u16,
86    /// Number of library directory paths in the manifest.
87    pub lib_dir_count: u16,
88    /// Offset into the string table for the package name.
89    pub name_offset: u16,
90    /// Unique package identifier (BLAKE3 hash).
91    pub package_id: [u8; 32],
92    /// BLAKE3 over the runtime ELF prefix, the file bytes
93    /// `[0, manifest_offset)`. Zero in manifests written before version 3.
94    pub prefix_hash: [u8; 32],
95    /// BLAKE3 over the file's footer with the manifest-derived layout
96    /// fields zeroed, per [`crate::footer::Footer::structure_hash`]. Zero
97    /// in manifests written before version 3.
98    pub footer_hash: [u8; 32],
99}
100
101impl ManifestHeader {
102    pub fn write_to<W: Write>(&self, w: &mut W) -> io::Result<()> {
103        w.write_all(&self.version.to_le_bytes())?;
104        w.write_all(&self.entry_count.to_le_bytes())?;
105        w.write_all(&self.string_table_size.to_le_bytes())?;
106        w.write_all(&self.entrypoint_count.to_le_bytes())?;
107        w.write_all(&self.default_entrypoint.to_le_bytes())?;
108        w.write_all(&self.lib_dir_count.to_le_bytes())?;
109        w.write_all(&self.name_offset.to_le_bytes())?;
110        w.write_all(&self.package_id)?;
111        w.write_all(&self.prefix_hash)?;
112        w.write_all(&self.footer_hash)?;
113        Ok(())
114    }
115
116    pub fn read_from<R: Read>(r: &mut R) -> io::Result<Self> {
117        let mut buf = [0u8; V1_HEADER_SIZE];
118        r.read_exact(&mut buf)?;
119
120        let mut package_id = [0u8; 32];
121        package_id.copy_from_slice(&buf[18..50]);
122
123        let mut header = ManifestHeader {
124            version: u16::from_le_bytes(buf[0..2].try_into().unwrap()),
125            entry_count: u32::from_le_bytes(buf[2..6].try_into().unwrap()),
126            string_table_size: u32::from_le_bytes(buf[6..10].try_into().unwrap()),
127            entrypoint_count: u16::from_le_bytes(buf[10..12].try_into().unwrap()),
128            default_entrypoint: u16::from_le_bytes(buf[12..14].try_into().unwrap()),
129            lib_dir_count: u16::from_le_bytes(buf[14..16].try_into().unwrap()),
130            name_offset: u16::from_le_bytes(buf[16..18].try_into().unwrap()),
131            package_id,
132            prefix_hash: [0; 32],
133            footer_hash: [0; 32],
134        };
135
136        // Version 3 extended the header with the structure hashes. Older
137        // manifests simply have no such fields, which reads as zeros.
138        if header.version >= 3 {
139            let mut hashes = [0u8; 64];
140            r.read_exact(&mut hashes)?;
141            header.prefix_hash.copy_from_slice(&hashes[..32]);
142            header.footer_hash.copy_from_slice(&hashes[32..]);
143        }
144
145        Ok(header)
146    }
147}
148
149/// The xxh32 checksum a footer carries over `manifest`'s uncompressed
150/// bytes: plain xxh32 for version 1/2 manifests, xxh32 with the
151/// `footer_hash` header field zeroed from version 3 on, since that field
152/// is pinned by [`crate::footer::Footer::structure_hash`] instead. The
153/// packer, the runtime loader and `verify` must all agree on this, so it
154/// lives here rather than at each call site.
155///
156/// Fails when `manifest` is too short to hold the header fields the
157/// checksum depends on, which only a corrupt or crafted footer produces.
158pub fn manifest_checksum(manifest: &[u8]) -> io::Result<[u8; 4]> {
159    let truncated = || io::Error::new(io::ErrorKind::InvalidData, "manifest header truncated");
160    let version = u16::from_le_bytes(
161        manifest
162            .get(..2)
163            .ok_or_else(truncated)?
164            .try_into()
165            .expect("u16 version"),
166    );
167    if version < 3 {
168        return Ok(xxhash_rust::xxh32::xxh32(manifest, 0).to_le_bytes());
169    }
170    if manifest.len() < MANIFEST_HEADER_SIZE {
171        return Err(truncated());
172    }
173    let mut zeroed = manifest.to_vec();
174    zeroed[FOOTER_HASH_RANGE].fill(0);
175    Ok(xxhash_rust::xxh32::xxh32(&zeroed, 0).to_le_bytes())
176}
177
178#[derive(Debug, Clone)]
179pub struct Manifest {
180    /// Fixed-size header containing counts, offsets, and the package ID.
181    pub header: ManifestHeader,
182    /// Named executable entrypoints into the package.
183    pub entrypoints: Vec<EntryPoint>,
184    /// All filesystem entries (files, directories, symlinks) in the package.
185    pub entries: Vec<Entry>,
186    /// Library directory string table offsets: the bundle's library directories.
187    pub lib_dir_offsets: Vec<u32>,
188    /// Null-terminated string pool referenced by offset from entries and entrypoints.
189    pub string_table: Vec<u8>,
190}
191
192impl Manifest {
193    /// Serialize at [`MANIFEST_VERSION`].
194    ///
195    /// The version field is written from the constant rather than from
196    /// `header`, because the body is always laid out in the current format.
197    /// Trusting a caller-supplied version would let the header advertise
198    /// version 1 while the blocks carry a version 2 hash, which a reader
199    /// would then decode at the wrong width.
200    pub fn serialize(&self) -> io::Result<Vec<u8>> {
201        let mut buf = Vec::new();
202        let header = ManifestHeader {
203            version: MANIFEST_VERSION,
204            ..self.header.clone()
205        };
206        header.write_to(&mut buf)?;
207        for ep in &self.entrypoints {
208            ep.write_to(&mut buf)?;
209        }
210        for entry in &self.entries {
211            entry.write_to(&mut buf)?;
212        }
213        for &offset in &self.lib_dir_offsets {
214            buf.write_all(&offset.to_le_bytes())?;
215        }
216        buf.write_all(&self.string_table)?;
217        Ok(buf)
218    }
219
220    pub fn deserialize(data: &[u8]) -> io::Result<Self> {
221        let total = data.len();
222        let mut cursor = Cursor::new(data);
223        let header = ManifestHeader::read_from(&mut cursor)?;
224
225        if header.version == 0 || header.version > MANIFEST_VERSION {
226            return Err(io::Error::new(
227                io::ErrorKind::InvalidData,
228                format!("unsupported manifest version: {}", header.version),
229            ));
230        }
231        let version = header.version;
232
233        // Clamp every speculative allocation to what the input can back,
234        // so an oversized count in the header cannot request huge memory
235        // before `read_exact` fails on the truncated data.
236        let remaining = |c: &Cursor<&[u8]>| total.saturating_sub(c.position() as usize);
237
238        let ep_cap = (header.entrypoint_count as usize).min(remaining(&cursor) / ENTRYPOINT_SIZE);
239        let mut entrypoints = Vec::with_capacity(ep_cap);
240        for _ in 0..header.entrypoint_count {
241            entrypoints.push(EntryPoint::read_from(&mut cursor)?);
242        }
243
244        let entry_cap = (header.entry_count as usize).min(remaining(&cursor) / ENTRY_HEADER_SIZE);
245        let mut entries = Vec::with_capacity(entry_cap);
246        for _ in 0..header.entry_count {
247            entries.push(Entry::read_from(&mut cursor, version)?);
248        }
249
250        let ld_cap = (header.lib_dir_count as usize).min(remaining(&cursor) / 4);
251        let mut lib_dir_offsets = Vec::with_capacity(ld_cap);
252        for _ in 0..header.lib_dir_count {
253            let mut offset_buf = [0u8; 4];
254            cursor.read_exact(&mut offset_buf)?;
255            lib_dir_offsets.push(u32::from_le_bytes(offset_buf));
256        }
257
258        let st_size = header.string_table_size as usize;
259        if st_size > remaining(&cursor) {
260            return Err(io::Error::new(
261                io::ErrorKind::InvalidData,
262                "string table size exceeds remaining input",
263            ));
264        }
265        let mut string_table = vec![0u8; st_size];
266        cursor.read_exact(&mut string_table)?;
267
268        let manifest = Manifest {
269            header,
270            entrypoints,
271            entries,
272            lib_dir_offsets,
273            string_table,
274        };
275        manifest.validate()?;
276        Ok(manifest)
277    }
278
279    /// Validate every cross-reference (parent, target_entry,
280    /// default_entrypoint, string offsets) against its range. Rejects a
281    /// crafted manifest before any consumer indexes it directly.
282    fn validate(&self) -> io::Result<()> {
283        let n = self.entries.len();
284        let st = self.string_table.len() as u32;
285        let bad = |msg: &'static str| io::Error::new(io::ErrorKind::InvalidData, msg);
286
287        for (i, e) in self.entries.iter().enumerate() {
288            // The packer writes every directory before its contents, so a
289            // parent always precedes its children. Requiring that here is
290            // what rules out a cycle, which would otherwise make every
291            // path lookup walk the whole table.
292            if e.parent != u32::MAX {
293                let p = e.parent as usize;
294                if p >= n {
295                    return Err(bad("entry parent index out of range"));
296                }
297                if p >= i {
298                    return Err(bad("entry parent does not precede it"));
299                }
300                if self.entries[p].kind != EntryKind::Dir {
301                    return Err(bad("entry parent is not a directory"));
302                }
303            }
304            if e.name > st {
305                return Err(bad("entry name offset out of range"));
306            }
307            if e.symlink_target > st {
308                return Err(bad("entry symlink_target offset out of range"));
309            }
310        }
311        for ep in &self.entrypoints {
312            if ep.target_entry as usize >= n {
313                return Err(bad("entrypoint target_entry out of range"));
314            }
315            if ep.name > st || ep.args > st {
316                return Err(bad("entrypoint string offset out of range"));
317            }
318        }
319        if !self.entrypoints.is_empty()
320            && self.header.default_entrypoint as usize >= self.entrypoints.len()
321        {
322            return Err(bad("default_entrypoint out of range"));
323        }
324        for &off in &self.lib_dir_offsets {
325            if off > st {
326                return Err(bad("lib_dir offset out of range"));
327            }
328            // Joined onto the package root and put on the library path, so
329            // an absolute one or one climbing out would name a host
330            // directory.
331            let dir = self.get_string(off);
332            let safe = !dir.is_empty()
333                && std::path::Path::new(dir)
334                    .components()
335                    .all(|c| matches!(c, std::path::Component::Normal(_)))
336                && !dir.contains('\0');
337            if !safe {
338                return Err(bad("lib_dir is not a relative path inside the package"));
339            }
340        }
341        if self.header.name_offset as u32 > st {
342            return Err(bad("name_offset out of range"));
343        }
344        Ok(())
345    }
346
347    /// Returns the package name, or empty string if unset.
348    pub fn name(&self) -> &str {
349        if self.header.name_offset > 0 {
350            self.get_string(self.header.name_offset as u32)
351        } else {
352            ""
353        }
354    }
355
356    /// Returns resolved library directory paths.
357    pub fn lib_dirs(&self) -> Vec<&str> {
358        self.lib_dir_offsets
359            .iter()
360            .map(|&offset| self.get_string(offset))
361            .collect()
362    }
363
364    pub fn get_string(&self, offset: u32) -> &str {
365        let start = offset as usize;
366        let Some(tail) = self.string_table.get(start..) else {
367            return "";
368        };
369        let end = tail.iter().position(|&b| b == 0).unwrap_or(tail.len());
370        std::str::from_utf8(&tail[..end]).unwrap_or("")
371    }
372
373    /// Check if a top-level directory with the given name exists
374    pub fn has_toplevel_dir(&self, name: &str) -> bool {
375        use crate::entry::EntryKind;
376        self.entries.iter().any(|e| {
377            e.kind == EntryKind::Dir && e.parent == u32::MAX && self.get_string(e.name) == name
378        })
379    }
380
381    /// Find the path to a lib directory if one exists
382    /// Returns the path (e.g., "lib" or "overlayed/lib") or empty string if not found
383    pub fn find_lib_dir(&self) -> String {
384        use crate::entry::EntryKind;
385        for (i, e) in self.entries.iter().enumerate() {
386            if e.kind == EntryKind::Dir && self.get_string(e.name) == "lib" {
387                return self.entry_path(i);
388            }
389        }
390        String::new()
391    }
392
393    /// Reconstruct the full path for an entry by walking parent chain.
394    ///
395    /// The walk is bounded to the number of entries, so a crafted parent
396    /// cycle terminates instead of looping forever, and an out-of-range
397    /// parent index stops the walk instead of panicking.
398    pub fn entry_path(&self, index: usize) -> String {
399        let mut parts = Vec::new();
400        let mut idx = index;
401        for _ in 0..=self.entries.len() {
402            let Some(entry) = self.entries.get(idx) else {
403                break;
404            };
405            let name = self.get_string(entry.name);
406            if name.is_empty() {
407                break;
408            }
409            parts.push(name);
410            if entry.parent == u32::MAX {
411                break;
412            }
413            idx = entry.parent as usize;
414        }
415        parts.reverse();
416        parts.join("/")
417    }
418
419    /// Like [`Manifest::entry_path`], but validates every component is a
420    /// safe single path segment (no `..`, no absolute or embedded `/`,
421    /// no NUL) and returns a relative [`PathBuf`]. Callers extracting to
422    /// disk MUST use this and confirm the joined path stays under the
423    /// target root. Errors on an unsafe component or out-of-range parent.
424    pub fn validated_entry_path(&self, index: usize) -> io::Result<PathBuf> {
425        let mut parts: Vec<&str> = Vec::new();
426        let mut idx = index;
427        for _ in 0..=self.entries.len() {
428            let entry = self.entries.get(idx).ok_or_else(|| {
429                io::Error::new(
430                    io::ErrorKind::InvalidData,
431                    "entry parent index out of range",
432                )
433            })?;
434            let name = self.get_string(entry.name);
435            if name.is_empty() {
436                break;
437            }
438            if !is_safe_component(name) {
439                return Err(io::Error::new(
440                    io::ErrorKind::InvalidData,
441                    format!("unsafe path component: {name:?}"),
442                ));
443            }
444            parts.push(name);
445            if entry.parent == u32::MAX {
446                break;
447            }
448            if entry.parent as usize >= idx {
449                return Err(io::Error::new(
450                    io::ErrorKind::InvalidData,
451                    "entry parent does not precede it",
452                ));
453            }
454            idx = entry.parent as usize;
455        }
456        parts.reverse();
457        let mut path = PathBuf::new();
458        for part in parts {
459            path.push(part);
460        }
461        Ok(path)
462    }
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468    use crate::entry::{Block, Entry, EntryKind};
469
470    fn entry(name: u32, parent: u32) -> Entry {
471        Entry {
472            kind: EntryKind::Dir,
473            parent,
474            name,
475            mode: 0o755,
476            mtime_secs: 0,
477            mtime_nsec: 0,
478            content_hash: [0u8; 32],
479            blocks: Vec::new(),
480            symlink_target: 0,
481        }
482    }
483
484    fn manifest(entries: Vec<Entry>, string_table: Vec<u8>) -> Manifest {
485        Manifest {
486            header: ManifestHeader {
487                version: MANIFEST_VERSION,
488                entry_count: entries.len() as u32,
489                string_table_size: string_table.len() as u32,
490                entrypoint_count: 0,
491                default_entrypoint: 0,
492                lib_dir_count: 0,
493                name_offset: 0,
494                package_id: [0u8; 32],
495                prefix_hash: [0u8; 32],
496                footer_hash: [0u8; 32],
497            },
498            entrypoints: Vec::new(),
499            entries,
500            lib_dir_offsets: Vec::new(),
501            string_table,
502        }
503    }
504
505    #[test]
506    fn roundtrip_valid_manifest() {
507        // string table: "\0a\0b\0" -> "a" at 1, "b" at 3
508        let st = b"\0a\0b\0".to_vec();
509        let m = manifest(vec![entry(1, u32::MAX), entry(3, 0)], st);
510        let bytes = m.serialize().unwrap();
511        let back = Manifest::deserialize(&bytes).unwrap();
512        assert_eq!(back.entry_path(1), "a/b");
513        assert_eq!(back.get_string(3), "b");
514    }
515
516    #[test]
517    fn file_entry_with_blocks_roundtrips_byte_identical() {
518        // A File entry carrying two payload blocks. The serialized block
519        // count is derived from `blocks.len()` now that `num_blocks` is gone;
520        // this asserts the derivation and a byte-identical re-serialize.
521        let mut file = entry(1, u32::MAX);
522        file.kind = EntryKind::File;
523        file.blocks = vec![
524            Block {
525                payload_offset: 0,
526                compressed_size: 10,
527                original_size: 20,
528                content_hash: [0u8; 32],
529            },
530            Block {
531                payload_offset: 10,
532                compressed_size: 5,
533                original_size: 8,
534                content_hash: [0u8; 32],
535            },
536        ];
537        let m = manifest(vec![file], b"\0a\0".to_vec());
538
539        let bytes = m.serialize().unwrap();
540        let back = Manifest::deserialize(&bytes).unwrap();
541        assert_eq!(back.entries[0].blocks.len(), 2);
542        assert_eq!(back.entries[0].blocks[1].original_size, 8);
543        // Re-serializing the decoded manifest must reproduce the exact bytes.
544        assert_eq!(back.serialize().unwrap(), bytes);
545    }
546
547    /// Version 1 laid blocks out 24 bytes wide with no per-block hash.
548    /// Packages already published in that format must keep decoding, and
549    /// their blocks must report that they carry no hash.
550    #[test]
551    fn version_one_blocks_still_decode() {
552        let mut file = entry(1, u32::MAX);
553        file.kind = EntryKind::File;
554        file.blocks = vec![Block {
555            payload_offset: 7,
556            compressed_size: 11,
557            original_size: 13,
558            content_hash: [0u8; 32],
559        }];
560        let m = manifest(vec![file], b"\0a\0".to_vec());
561
562        // Hand-assemble the v1 encoding: the current writer only emits v3,
563        // whose header carries the two structure hashes v1 lacked.
564        let mut bytes = Vec::new();
565        let header = &m.header;
566        bytes.extend_from_slice(&1u16.to_le_bytes());
567        bytes.extend_from_slice(&header.entry_count.to_le_bytes());
568        bytes.extend_from_slice(&header.string_table_size.to_le_bytes());
569        bytes.extend_from_slice(&header.entrypoint_count.to_le_bytes());
570        bytes.extend_from_slice(&header.default_entrypoint.to_le_bytes());
571        bytes.extend_from_slice(&header.lib_dir_count.to_le_bytes());
572        bytes.extend_from_slice(&header.name_offset.to_le_bytes());
573        bytes.extend_from_slice(&header.package_id);
574        let e = &m.entries[0];
575        bytes.push(e.kind as u8);
576        bytes.extend_from_slice(&e.parent.to_le_bytes());
577        bytes.extend_from_slice(&e.name.to_le_bytes());
578        bytes.extend_from_slice(&e.mode.to_le_bytes());
579        bytes.extend_from_slice(&e.mtime_secs.to_le_bytes());
580        bytes.extend_from_slice(&e.mtime_nsec.to_le_bytes());
581        bytes.extend_from_slice(&e.content_hash);
582        bytes.extend_from_slice(&1u32.to_le_bytes());
583        bytes.extend_from_slice(&e.symlink_target.to_le_bytes());
584        let b = &e.blocks[0];
585        bytes.extend_from_slice(&b.payload_offset.to_le_bytes());
586        bytes.extend_from_slice(&b.compressed_size.to_le_bytes());
587        bytes.extend_from_slice(&b.original_size.to_le_bytes());
588        bytes.extend_from_slice(&m.string_table);
589
590        let back = Manifest::deserialize(&bytes).expect("version 1 must decode");
591        assert_eq!(back.header.version, 1);
592        let block = &back.entries[0].blocks[0];
593        assert_eq!(block.payload_offset, 7);
594        assert_eq!(block.compressed_size, 11);
595        assert_eq!(block.original_size, 13);
596        assert!(!block.has_content_hash(), "v1 carries no per-block hash");
597        // A pre-v3 manifest has no structure hashes; they read as zeros.
598        assert_eq!(back.header.prefix_hash, [0u8; 32]);
599        assert_eq!(back.header.footer_hash, [0u8; 32]);
600    }
601
602    #[test]
603    fn structure_hashes_roundtrip_through_serialization() {
604        let mut m = manifest(vec![entry(1, u32::MAX)], b"\0a\0".to_vec());
605        m.header.prefix_hash = [1u8; 32];
606        m.header.footer_hash = [2u8; 32];
607        let bytes = m.serialize().unwrap();
608        let back = Manifest::deserialize(&bytes).unwrap();
609        assert_eq!(back.header.prefix_hash, [1u8; 32]);
610        assert_eq!(back.header.footer_hash, [2u8; 32]);
611        // Re-serializing the decoded manifest must reproduce the bytes.
612        assert_eq!(back.serialize().unwrap(), bytes);
613    }
614
615    #[test]
616    fn manifest_checksum_zeroes_the_footer_hash_field_from_v3() {
617        let mut m = manifest(vec![entry(1, u32::MAX)], b"\0a\0".to_vec());
618        m.header.prefix_hash = [3u8; 32];
619        m.header.footer_hash = [4u8; 32];
620        let bytes = m.serialize().unwrap();
621
622        let mut zeroed = bytes.clone();
623        zeroed[FOOTER_HASH_RANGE].fill(0);
624        assert_eq!(
625            manifest_checksum(&bytes).unwrap(),
626            xxhash_rust::xxh32::xxh32(&zeroed, 0).to_le_bytes()
627        );
628        // The prefix hash is covered by the checksum, only footer_hash is
629        // excluded.
630        assert_ne!(
631            manifest_checksum(&bytes).unwrap(),
632            xxhash_rust::xxh32::xxh32(&bytes, 0).to_le_bytes()
633        );
634
635        // Version 1 bytes checksum plainly: the field does not exist there.
636        let v1 = [0u8, 0, 1, 2, 3];
637        assert_eq!(
638            manifest_checksum(&v1).unwrap(),
639            xxhash_rust::xxh32::xxh32(&v1, 0).to_le_bytes()
640        );
641    }
642
643    /// A crafted footer can make the manifest decompress to anything, and
644    /// the checksum runs before `deserialize` checks the length.
645    #[test]
646    fn manifest_checksum_refuses_truncated_headers() {
647        assert!(manifest_checksum(&[]).is_err());
648        assert!(manifest_checksum(&[3]).is_err());
649        let mut short_v3 = vec![0u8; MANIFEST_HEADER_SIZE - 1];
650        short_v3[0] = 3;
651        assert!(manifest_checksum(&short_v3).is_err());
652        short_v3.push(0);
653        assert!(manifest_checksum(&short_v3).is_ok());
654    }
655
656    #[test]
657    fn future_versions_are_refused() {
658        let m = manifest(vec![entry(1, u32::MAX)], b"\0a\0".to_vec());
659        let mut bytes = m.serialize().unwrap();
660        bytes[0..2].copy_from_slice(&(MANIFEST_VERSION + 1).to_le_bytes());
661        assert!(Manifest::deserialize(&bytes).is_err());
662        bytes[0..2].copy_from_slice(&0u16.to_le_bytes());
663        assert!(Manifest::deserialize(&bytes).is_err());
664    }
665
666    #[test]
667    fn serialize_always_writes_the_current_version() {
668        let mut m = manifest(vec![entry(1, u32::MAX)], b"\0a\0".to_vec());
669        m.header.version = 1;
670        let bytes = m.serialize().unwrap();
671        assert_eq!(
672            u16::from_le_bytes(bytes[0..2].try_into().unwrap()),
673            MANIFEST_VERSION,
674            "the body is written in the current format, so the header must say so"
675        );
676    }
677
678    #[test]
679    fn get_string_out_of_range_returns_empty() {
680        let m = manifest(vec![entry(0, u32::MAX)], b"\0".to_vec());
681        assert_eq!(m.get_string(9999), "");
682    }
683
684    #[test]
685    fn oversized_entry_count_errors_without_panic() {
686        let m = manifest(vec![entry(1, u32::MAX)], b"\0a\0".to_vec());
687        let mut bytes = m.serialize().unwrap();
688        // entry_count lives at header offset 2..6.
689        bytes[2..6].copy_from_slice(&u32::MAX.to_le_bytes());
690        assert!(Manifest::deserialize(&bytes).is_err());
691    }
692
693    #[test]
694    fn oversized_string_table_errors() {
695        let m = manifest(vec![entry(1, u32::MAX)], b"\0a\0".to_vec());
696        let mut bytes = m.serialize().unwrap();
697        // string_table_size lives at header offset 6..10.
698        bytes[6..10].copy_from_slice(&u32::MAX.to_le_bytes());
699        assert!(Manifest::deserialize(&bytes).is_err());
700    }
701
702    #[test]
703    fn out_of_range_parent_rejected_at_deserialize() {
704        let m = manifest(vec![entry(1, 5)], b"\0a\0".to_vec());
705        let bytes = m.serialize().unwrap();
706        assert!(Manifest::deserialize(&bytes).is_err());
707    }
708
709    #[test]
710    fn parent_cycle_is_rejected() {
711        // entries 0 and 1 point at each other. Deserializing refuses the
712        // table, and a path lookup on it in memory errors rather than
713        // returning a path that walked the cycle.
714        let st = b"\0a\0b\0".to_vec();
715        let m = manifest(vec![entry(1, 1), entry(3, 0)], st);
716        assert!(Manifest::deserialize(&m.serialize().unwrap()).is_err());
717        assert!(m.validated_entry_path(0).is_err());
718        assert!(
719            !m.entry_path(0).is_empty(),
720            "the unvalidated walk still ends"
721        );
722    }
723
724    #[test]
725    fn a_lib_dir_leaving_the_package_is_rejected() {
726        for (st, ok) in [
727            (&b"\0lib\0"[..], true),
728            (b"\0opt/app/lib\0", true),
729            (b"\0/usr/lib\0", false),
730            (b"\0../lib\0", false),
731            (b"\0lib/../../x\0", false),
732            (b"\0\0", false),
733        ] {
734            let mut m = manifest(vec![entry(0, u32::MAX)], st.to_vec());
735            m.lib_dir_offsets = vec![1];
736            m.header.lib_dir_count = 1;
737            let back = Manifest::deserialize(&m.serialize().unwrap());
738            assert_eq!(back.is_ok(), ok, "{:?}", String::from_utf8_lossy(st));
739        }
740    }
741
742    #[test]
743    fn a_file_cannot_be_a_parent() {
744        let st = b"\0a\0b\0".to_vec();
745        let mut file = entry(1, u32::MAX);
746        file.kind = EntryKind::File;
747        let m = manifest(vec![file, entry(3, 0)], st);
748        assert!(Manifest::deserialize(&m.serialize().unwrap()).is_err());
749    }
750
751    #[test]
752    fn unsafe_component_rejected() {
753        // name ".." at offset 1.
754        let m = manifest(vec![entry(1, u32::MAX)], b"\0..\0".to_vec());
755        assert!(m.validated_entry_path(0).is_err());
756    }
757
758    #[test]
759    fn safe_component_accepted() {
760        let m = manifest(vec![entry(1, u32::MAX)], b"\0a\0".to_vec());
761        assert_eq!(m.validated_entry_path(0).unwrap().to_str(), Some("a"));
762    }
763
764    #[test]
765    fn is_safe_component_rules() {
766        assert!(is_safe_component("lib"));
767        assert!(!is_safe_component(""));
768        assert!(!is_safe_component("."));
769        assert!(!is_safe_component(".."));
770        assert!(!is_safe_component("a/b"));
771        assert!(!is_safe_component("a\0b"));
772    }
773
774    #[test]
775    fn symlink_within_root_rules() {
776        use std::path::Path;
777        // Relative target that stays inside the root.
778        assert!(symlink_target_within_root(
779            Path::new("bin/app"),
780            "../lib/x.so"
781        ));
782        assert!(symlink_target_within_root(Path::new("a/b/c"), "d"));
783        // Absolute targets are refused.
784        assert!(!symlink_target_within_root(
785            Path::new("bin/app"),
786            "/etc/passwd"
787        ));
788        // Walking above the root is refused.
789        assert!(!symlink_target_within_root(
790            Path::new("bin/app"),
791            "../../etc/passwd"
792        ));
793        assert!(!symlink_target_within_root(Path::new("top"), "../escape"));
794        // Empty target is refused.
795        assert!(!symlink_target_within_root(Path::new("x"), ""));
796    }
797}
798
799/// Helper for building a string table during packing.
800#[derive(Debug, Default)]
801pub struct StringTableBuilder {
802    data: Vec<u8>,
803    index: HashMap<String, u32>,
804}
805
806impl StringTableBuilder {
807    pub fn new() -> Self {
808        Self {
809            data: Vec::new(),
810            index: HashMap::new(),
811        }
812    }
813
814    /// Add a string and return its offset in the table.
815    pub fn add(&mut self, s: &str) -> u32 {
816        if let Some(&offset) = self.index.get(s) {
817            return offset;
818        }
819        let offset = self.data.len() as u32;
820        self.data.extend_from_slice(s.as_bytes());
821        self.data.push(0);
822        self.index.insert(s.to_owned(), offset);
823        offset
824    }
825
826    pub fn finish(self) -> Vec<u8> {
827        self.data
828    }
829
830    /// Current size of the table in bytes, which is also the offset the
831    /// next distinct string will be added at.
832    pub fn len(&self) -> u32 {
833        self.data.len() as u32
834    }
835
836    /// True before any string has been added. A table that has had even
837    /// the empty string added is non-empty, since each entry contributes
838    /// its terminating NUL.
839    pub fn is_empty(&self) -> bool {
840        self.data.is_empty()
841    }
842}