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