1use 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
13pub const EXT4_ROOTFS_MATERIALIZER_ABI: u32 = 2;
19
20const DEFAULT_ROOTFS_JOURNAL_BLOCKS: u32 = 16_384;
22
23const WORST_CASE_METADATA_BLOCKS_PER_GROUP: u64 = 772;
25
26const MIN_FREE_RESERVE_BLOCKS: u64 = 16_384;
28
29#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct Ext4RootfsOptions {
36 pub journal_blocks: u32,
38
39 pub derivation_digest: [u8; 32],
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct Ext4Artifact {
48 pub materializer_abi: u32,
50
51 pub uuid: [u8; 16],
53
54 pub virtual_size_bytes: u64,
56
57 pub inode_count: u64,
59
60 pub content_bytes: u64,
62
63 pub sha256: [u8; 32],
65}
66
67impl 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
80pub 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 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 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#[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 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 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}