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, 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 1 remains
55/// readable; entries from it carry no per-block hash and fall back to the
56/// whole-entry check.
57pub const MANIFEST_VERSION: u16 = 2;
58
59pub const MANIFEST_HEADER_SIZE: usize = 2 + 4 + 4 + 2 + 2 + 2 + 2 + 32; // 50 bytes
60
61#[derive(Debug, Clone)]
62pub struct ManifestHeader {
63    /// Manifest format version.
64    pub version: u16,
65    /// Total number of filesystem entries in the manifest.
66    pub entry_count: u32,
67    /// Size of the string table in bytes.
68    pub string_table_size: u32,
69    /// Number of entrypoints defined in this package.
70    pub entrypoint_count: u16,
71    /// Index of the default entrypoint to use when none is specified.
72    pub default_entrypoint: u16,
73    /// Number of library directory paths in the manifest.
74    pub lib_dir_count: u16,
75    /// Offset into the string table for the package name.
76    pub name_offset: u16,
77    /// Unique package identifier (BLAKE3 hash).
78    pub package_id: [u8; 32],
79}
80
81impl ManifestHeader {
82    pub fn write_to<W: Write>(&self, w: &mut W) -> io::Result<()> {
83        w.write_all(&self.version.to_le_bytes())?;
84        w.write_all(&self.entry_count.to_le_bytes())?;
85        w.write_all(&self.string_table_size.to_le_bytes())?;
86        w.write_all(&self.entrypoint_count.to_le_bytes())?;
87        w.write_all(&self.default_entrypoint.to_le_bytes())?;
88        w.write_all(&self.lib_dir_count.to_le_bytes())?;
89        w.write_all(&self.name_offset.to_le_bytes())?;
90        w.write_all(&self.package_id)?;
91        Ok(())
92    }
93
94    pub fn read_from<R: Read>(r: &mut R) -> io::Result<Self> {
95        let mut buf = [0u8; MANIFEST_HEADER_SIZE];
96        r.read_exact(&mut buf)?;
97
98        let mut package_id = [0u8; 32];
99        package_id.copy_from_slice(&buf[18..50]);
100
101        Ok(ManifestHeader {
102            version: u16::from_le_bytes(buf[0..2].try_into().unwrap()),
103            entry_count: u32::from_le_bytes(buf[2..6].try_into().unwrap()),
104            string_table_size: u32::from_le_bytes(buf[6..10].try_into().unwrap()),
105            entrypoint_count: u16::from_le_bytes(buf[10..12].try_into().unwrap()),
106            default_entrypoint: u16::from_le_bytes(buf[12..14].try_into().unwrap()),
107            lib_dir_count: u16::from_le_bytes(buf[14..16].try_into().unwrap()),
108            name_offset: u16::from_le_bytes(buf[16..18].try_into().unwrap()),
109            package_id,
110        })
111    }
112}
113
114#[derive(Debug, Clone)]
115pub struct Manifest {
116    /// Fixed-size header containing counts, offsets, and the package ID.
117    pub header: ManifestHeader,
118    /// Named executable entrypoints into the package.
119    pub entrypoints: Vec<EntryPoint>,
120    /// All filesystem entries (files, directories, symlinks) in the package.
121    pub entries: Vec<Entry>,
122    /// Library directory string table offsets for `LD_LIBRARY_PATH` injection.
123    pub lib_dir_offsets: Vec<u32>,
124    /// Null-terminated string pool referenced by offset from entries and entrypoints.
125    pub string_table: Vec<u8>,
126}
127
128impl Manifest {
129    /// Serialize at [`MANIFEST_VERSION`].
130    ///
131    /// The version field is written from the constant rather than from
132    /// `header`, because the body is always laid out in the current format.
133    /// Trusting a caller-supplied version would let the header advertise
134    /// version 1 while the blocks carry a version 2 hash, which a reader
135    /// would then decode at the wrong width.
136    pub fn serialize(&self) -> io::Result<Vec<u8>> {
137        let mut buf = Vec::new();
138        let header = ManifestHeader {
139            version: MANIFEST_VERSION,
140            ..self.header.clone()
141        };
142        header.write_to(&mut buf)?;
143        for ep in &self.entrypoints {
144            ep.write_to(&mut buf)?;
145        }
146        for entry in &self.entries {
147            entry.write_to(&mut buf)?;
148        }
149        for &offset in &self.lib_dir_offsets {
150            buf.write_all(&offset.to_le_bytes())?;
151        }
152        buf.write_all(&self.string_table)?;
153        Ok(buf)
154    }
155
156    pub fn deserialize(data: &[u8]) -> io::Result<Self> {
157        let total = data.len();
158        let mut cursor = Cursor::new(data);
159        let header = ManifestHeader::read_from(&mut cursor)?;
160
161        if header.version == 0 || header.version > MANIFEST_VERSION {
162            return Err(io::Error::new(
163                io::ErrorKind::InvalidData,
164                format!("unsupported manifest version: {}", header.version),
165            ));
166        }
167        let version = header.version;
168
169        // Clamp every speculative allocation to what the input can back,
170        // so an oversized count in the header cannot request huge memory
171        // before `read_exact` fails on the truncated data.
172        let remaining = |c: &Cursor<&[u8]>| total.saturating_sub(c.position() as usize);
173
174        let ep_cap = (header.entrypoint_count as usize).min(remaining(&cursor) / ENTRYPOINT_SIZE);
175        let mut entrypoints = Vec::with_capacity(ep_cap);
176        for _ in 0..header.entrypoint_count {
177            entrypoints.push(EntryPoint::read_from(&mut cursor)?);
178        }
179
180        let entry_cap = (header.entry_count as usize).min(remaining(&cursor) / ENTRY_HEADER_SIZE);
181        let mut entries = Vec::with_capacity(entry_cap);
182        for _ in 0..header.entry_count {
183            entries.push(Entry::read_from(&mut cursor, version)?);
184        }
185
186        let ld_cap = (header.lib_dir_count as usize).min(remaining(&cursor) / 4);
187        let mut lib_dir_offsets = Vec::with_capacity(ld_cap);
188        for _ in 0..header.lib_dir_count {
189            let mut offset_buf = [0u8; 4];
190            cursor.read_exact(&mut offset_buf)?;
191            lib_dir_offsets.push(u32::from_le_bytes(offset_buf));
192        }
193
194        let st_size = header.string_table_size as usize;
195        if st_size > remaining(&cursor) {
196            return Err(io::Error::new(
197                io::ErrorKind::InvalidData,
198                "string table size exceeds remaining input",
199            ));
200        }
201        let mut string_table = vec![0u8; st_size];
202        cursor.read_exact(&mut string_table)?;
203
204        let manifest = Manifest {
205            header,
206            entrypoints,
207            entries,
208            lib_dir_offsets,
209            string_table,
210        };
211        manifest.validate()?;
212        Ok(manifest)
213    }
214
215    /// Validate every cross-reference (parent, target_entry,
216    /// default_entrypoint, string offsets) against its range. Rejects a
217    /// crafted manifest before any consumer indexes it directly.
218    fn validate(&self) -> io::Result<()> {
219        let n = self.entries.len();
220        let st = self.string_table.len() as u32;
221        let bad = |msg: &'static str| io::Error::new(io::ErrorKind::InvalidData, msg);
222
223        for e in &self.entries {
224            if e.parent != u32::MAX && e.parent as usize >= n {
225                return Err(bad("entry parent index out of range"));
226            }
227            if e.name > st {
228                return Err(bad("entry name offset out of range"));
229            }
230            if e.symlink_target > st {
231                return Err(bad("entry symlink_target offset out of range"));
232            }
233        }
234        for ep in &self.entrypoints {
235            if ep.target_entry as usize >= n {
236                return Err(bad("entrypoint target_entry out of range"));
237            }
238            if ep.name > st || ep.args > st {
239                return Err(bad("entrypoint string offset out of range"));
240            }
241        }
242        if !self.entrypoints.is_empty()
243            && self.header.default_entrypoint as usize >= self.entrypoints.len()
244        {
245            return Err(bad("default_entrypoint out of range"));
246        }
247        for &off in &self.lib_dir_offsets {
248            if off > st {
249                return Err(bad("lib_dir offset out of range"));
250            }
251        }
252        if self.header.name_offset as u32 > st {
253            return Err(bad("name_offset out of range"));
254        }
255        Ok(())
256    }
257
258    /// Returns the package name, or empty string if unset.
259    pub fn name(&self) -> &str {
260        if self.header.name_offset > 0 {
261            self.get_string(self.header.name_offset as u32)
262        } else {
263            ""
264        }
265    }
266
267    /// Returns resolved library directory paths.
268    pub fn lib_dirs(&self) -> Vec<&str> {
269        self.lib_dir_offsets
270            .iter()
271            .map(|&offset| self.get_string(offset))
272            .collect()
273    }
274
275    pub fn get_string(&self, offset: u32) -> &str {
276        let start = offset as usize;
277        let Some(tail) = self.string_table.get(start..) else {
278            return "";
279        };
280        let end = tail.iter().position(|&b| b == 0).unwrap_or(tail.len());
281        std::str::from_utf8(&tail[..end]).unwrap_or("")
282    }
283
284    /// Check if a top-level directory with the given name exists
285    pub fn has_toplevel_dir(&self, name: &str) -> bool {
286        use crate::entry::EntryKind;
287        self.entries.iter().any(|e| {
288            e.kind == EntryKind::Dir && e.parent == u32::MAX && self.get_string(e.name) == name
289        })
290    }
291
292    /// Find the path to a lib directory if one exists
293    /// Returns the path (e.g., "lib" or "overlayed/lib") or empty string if not found
294    pub fn find_lib_dir(&self) -> String {
295        use crate::entry::EntryKind;
296        for (i, e) in self.entries.iter().enumerate() {
297            if e.kind == EntryKind::Dir && self.get_string(e.name) == "lib" {
298                return self.entry_path(i);
299            }
300        }
301        String::new()
302    }
303
304    /// Reconstruct the full path for an entry by walking parent chain.
305    ///
306    /// The walk is bounded to the number of entries, so a crafted parent
307    /// cycle terminates instead of looping forever, and an out-of-range
308    /// parent index stops the walk instead of panicking.
309    pub fn entry_path(&self, index: usize) -> String {
310        let mut parts = Vec::new();
311        let mut idx = index;
312        for _ in 0..=self.entries.len() {
313            let Some(entry) = self.entries.get(idx) else {
314                break;
315            };
316            let name = self.get_string(entry.name);
317            if name.is_empty() {
318                break;
319            }
320            parts.push(name);
321            if entry.parent == u32::MAX {
322                break;
323            }
324            idx = entry.parent as usize;
325        }
326        parts.reverse();
327        parts.join("/")
328    }
329
330    /// Like [`Manifest::entry_path`], but validates every component is a
331    /// safe single path segment (no `..`, no absolute or embedded `/`,
332    /// no NUL) and returns a relative [`PathBuf`]. Callers extracting to
333    /// disk MUST use this and confirm the joined path stays under the
334    /// target root. Errors on an unsafe component or out-of-range parent.
335    pub fn validated_entry_path(&self, index: usize) -> io::Result<PathBuf> {
336        let mut parts: Vec<&str> = Vec::new();
337        let mut idx = index;
338        for _ in 0..=self.entries.len() {
339            let entry = self.entries.get(idx).ok_or_else(|| {
340                io::Error::new(
341                    io::ErrorKind::InvalidData,
342                    "entry parent index out of range",
343                )
344            })?;
345            let name = self.get_string(entry.name);
346            if name.is_empty() {
347                break;
348            }
349            if !is_safe_component(name) {
350                return Err(io::Error::new(
351                    io::ErrorKind::InvalidData,
352                    format!("unsafe path component: {name:?}"),
353                ));
354            }
355            parts.push(name);
356            if entry.parent == u32::MAX {
357                break;
358            }
359            idx = entry.parent as usize;
360        }
361        parts.reverse();
362        let mut path = PathBuf::new();
363        for part in parts {
364            path.push(part);
365        }
366        Ok(path)
367    }
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373    use crate::entry::{Block, Entry, EntryKind};
374
375    fn entry(name: u32, parent: u32) -> Entry {
376        Entry {
377            kind: EntryKind::Dir,
378            parent,
379            name,
380            mode: 0o755,
381            mtime_secs: 0,
382            mtime_nsec: 0,
383            content_hash: [0u8; 32],
384            blocks: Vec::new(),
385            symlink_target: 0,
386        }
387    }
388
389    fn manifest(entries: Vec<Entry>, string_table: Vec<u8>) -> Manifest {
390        Manifest {
391            header: ManifestHeader {
392                version: 1,
393                entry_count: entries.len() as u32,
394                string_table_size: string_table.len() as u32,
395                entrypoint_count: 0,
396                default_entrypoint: 0,
397                lib_dir_count: 0,
398                name_offset: 0,
399                package_id: [0u8; 32],
400            },
401            entrypoints: Vec::new(),
402            entries,
403            lib_dir_offsets: Vec::new(),
404            string_table,
405        }
406    }
407
408    #[test]
409    fn roundtrip_valid_manifest() {
410        // string table: "\0a\0b\0" -> "a" at 1, "b" at 3
411        let st = b"\0a\0b\0".to_vec();
412        let m = manifest(vec![entry(1, u32::MAX), entry(3, 0)], st);
413        let bytes = m.serialize().unwrap();
414        let back = Manifest::deserialize(&bytes).unwrap();
415        assert_eq!(back.entry_path(1), "a/b");
416        assert_eq!(back.get_string(3), "b");
417    }
418
419    #[test]
420    fn file_entry_with_blocks_roundtrips_byte_identical() {
421        // A File entry carrying two payload blocks. The serialized block
422        // count is derived from `blocks.len()` now that `num_blocks` is gone;
423        // this asserts the derivation and a byte-identical re-serialize.
424        let mut file = entry(1, u32::MAX);
425        file.kind = EntryKind::File;
426        file.blocks = vec![
427            Block {
428                payload_offset: 0,
429                compressed_size: 10,
430                original_size: 20,
431                content_hash: [0u8; 32],
432            },
433            Block {
434                payload_offset: 10,
435                compressed_size: 5,
436                original_size: 8,
437                content_hash: [0u8; 32],
438            },
439        ];
440        let m = manifest(vec![file], b"\0a\0".to_vec());
441
442        let bytes = m.serialize().unwrap();
443        let back = Manifest::deserialize(&bytes).unwrap();
444        assert_eq!(back.entries[0].blocks.len(), 2);
445        assert_eq!(back.entries[0].blocks[1].original_size, 8);
446        // Re-serializing the decoded manifest must reproduce the exact bytes.
447        assert_eq!(back.serialize().unwrap(), bytes);
448    }
449
450    /// Version 1 laid blocks out 24 bytes wide with no per-block hash.
451    /// Packages already published in that format must keep decoding, and
452    /// their blocks must report that they carry no hash.
453    #[test]
454    fn version_one_blocks_still_decode() {
455        let mut file = entry(1, u32::MAX);
456        file.kind = EntryKind::File;
457        file.blocks = vec![Block {
458            payload_offset: 7,
459            compressed_size: 11,
460            original_size: 13,
461            content_hash: [0u8; 32],
462        }];
463        let m = manifest(vec![file], b"\0a\0".to_vec());
464
465        // Hand-assemble the v1 encoding: the current writer only emits v2.
466        let mut bytes = Vec::new();
467        let mut header = m.header.clone();
468        header.version = 1;
469        header.write_to(&mut bytes).unwrap();
470        let e = &m.entries[0];
471        bytes.push(e.kind as u8);
472        bytes.extend_from_slice(&e.parent.to_le_bytes());
473        bytes.extend_from_slice(&e.name.to_le_bytes());
474        bytes.extend_from_slice(&e.mode.to_le_bytes());
475        bytes.extend_from_slice(&e.mtime_secs.to_le_bytes());
476        bytes.extend_from_slice(&e.mtime_nsec.to_le_bytes());
477        bytes.extend_from_slice(&e.content_hash);
478        bytes.extend_from_slice(&1u32.to_le_bytes());
479        bytes.extend_from_slice(&e.symlink_target.to_le_bytes());
480        let b = &e.blocks[0];
481        bytes.extend_from_slice(&b.payload_offset.to_le_bytes());
482        bytes.extend_from_slice(&b.compressed_size.to_le_bytes());
483        bytes.extend_from_slice(&b.original_size.to_le_bytes());
484        bytes.extend_from_slice(&m.string_table);
485
486        let back = Manifest::deserialize(&bytes).expect("version 1 must decode");
487        assert_eq!(back.header.version, 1);
488        let block = &back.entries[0].blocks[0];
489        assert_eq!(block.payload_offset, 7);
490        assert_eq!(block.compressed_size, 11);
491        assert_eq!(block.original_size, 13);
492        assert!(!block.has_content_hash(), "v1 carries no per-block hash");
493    }
494
495    #[test]
496    fn future_versions_are_refused() {
497        let m = manifest(vec![entry(1, u32::MAX)], b"\0a\0".to_vec());
498        let mut bytes = m.serialize().unwrap();
499        bytes[0..2].copy_from_slice(&(MANIFEST_VERSION + 1).to_le_bytes());
500        assert!(Manifest::deserialize(&bytes).is_err());
501        bytes[0..2].copy_from_slice(&0u16.to_le_bytes());
502        assert!(Manifest::deserialize(&bytes).is_err());
503    }
504
505    #[test]
506    fn serialize_always_writes_the_current_version() {
507        let mut m = manifest(vec![entry(1, u32::MAX)], b"\0a\0".to_vec());
508        m.header.version = 1;
509        let bytes = m.serialize().unwrap();
510        assert_eq!(
511            u16::from_le_bytes(bytes[0..2].try_into().unwrap()),
512            MANIFEST_VERSION,
513            "the body is written in the current format, so the header must say so"
514        );
515    }
516
517    #[test]
518    fn get_string_out_of_range_returns_empty() {
519        let m = manifest(vec![entry(0, u32::MAX)], b"\0".to_vec());
520        assert_eq!(m.get_string(9999), "");
521    }
522
523    #[test]
524    fn oversized_entry_count_errors_without_panic() {
525        let m = manifest(vec![entry(1, u32::MAX)], b"\0a\0".to_vec());
526        let mut bytes = m.serialize().unwrap();
527        // entry_count lives at header offset 2..6.
528        bytes[2..6].copy_from_slice(&u32::MAX.to_le_bytes());
529        assert!(Manifest::deserialize(&bytes).is_err());
530    }
531
532    #[test]
533    fn oversized_string_table_errors() {
534        let m = manifest(vec![entry(1, u32::MAX)], b"\0a\0".to_vec());
535        let mut bytes = m.serialize().unwrap();
536        // string_table_size lives at header offset 6..10.
537        bytes[6..10].copy_from_slice(&u32::MAX.to_le_bytes());
538        assert!(Manifest::deserialize(&bytes).is_err());
539    }
540
541    #[test]
542    fn out_of_range_parent_rejected_at_deserialize() {
543        let m = manifest(vec![entry(1, 5)], b"\0a\0".to_vec());
544        let bytes = m.serialize().unwrap();
545        assert!(Manifest::deserialize(&bytes).is_err());
546    }
547
548    #[test]
549    fn parent_cycle_terminates() {
550        // entries 0 and 1 point at each other; entry_path must not hang.
551        let st = b"\0a\0b\0".to_vec();
552        let m = manifest(vec![entry(1, 1), entry(3, 0)], st);
553        let path = m.entry_path(0);
554        assert!(!path.is_empty());
555    }
556
557    #[test]
558    fn unsafe_component_rejected() {
559        // name ".." at offset 1.
560        let m = manifest(vec![entry(1, u32::MAX)], b"\0..\0".to_vec());
561        assert!(m.validated_entry_path(0).is_err());
562    }
563
564    #[test]
565    fn safe_component_accepted() {
566        let m = manifest(vec![entry(1, u32::MAX)], b"\0a\0".to_vec());
567        assert_eq!(m.validated_entry_path(0).unwrap().to_str(), Some("a"));
568    }
569
570    #[test]
571    fn is_safe_component_rules() {
572        assert!(is_safe_component("lib"));
573        assert!(!is_safe_component(""));
574        assert!(!is_safe_component("."));
575        assert!(!is_safe_component(".."));
576        assert!(!is_safe_component("a/b"));
577        assert!(!is_safe_component("a\0b"));
578    }
579
580    #[test]
581    fn symlink_within_root_rules() {
582        use std::path::Path;
583        // Relative target that stays inside the root.
584        assert!(symlink_target_within_root(
585            Path::new("bin/app"),
586            "../lib/x.so"
587        ));
588        assert!(symlink_target_within_root(Path::new("a/b/c"), "d"));
589        // Absolute targets are refused.
590        assert!(!symlink_target_within_root(
591            Path::new("bin/app"),
592            "/etc/passwd"
593        ));
594        // Walking above the root is refused.
595        assert!(!symlink_target_within_root(
596            Path::new("bin/app"),
597            "../../etc/passwd"
598        ));
599        assert!(!symlink_target_within_root(Path::new("top"), "../escape"));
600        // Empty target is refused.
601        assert!(!symlink_target_within_root(Path::new("x"), ""));
602    }
603}
604
605/// Helper for building a string table during packing.
606#[derive(Debug, Default)]
607pub struct StringTableBuilder {
608    data: Vec<u8>,
609    index: HashMap<String, u32>,
610}
611
612impl StringTableBuilder {
613    pub fn new() -> Self {
614        Self {
615            data: Vec::new(),
616            index: HashMap::new(),
617        }
618    }
619
620    /// Add a string and return its offset in the table.
621    pub fn add(&mut self, s: &str) -> u32 {
622        if let Some(&offset) = self.index.get(s) {
623            return offset;
624        }
625        let offset = self.data.len() as u32;
626        self.data.extend_from_slice(s.as_bytes());
627        self.data.push(0);
628        self.index.insert(s.to_owned(), offset);
629        offset
630    }
631
632    pub fn finish(self) -> Vec<u8> {
633        self.data
634    }
635
636    /// Current size of the table in bytes, which is also the offset the
637    /// next distinct string will be added at.
638    pub fn len(&self) -> u32 {
639        self.data.len() as u32
640    }
641
642    /// True before any string has been added. A table that has had even
643    /// the empty string added is non-empty, since each entry contributes
644    /// its terminating NUL.
645    pub fn is_empty(&self) -> bool {
646        self.data.is_empty()
647    }
648}