Skip to main content

microsandbox_image/ext4/
rootfs.rs

1//! Pure-Rust materialization of a merged OCI tree into a deterministic ext4 root filesystem.
2
3use std::io::Read;
4use std::path::Path;
5
6use sha2::{Digest as Sha2Digest, Sha256};
7
8use super::format::{EXT4_BLOCK_SIZE, EXT4_BLOCKS_PER_GROUP, EXT4_INODES_PER_GROUP};
9use super::formatter::{Ext4Error, Ext4FormatOptions, format_ext4_rootfs_with_tree_and_uuid};
10use super::resizer::validate_rootfs_image;
11use crate::tree::FileTree;
12
13//--------------------------------------------------------------------------------------------------
14// Constants
15//--------------------------------------------------------------------------------------------------
16
17/// Version of the on-disk materializer behavior implemented by this module.
18pub const EXT4_ROOTFS_MATERIALIZER_ABI: u32 = 2;
19
20/// Profile-v1 journal size: 64 MiB with 4 KiB filesystem blocks.
21const DEFAULT_ROOTFS_JOURNAL_BLOCKS: u32 = 16_384;
22
23/// Conservative per-group metadata allowance, including sparse-super and reserved-GDT overhead.
24const WORST_CASE_METADATA_BLOCKS_PER_GROUP: u64 = 772;
25
26/// Minimum free-space reserve retained in the immutable base.
27const MIN_FREE_RESERVE_BLOCKS: u64 = 16_384;
28
29//--------------------------------------------------------------------------------------------------
30// Types
31//--------------------------------------------------------------------------------------------------
32
33/// Options that define deterministic ext4 rootfs output.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct Ext4RootfsOptions {
36    /// Number of 4 KiB blocks reserved for the internal journal.
37    pub journal_blocks: u32,
38
39    /// Canonical derivation digest for the manifest, platform and filesystem profile.
40    ///
41    /// The first 16 bytes seed the filesystem UUID after RFC 4122 variant/version normalization.
42    pub derivation_digest: [u8; 32],
43}
44
45/// Metadata describing a validated deterministic ext4 rootfs artifact.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct Ext4Artifact {
48    /// Materializer ABI that defines the generated byte layout.
49    pub materializer_abi: u32,
50
51    /// Deterministic ext4 filesystem UUID.
52    pub uuid: [u8; 16],
53
54    /// Logical size of the sparse raw image.
55    pub virtual_size_bytes: u64,
56
57    /// Number of unique filesystem inodes represented by the merged tree, including root.
58    pub inode_count: u64,
59
60    /// Unique regular-file content bytes represented by the merged tree.
61    pub content_bytes: u64,
62
63    /// SHA-256 digest of the complete logical raw image bytes.
64    pub sha256: [u8; 32],
65}
66
67//--------------------------------------------------------------------------------------------------
68// Trait Implementations
69//--------------------------------------------------------------------------------------------------
70
71impl Default for Ext4RootfsOptions {
72    fn default() -> Self {
73        Self {
74            journal_blocks: DEFAULT_ROOTFS_JOURNAL_BLOCKS,
75            derivation_digest: [0u8; 32],
76        }
77    }
78}
79
80//--------------------------------------------------------------------------------------------------
81// Functions
82//--------------------------------------------------------------------------------------------------
83
84/// Materialize a merged OCI tree into a deterministic raw ext4 filesystem.
85///
86/// This is a host-only pure-Rust operation. It does not mount the image or invoke an external
87/// formatter. The output file is validated and fully synchronized before this function returns.
88pub fn materialize_ext4_rootfs(
89    path: &Path,
90    tree: FileTree,
91    options: &Ext4RootfsOptions,
92) -> Result<Ext4Artifact, Ext4Error> {
93    let inode_count = unique_inode_count(&tree);
94    let content_bytes = tree.total_data_size();
95    let size_bytes = canonical_size_bytes(inode_count, content_bytes, options.journal_blocks)?;
96    let format_options = Ext4FormatOptions {
97        size_bytes,
98        journal_blocks: options.journal_blocks,
99    };
100    let uuid = deterministic_uuid(&options.derivation_digest);
101
102    format_ext4_rootfs_with_tree_and_uuid(path, &format_options, tree, uuid)?;
103    validate_rootfs_image(path)?;
104
105    // Rootfs artifacts enter a shared cache, unlike ephemeral upper creation. Durably persist the
106    // complete candidate before a later cache transaction can hash and atomically publish it.
107    std::fs::OpenOptions::new()
108        .read(true)
109        .write(true)
110        .open(path)?
111        .sync_all()?;
112
113    Ok(Ext4Artifact {
114        materializer_abi: EXT4_ROOTFS_MATERIALIZER_ABI,
115        uuid,
116        virtual_size_bytes: size_bytes,
117        inode_count,
118        content_bytes,
119        sha256: sha256_file(path)?,
120    })
121}
122
123fn unique_inode_count(tree: &FileTree) -> u64 {
124    let duplicate_hardlink_aliases = tree
125        .regular_file_link_counts()
126        .values()
127        .map(|count| u64::from(count.saturating_sub(1)))
128        .sum::<u64>();
129    tree.node_count() + 1 - duplicate_hardlink_aliases
130}
131
132fn canonical_size_bytes(
133    inode_count: u64,
134    content_bytes: u64,
135    journal_blocks: u32,
136) -> Result<u64, Ext4Error> {
137    let data_blocks = content_bytes.div_ceil(u64::from(EXT4_BLOCK_SIZE));
138    let reserve_blocks = MIN_FREE_RESERVE_BLOCKS.max(data_blocks.div_ceil(20));
139    // Two metadata/data blocks per inode safely cover a directory or symlink block plus an xattr
140    // or extent-index block. Exact allocation remains the formatter's authority.
141    let inode_overhead_blocks = inode_count
142        .checked_mul(2)
143        .ok_or_else(|| Ext4Error::InvalidSize("rootfs inode overhead exceeds u64".to_string()))?;
144    let required_usable_blocks = data_blocks
145        .checked_add(reserve_blocks)
146        .and_then(|blocks| blocks.checked_add(u64::from(journal_blocks)))
147        .and_then(|blocks| blocks.checked_add(inode_overhead_blocks))
148        .ok_or_else(|| Ext4Error::InvalidSize("rootfs size calculation overflowed".to_string()))?;
149
150    let inode_slots = inode_count.saturating_add(9).div_ceil(10) * 11;
151    let inode_groups = inode_slots.div_ceil(u64::from(EXT4_INODES_PER_GROUP));
152    let usable_per_group =
153        u64::from(EXT4_BLOCKS_PER_GROUP).saturating_sub(WORST_CASE_METADATA_BLOCKS_PER_GROUP);
154    let data_groups = required_usable_blocks.div_ceil(usable_per_group);
155    let groups = inode_groups.max(data_groups).max(2);
156    groups
157        .checked_mul(u64::from(EXT4_BLOCKS_PER_GROUP))
158        .and_then(|blocks| blocks.checked_mul(u64::from(EXT4_BLOCK_SIZE)))
159        .ok_or_else(|| Ext4Error::InvalidSize("rootfs virtual size exceeds u64".to_string()))
160}
161
162fn sha256_file(path: &Path) -> Result<[u8; 32], Ext4Error> {
163    let mut file = std::fs::File::open(path)?;
164    let mut hasher = Sha256::new();
165    let mut buffer = [0u8; 64 * 1024];
166    loop {
167        let len = file.read(&mut buffer)?;
168        if len == 0 {
169            break;
170        }
171        hasher.update(&buffer[..len]);
172    }
173    Ok(hasher.finalize().into())
174}
175
176fn deterministic_uuid(digest: &[u8; 32]) -> [u8; 16] {
177    let mut uuid = [0u8; 16];
178    uuid.copy_from_slice(&digest[..16]);
179    uuid[6] = (uuid[6] & 0x0f) | 0x40;
180    uuid[8] = (uuid[8] & 0x3f) | 0x80;
181    uuid
182}
183
184//--------------------------------------------------------------------------------------------------
185// Tests
186//--------------------------------------------------------------------------------------------------
187
188#[cfg(test)]
189mod tests {
190    use std::io::{Read, Seek, SeekFrom, Write};
191
192    use super::*;
193    use crate::tree::{FileData, InodeMetadata, RegularFileNode, TreeNode, Xattr};
194
195    const TEST_JOURNAL_BLOCKS: u32 = 1024;
196
197    fn test_options(digest: [u8; 32]) -> Ext4RootfsOptions {
198        Ext4RootfsOptions {
199            journal_blocks: TEST_JOURNAL_BLOCKS,
200            derivation_digest: digest,
201        }
202    }
203
204    fn inode_table_block() -> u64 {
205        // One 128 MiB group: superblock block, one GDT block, reserved GDT headroom, then
206        // block and inode bitmaps. This mirrors profile-v1 group-zero geometry.
207        1 + 1 + super::super::layout::RESERVED_GDT_BLOCKS as u64 + 2
208    }
209
210    fn read_inode(path: &Path, inode: u32) -> Vec<u8> {
211        let offset = inode_table_block() * 4096 + u64::from(inode - 1) * 256;
212        let mut file = std::fs::File::open(path).unwrap();
213        file.seek(SeekFrom::Start(offset)).unwrap();
214        let mut bytes = vec![0u8; 256];
215        file.read_exact(&mut bytes).unwrap();
216        bytes
217    }
218
219    fn le_u16(bytes: &[u8], offset: usize) -> u16 {
220        u16::from_le_bytes([bytes[offset], bytes[offset + 1]])
221    }
222
223    fn le_u32(bytes: &[u8], offset: usize) -> u32 {
224        u32::from_le_bytes([
225            bytes[offset],
226            bytes[offset + 1],
227            bytes[offset + 2],
228            bytes[offset + 3],
229        ])
230    }
231
232    fn regular_file(id: crate::tree::RegularFileId) -> RegularFileNode {
233        RegularFileNode {
234            id,
235            metadata: InodeMetadata {
236                uid: 0x12345,
237                gid: 0x23456,
238                mode: 0o4750,
239                mtime: 1_800_000_000,
240                mtime_nsec: 123_456_789,
241            },
242            xattrs: Vec::new(),
243            data: FileData::Memory(b"rootfs-data".to_vec()),
244            nlink: 1,
245        }
246    }
247
248    #[test]
249    fn deterministic_uuid_has_rfc4122_version_and_variant() {
250        let digest = [0xff; 32];
251        let uuid = deterministic_uuid(&digest);
252
253        assert_eq!(uuid[6] >> 4, 4);
254        assert_eq!(uuid[8] >> 6, 2);
255        assert_eq!(&uuid[..6], &[0xff; 6]);
256    }
257
258    #[test]
259    fn materializer_preserves_inode_metadata_and_hardlinks() {
260        let dir = tempfile::tempdir().unwrap();
261        let path = dir.path().join("rootfs.raw");
262        let mut tree = FileTree::new();
263        let file = regular_file(crate::tree::RegularFileId::new());
264        tree.insert(b"usr/bin/tool", TreeNode::RegularFile(file.clone()))
265            .unwrap();
266        tree.insert(b"usr/bin/tool-link", TreeNode::RegularFile(file))
267            .unwrap();
268
269        let artifact = materialize_ext4_rootfs(&path, tree, &test_options([7u8; 32])).unwrap();
270        assert_eq!(artifact.inode_count, 4);
271        assert_eq!(artifact.content_bytes, b"rootfs-data".len() as u64);
272
273        // Intermediate directories consume inodes 11 and 12; the hardlinked file is inode 13.
274        let inode = read_inode(&path, 13);
275        assert_eq!(le_u16(&inode, 0x00), 0o100000 | 0o4750);
276        assert_eq!(le_u16(&inode, 0x02), 0x2345);
277        assert_eq!(le_u16(&inode, 0x78), 0x0001);
278        assert_eq!(le_u16(&inode, 0x18), 0x3456);
279        assert_eq!(le_u16(&inode, 0x7a), 0x0002);
280        assert_eq!(le_u16(&inode, 0x1a), 2);
281        assert_eq!(le_u32(&inode, 0x10), 1_800_000_000);
282        assert_eq!(le_u32(&inode, 0x88) >> 2, 123_456_789);
283    }
284
285    #[test]
286    fn materializer_is_byte_deterministic_for_same_inputs() {
287        let dir = tempfile::tempdir().unwrap();
288        let first_path = dir.path().join("first.raw");
289        let second_path = dir.path().join("second.raw");
290        let mut tree = FileTree::new();
291        tree.insert(
292            b"payload",
293            TreeNode::RegularFile(regular_file(crate::tree::RegularFileId::new())),
294        )
295        .unwrap();
296        let options = test_options([9u8; 32]);
297
298        let first_artifact = materialize_ext4_rootfs(&first_path, tree.clone(), &options).unwrap();
299        let second_artifact = materialize_ext4_rootfs(&second_path, tree, &options).unwrap();
300        assert_eq!(first_artifact, second_artifact);
301        assert_eq!(first_artifact.virtual_size_bytes, 256 * 1024 * 1024);
302        assert_eq!(first_artifact.inode_count, 2);
303
304        let mut first = std::fs::File::open(first_path).unwrap();
305        let mut second = std::fs::File::open(second_path).unwrap();
306        let mut first_buf = vec![0u8; 1024 * 1024];
307        let mut second_buf = vec![0u8; 1024 * 1024];
308        loop {
309            let first_len = first.read(&mut first_buf).unwrap();
310            let second_len = second.read(&mut second_buf).unwrap();
311            assert_eq!(first_len, second_len);
312            assert_eq!(&first_buf[..first_len], &second_buf[..second_len]);
313            if first_len == 0 {
314                break;
315            }
316        }
317    }
318
319    #[test]
320    fn materializer_links_external_xattr_block_from_inode() {
321        let dir = tempfile::tempdir().unwrap();
322        let path = dir.path().join("rootfs.raw");
323        let mut tree = FileTree::new();
324        let mut file = regular_file(crate::tree::RegularFileId::new());
325        file.xattrs.push(Xattr {
326            name: b"security.capability".to_vec(),
327            value: (0u8..128).collect(),
328        });
329        tree.insert(b"usr/bin/tool", TreeNode::RegularFile(file))
330            .unwrap();
331
332        materialize_ext4_rootfs(&path, tree, &test_options([11u8; 32])).unwrap();
333
334        let inode = read_inode(&path, 13);
335        let xattr_block = u64::from(le_u32(&inode, 0x68)) | (u64::from(le_u16(&inode, 0x76)) << 32);
336        assert_ne!(xattr_block, 0);
337        let mut image = std::fs::File::open(&path).unwrap();
338        image.seek(SeekFrom::Start(xattr_block * 4096)).unwrap();
339        let mut block = vec![0u8; 4096];
340        image.read_exact(&mut block).unwrap();
341        assert_eq!(le_u32(&block, 0), 0xEA02_0000);
342        assert_eq!(block[33], 6);
343        assert_eq!(&block[48..58], b"capability");
344    }
345
346    #[test]
347    fn validator_rejects_corrupted_inode() {
348        let dir = tempfile::tempdir().unwrap();
349        let path = dir.path().join("rootfs.raw");
350        let mut tree = FileTree::new();
351        tree.insert(
352            b"payload",
353            TreeNode::RegularFile(regular_file(crate::tree::RegularFileId::new())),
354        )
355        .unwrap();
356        materialize_ext4_rootfs(&path, tree, &test_options([13u8; 32])).unwrap();
357
358        let root_mode_offset = inode_table_block() * 4096 + 256;
359        let mut image = std::fs::OpenOptions::new()
360            .read(true)
361            .write(true)
362            .open(&path)
363            .unwrap();
364        image.seek(SeekFrom::Start(root_mode_offset)).unwrap();
365        image.write_all(&[0]).unwrap();
366        image.flush().unwrap();
367
368        assert!(validate_rootfs_image(&path).is_err());
369    }
370
371    #[test]
372    fn validator_rejects_corrupted_external_xattr() {
373        let dir = tempfile::tempdir().unwrap();
374        let path = dir.path().join("rootfs.raw");
375        let mut tree = FileTree::new();
376        let mut file = regular_file(crate::tree::RegularFileId::new());
377        file.xattrs.push(Xattr {
378            name: b"security.capability".to_vec(),
379            value: vec![0x5a; 128],
380        });
381        tree.insert(b"payload", TreeNode::RegularFile(file))
382            .unwrap();
383        materialize_ext4_rootfs(&path, tree, &test_options([15u8; 32])).unwrap();
384
385        let inode = read_inode(&path, 11);
386        let xattr_block = u64::from(le_u32(&inode, 0x68)) | (u64::from(le_u16(&inode, 0x76)) << 32);
387        let mut image = std::fs::OpenOptions::new()
388            .read(true)
389            .write(true)
390            .open(&path)
391            .unwrap();
392        image
393            .seek(SeekFrom::Start(xattr_block * 4096 + 32))
394            .unwrap();
395        image.write_all(&[0xff]).unwrap();
396        image.flush().unwrap();
397
398        assert!(validate_rootfs_image(&path).is_err());
399    }
400}