Skip to main content

onelf_format/
entry.rs

1use std::io::{self, Read, Write};
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4#[repr(u8)]
5pub enum EntryKind {
6    Dir = 0,
7    File = 1,
8    Symlink = 2,
9}
10
11impl TryFrom<u8> for EntryKind {
12    type Error = io::Error;
13
14    fn try_from(v: u8) -> Result<Self, Self::Error> {
15        match v {
16            0 => Ok(EntryKind::Dir),
17            1 => Ok(EntryKind::File),
18            2 => Ok(EntryKind::Symlink),
19            _ => Err(io::Error::new(
20                io::ErrorKind::InvalidData,
21                format!("invalid entry kind: {v}"),
22            )),
23        }
24    }
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28#[repr(u8)]
29pub enum WorkingDir {
30    Inherit = 0,
31    PackageRoot = 1,
32    EntrypointParent = 2,
33}
34
35impl TryFrom<u8> for WorkingDir {
36    type Error = io::Error;
37
38    fn try_from(v: u8) -> Result<Self, Self::Error> {
39        match v {
40            0 => Ok(WorkingDir::Inherit),
41            1 => Ok(WorkingDir::PackageRoot),
42            2 => Ok(WorkingDir::EntrypointParent),
43            _ => Err(io::Error::new(
44                io::ErrorKind::InvalidData,
45                format!("invalid working_dir: {v}"),
46            )),
47        }
48    }
49}
50
51bitflags! {
52    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
53    pub struct EntryPointFlags: u8 {
54        const MEMFD_ELIGIBLE = 1 << 0;
55    }
56}
57
58/// One block of a file's content.
59///
60/// In a stipplefs payload ([`crate::Flags::STIPPLE_PAYLOAD`]) a block is a
61/// slice of the file rather than a region of the payload: `payload_offset`
62/// is where the slice starts within the file and `compressed_size` holds
63/// the file's stipplefs inode. See [`crate::payload`].
64#[derive(Debug, Clone)]
65pub struct Block {
66    /// Byte offset into the payload section where this block's data begins.
67    pub payload_offset: u64,
68    /// Size of the block after compression.
69    pub compressed_size: u64,
70    /// Size of the block before compression.
71    pub original_size: u64,
72    /// BLAKE3 of this block's *decompressed* bytes.
73    ///
74    /// Hashing the decompressed form is what lets a random-access reader
75    /// verify one block without reassembling the whole entry, which is the
76    /// difference between serving a byte of a large file and buffering all
77    /// of it. Zero on manifests written before version 2, where the only
78    /// available check is the whole-entry hash on [`Entry`].
79    pub content_hash: [u8; 32],
80}
81
82/// Serialized width of a block, as written by the current format version.
83pub const BLOCK_SIZE: usize = 56;
84
85/// Serialized width in manifest version 1, which carried no per-block hash.
86pub const BLOCK_SIZE_V1: usize = 24;
87
88impl Block {
89    pub fn write_to<W: Write>(&self, w: &mut W) -> io::Result<()> {
90        w.write_all(&self.payload_offset.to_le_bytes())?;
91        w.write_all(&self.compressed_size.to_le_bytes())?;
92        w.write_all(&self.original_size.to_le_bytes())?;
93        w.write_all(&self.content_hash)?;
94        Ok(())
95    }
96
97    /// Read one block as written by manifest `version`.
98    pub fn read_from<R: Read>(r: &mut R, version: u16) -> io::Result<Self> {
99        let mut buf = [0u8; BLOCK_SIZE];
100        let width = if version >= 2 {
101            BLOCK_SIZE
102        } else {
103            BLOCK_SIZE_V1
104        };
105        r.read_exact(&mut buf[..width])?;
106
107        Ok(Block {
108            payload_offset: u64::from_le_bytes(buf[0..8].try_into().unwrap()),
109            compressed_size: u64::from_le_bytes(buf[8..16].try_into().unwrap()),
110            original_size: u64::from_le_bytes(buf[16..24].try_into().unwrap()),
111            // Left zero for version 1, where it means "no per-block check".
112            content_hash: buf[24..56].try_into().unwrap(),
113        })
114    }
115
116    /// Whether this block carries a usable per-block hash.
117    pub fn has_content_hash(&self) -> bool {
118        self.content_hash != [0u8; 32]
119    }
120}
121
122#[derive(Debug, Clone)]
123pub struct EntryPoint {
124    /// Offset into the string table for this entrypoint's name.
125    pub name: u32,
126    /// Index of the filesystem entry this entrypoint executes.
127    pub target_entry: u32,
128    /// Offset into the string table for the argument string.
129    pub args: u32,
130    /// Working directory strategy when launching this entrypoint.
131    pub working_dir: WorkingDir,
132    /// Behavioral flags for this entrypoint.
133    pub flags: EntryPointFlags,
134}
135
136/// Size of serialized EntryPoint: 4+4+4+1+1 = 14 bytes
137pub const ENTRYPOINT_SIZE: usize = 14;
138
139impl EntryPoint {
140    pub fn write_to<W: Write>(&self, w: &mut W) -> io::Result<()> {
141        w.write_all(&self.name.to_le_bytes())?;
142        w.write_all(&self.target_entry.to_le_bytes())?;
143        w.write_all(&self.args.to_le_bytes())?;
144        w.write_all(&[self.working_dir as u8])?;
145        w.write_all(&[self.flags.bits()])?;
146        Ok(())
147    }
148
149    pub fn read_from<R: Read>(r: &mut R) -> io::Result<Self> {
150        let mut buf = [0u8; ENTRYPOINT_SIZE];
151        r.read_exact(&mut buf)?;
152
153        Ok(EntryPoint {
154            name: u32::from_le_bytes(buf[0..4].try_into().unwrap()),
155            target_entry: u32::from_le_bytes(buf[4..8].try_into().unwrap()),
156            args: u32::from_le_bytes(buf[8..12].try_into().unwrap()),
157            working_dir: WorkingDir::try_from(buf[12])?,
158            flags: EntryPointFlags::from_bits_retain(buf[13]),
159        })
160    }
161
162    pub fn is_memfd_eligible(&self) -> bool {
163        self.flags.contains(EntryPointFlags::MEMFD_ELIGIBLE)
164    }
165}
166
167#[derive(Debug, Clone)]
168pub struct Entry {
169    /// Type of this entry (file, directory, or symlink).
170    pub kind: EntryKind,
171    /// Index of the parent directory entry, or `u32::MAX` for top-level entries.
172    pub parent: u32,
173    /// Offset into the string table for this entry's name.
174    pub name: u32,
175    /// Unix file mode (permissions and type bits).
176    pub mode: u32,
177    /// Modification time: seconds since Unix epoch.
178    pub mtime_secs: u64,
179    /// Modification time: nanosecond component.
180    pub mtime_nsec: u32,
181    /// BLAKE3 hash of the file content (files only).
182    pub content_hash: [u8; 32],
183    /// Compressed payload blocks containing this file's data (files only).
184    /// The serialized block count is derived from `blocks.len()`.
185    pub blocks: Vec<Block>,
186    /// Offset into the string table for the symlink target path (symlinks only).
187    pub symlink_target: u32,
188}
189
190/// Size of serialized Entry (without blocks):
191/// 1 + 4 + 4 + 4 + 8 + 4 + 32 + 4 + 4 = 65 bytes
192/// Plus num_blocks * BLOCK_SIZE bytes of block data
193pub const ENTRY_HEADER_SIZE: usize = 65;
194
195impl Entry {
196    pub fn write_to<W: Write>(&self, w: &mut W) -> io::Result<()> {
197        w.write_all(&[self.kind as u8])?;
198        w.write_all(&self.parent.to_le_bytes())?;
199        w.write_all(&self.name.to_le_bytes())?;
200        w.write_all(&self.mode.to_le_bytes())?;
201        w.write_all(&self.mtime_secs.to_le_bytes())?;
202        w.write_all(&self.mtime_nsec.to_le_bytes())?;
203        w.write_all(&self.content_hash)?;
204        // Block count is derived from the vec so it can never disagree with
205        // the blocks that follow.
206        w.write_all(&(self.blocks.len() as u32).to_le_bytes())?;
207        w.write_all(&self.symlink_target.to_le_bytes())?;
208        for block in &self.blocks {
209            block.write_to(w)?;
210        }
211        Ok(())
212    }
213
214    /// Read one entry as written by manifest `version`.
215    pub fn read_from<R: Read>(r: &mut R, version: u16) -> io::Result<Self> {
216        let mut buf = [0u8; ENTRY_HEADER_SIZE];
217        r.read_exact(&mut buf)?;
218
219        let kind = EntryKind::try_from(buf[0])?;
220        let parent = u32::from_le_bytes(buf[1..5].try_into().unwrap());
221        let name = u32::from_le_bytes(buf[5..9].try_into().unwrap());
222        let mode = u32::from_le_bytes(buf[9..13].try_into().unwrap());
223        let mtime_secs = u64::from_le_bytes(buf[13..21].try_into().unwrap());
224        let mtime_nsec = u32::from_le_bytes(buf[21..25].try_into().unwrap());
225        let mut content_hash = [0u8; 32];
226        content_hash.copy_from_slice(&buf[25..57]);
227        let num_blocks = u32::from_le_bytes(buf[57..61].try_into().unwrap());
228        let symlink_target = u32::from_le_bytes(buf[61..65].try_into().unwrap());
229
230        // Cap the speculative allocation so a crafted `num_blocks` cannot
231        // request gigabytes before the reader hits EOF. The loop still
232        // reads exactly `num_blocks` blocks (growing as needed) and fails
233        // cleanly via `read_exact` when the input is short.
234        let cap = (num_blocks as usize).min(4096);
235        let mut blocks = Vec::with_capacity(cap);
236        for _ in 0..num_blocks {
237            blocks.push(Block::read_from(r, version)?);
238        }
239
240        Ok(Entry {
241            kind,
242            parent,
243            name,
244            mode,
245            mtime_secs,
246            mtime_nsec,
247            content_hash,
248            blocks,
249            symlink_target,
250        })
251    }
252}