1#![recursion_limit = "256"]
6
7use anyhow::{Context, Error, anyhow};
8use delivery_blob::Type1Blob;
9pub use delivery_blob::compression::CompressionAlgorithm;
10use delivery_blob::compression::{ChunkedArchive, ChunkedArchiveOptions};
11use fuchsia_async as fasync;
12use fuchsia_merkle::{Hash, MerkleRootBuilder};
13use futures::{StreamExt as _, TryStreamExt as _};
14use fxfs::blob_metadata::{
15 BlobFormat, BlobMetadata, BlobMetadataLeafHashCollector, FxfsBlobMetadataExt,
16};
17use fxfs::errors::FxfsError;
18use fxfs::filesystem::{FxFilesystemBuilder, OpenFxFilesystem};
19use fxfs::object_handle::WriteBytes;
20use fxfs::object_store::directory::Directory;
21use fxfs::object_store::journal::RESERVED_SPACE;
22use fxfs::object_store::journal::super_block::SuperBlockInstance;
23use fxfs::object_store::transaction::{LockKey, lock_keys};
24use fxfs::object_store::volume::root_volume;
25use fxfs::object_store::{
26 DataObjectHandle, DirectWriter, HandleOptions, NewChildStoreOptions, ObjectStore, StoreOptions,
27};
28use rayon::ThreadPoolBuilder;
29use serde::{Deserialize, Serialize};
30use sparse::unsparse;
31use std::collections::BTreeMap;
32use std::fs;
33use std::io::{BufWriter, Read, Write};
34use std::path::PathBuf;
35use storage_device::DeviceHolder;
36use storage_device::file_backed_device::FileBackedDevice;
37use storage_units::BlockSize;
38
39pub const BLOB_VOLUME_NAME: &str = "blob";
40
41const BLOCK_SIZE: BlockSize = BlockSize::SIZE_4KIB;
42
43#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
44struct BlobsJsonOutputEntry {
45 source_path: String,
46 merkle: String,
47 bytes: usize,
48 size: u64,
49 file_size: usize,
50 compressed_file_size: u64,
51 merkle_tree_size: usize,
52 used_space_in_blobfs: u64,
54}
55
56type BlobsJsonOutput = Vec<BlobsJsonOutputEntry>;
57
58pub async fn make_blob_image(
69 output_image_path: &str,
70 sparse_output_image_path: Option<&str>,
71 blobs: Vec<(Hash, PathBuf)>,
72 json_output_path: &str,
73 target_size: Option<u64>,
74 compression_algorithm: Option<CompressionAlgorithm>,
75) -> Result<(), Error> {
76 let output_image = std::fs::OpenOptions::new()
77 .read(true)
78 .write(true)
79 .create(true)
80 .truncate(true)
81 .open(output_image_path)?;
82
83 let mut target_size = target_size.unwrap_or_default();
84
85 if target_size > 0 && target_size < BLOCK_SIZE {
86 return Err(anyhow!("Size {} is too small", target_size));
87 }
88 if !BLOCK_SIZE.is_aligned(target_size) {
89 return Err(anyhow!("Invalid size {} is not block-aligned", target_size));
90 }
91 let block_count = if target_size != 0 {
92 output_image.set_len(target_size).context("Failed to resize image")?;
94 target_size / BLOCK_SIZE
95 } else {
96 const FOUR_GIGS: u64 = 4 * 1024 * 1024 * 1024;
101 FOUR_GIGS / BLOCK_SIZE
102 };
103
104 let device = DeviceHolder::new(FileBackedDevice::new_with_block_count(
105 output_image,
106 BLOCK_SIZE.get() as u32,
107 block_count,
108 ));
109 let fxblob = FxBlobBuilder::new(device).await?;
110 let blobs_json = install_blobs(&fxblob, blobs, compression_algorithm).await.map_err(|e| {
111 if target_size != 0 && FxfsError::NoSpace.matches(&e) {
112 e.context(format!(
113 "Configured image size {} is too small to fit the base system image.",
114 target_size
115 ))
116 } else {
117 e
118 }
119 })?;
120 let actual_size = fxblob.finalize().await?.1;
121
122 if target_size == 0 {
123 target_size = (actual_size + RESERVED_SPACE) * 2;
126 }
127
128 if let Some(sparse_path) = sparse_output_image_path {
129 create_sparse_image(sparse_path, output_image_path, actual_size, target_size, BLOCK_SIZE)
130 .context("Failed to create sparse image")?;
131 }
132
133 if target_size != actual_size {
134 debug_assert!(target_size > actual_size);
135 let output_image =
136 std::fs::OpenOptions::new().read(true).write(true).open(output_image_path)?;
137 output_image.set_len(target_size).context("Failed to resize image")?;
138 }
139
140 let mut json_output = BufWriter::new(
141 std::fs::File::create(json_output_path).context("Failed to create JSON output file")?,
142 );
143 serde_json::to_writer_pretty(&mut json_output, &blobs_json)
144 .context("Failed to serialize to JSON output")?;
145
146 Ok(())
147}
148
149fn create_sparse_image(
150 sparse_output_image_path: &str,
151 image_path: &str,
152 actual_size: u64,
153 target_size: u64,
154 block_size: BlockSize,
155) -> Result<(), Error> {
156 let image = std::fs::OpenOptions::new()
157 .read(true)
158 .open(image_path)
159 .with_context(|| format!("Failed to open {:?}", image_path))?;
160 let mut output = std::fs::OpenOptions::new()
161 .read(true)
162 .write(true)
163 .create(true)
164 .truncate(true)
165 .open(sparse_output_image_path)
166 .with_context(|| format!("Failed to create {:?}", sparse_output_image_path))?;
167 sparse::builder::SparseImageBuilder::new()
168 .set_block_size(block_size.get() as u32)
169 .add_source(sparse::builder::DataSource::Reader {
170 reader: Box::new(image),
171 size: actual_size,
172 })
173 .add_source(sparse::builder::DataSource::Skip(target_size - actual_size))
174 .build(&mut output)
175 .map_err(anyhow::Error::from)
176}
177
178pub struct FxBlobBuilder {
180 blob_directory: Directory<ObjectStore>,
181 filesystem: OpenFxFilesystem,
182}
183
184impl FxBlobBuilder {
185 pub async fn new(device: DeviceHolder) -> Result<Self, Error> {
187 let filesystem = FxFilesystemBuilder::new()
188 .format(true)
189 .trim_config(None)
190 .image_builder_mode(Some(SuperBlockInstance::A))
191 .open(device)
192 .await
193 .context("Failed to format filesystem")?;
194 filesystem.enable_allocations();
195 let root_volume = root_volume(filesystem.clone()).await?;
196 let vol = root_volume
197 .new_volume(BLOB_VOLUME_NAME, NewChildStoreOptions::default())
198 .await
199 .context("Failed to create volume")?;
200 let blob_directory = Directory::open(&vol, vol.root_directory_object_id())
201 .await
202 .context("Unable to open root blob directory")?;
203 Ok(Self { blob_directory, filesystem })
204 }
205
206 pub async fn finalize(self) -> Result<(DeviceHolder, u64), Error> {
210 self.filesystem.close().await?;
211 let actual_size = self.filesystem.allocator().maximum_offset();
212 Ok((self.filesystem.take_device().await, actual_size))
213 }
214
215 pub async fn install_blob(
217 &self,
218 blob: &BlobToInstall,
219 ) -> Result<DataObjectHandle<ObjectStore>, Error> {
220 let handle;
221 let keys = lock_keys![LockKey::object(
222 self.blob_directory.store().store_object_id(),
223 self.blob_directory.object_id(),
224 )];
225 let mut transaction = self
226 .blob_directory
227 .store()
228 .new_transaction(keys, Default::default())
229 .await
230 .context("new transaction")?;
231 handle = self
232 .blob_directory
233 .create_child_file_with_options(
234 &mut transaction,
235 &blob.hash.to_string(),
236 HandleOptions { skip_checksums: true, ..Default::default() },
238 )
239 .await
240 .context("create child file")?;
241 transaction.commit().await.context("transaction commit")?;
242
243 {
245 let mut writer = DirectWriter::new(&handle, Default::default()).await;
246 match &blob.data {
247 BlobData::Uncompressed(data) => {
248 writer.write_bytes(data).await.context("write blob contents")?;
249 }
250 BlobData::CompressedZstd(archive) | BlobData::CompressedLz4(archive) => {
251 for chunk in archive.chunks() {
252 writer
253 .write_bytes(&chunk.compressed_data)
254 .await
255 .context("write blob contents")?;
256 }
257 }
258 }
259 writer.complete().await.context("flush blob contents")?;
260 }
261
262 blob.metadata.write_to(&handle).await.context("write blob metadata")?;
264
265 Ok(handle)
266 }
267
268 pub fn generate_blob(
270 &self,
271 data: Vec<u8>,
272 compression_algorithm: Option<CompressionAlgorithm>,
273 ) -> Result<BlobToInstall, Error> {
274 BlobToInstall::new(data, self.filesystem.block_size(), compression_algorithm)
275 }
276}
277
278enum BlobData {
279 Uncompressed(Vec<u8>),
280 CompressedZstd(ChunkedArchive),
281 CompressedLz4(ChunkedArchive),
282}
283
284fn compressed_offsets(chunked_archive: &ChunkedArchive) -> Vec<u64> {
285 let mut offsets = Vec::with_capacity(chunked_archive.chunks().len());
286 let mut offset: u64 = 0;
287 for chunk in chunked_archive.chunks() {
288 offsets.push(offset);
289 offset += chunk.compressed_data.len() as u64;
290 }
291 offsets
292}
293
294pub struct BlobToInstall {
296 hash: Hash,
298 data: BlobData,
300 uncompressed_size: usize,
302 metadata: BlobMetadata,
304 source: Option<PathBuf>,
307}
308
309impl BlobToInstall {
310 pub fn new(
312 data: Vec<u8>,
313 fs_block_size: BlockSize,
314 compression_algorithm: Option<CompressionAlgorithm>,
315 ) -> Result<Self, Error> {
316 let (hash, hashes) =
317 MerkleRootBuilder::new(BlobMetadataLeafHashCollector::new()).complete(&data);
318
319 let uncompressed_size = data.len();
320 let data = if let Some(compression_algorithm) = compression_algorithm {
321 maybe_compress(data, fs_block_size, compression_algorithm)
322 } else {
323 BlobData::Uncompressed(data)
324 };
325 let metadata = match &data {
326 BlobData::Uncompressed(_) => {
327 BlobMetadata { merkle_leaves: hashes, format: BlobFormat::Uncompressed }
328 }
329 BlobData::CompressedZstd(chunked_archive) => BlobMetadata {
330 merkle_leaves: hashes,
331 format: BlobFormat::ChunkedZstd {
332 uncompressed_size: uncompressed_size as u64,
333 chunk_size: chunked_archive.chunk_size() as u64,
334 compressed_offsets: compressed_offsets(&chunked_archive),
335 },
336 },
337 BlobData::CompressedLz4(chunked_archive) => BlobMetadata {
338 merkle_leaves: hashes,
339 format: BlobFormat::ChunkedLz4 {
340 uncompressed_size: uncompressed_size as u64,
341 chunk_size: chunked_archive.chunk_size() as u64,
342 compressed_offsets: compressed_offsets(&chunked_archive),
343 },
344 },
345 };
346 Ok(BlobToInstall { hash, data, uncompressed_size, metadata, source: None })
347 }
348
349 pub fn new_from_file(
352 path: PathBuf,
353 fs_block_size: BlockSize,
354 compression_algorithm: Option<CompressionAlgorithm>,
355 ) -> Result<Self, Error> {
356 let mut data = Vec::new();
357 std::fs::File::open(&path)
358 .with_context(|| format!("Unable to open `{:?}'", path))?
359 .read_to_end(&mut data)
360 .with_context(|| format!("Unable to read contents of `{:?}'", path))?;
361 let blob = Self::new(data, fs_block_size, compression_algorithm)?;
362 Ok(Self { source: Some(path), ..blob })
363 }
364
365 pub fn hash(&self) -> Hash {
366 self.hash.clone()
367 }
368}
369
370async fn install_blobs(
371 fxblob: &FxBlobBuilder,
372 mut blobs: Vec<(Hash, PathBuf)>,
373 compression_algorithm: Option<CompressionAlgorithm>,
374) -> Result<BlobsJsonOutput, Error> {
375 let num_blobs = blobs.len();
376 let fs_block_size = fxblob.filesystem.block_size();
377 blobs.sort_unstable_by(|(h1, p1), (h2, p2)| p1.cmp(p2).then_with(|| h1.cmp(h2)));
381
382 const MAX_IN_FLIGHT_BLOBS: usize = 32;
388 let num_threads: usize = std::thread::available_parallelism().unwrap().into();
389 let thread_pool = ThreadPoolBuilder::new().num_threads(num_threads).build().unwrap();
390 let (tx, mut rx) = futures::channel::mpsc::unbounded::<Result<(usize, BlobToInstall), Error>>();
391 let mut tx_opt = Some(tx);
392 let mut blobs_iter = blobs.into_iter().enumerate();
393 let mut spawn_next = || {
394 if let Some(tx_ref) = &tx_opt {
395 if let Some((idx, (hash, path))) = blobs_iter.next() {
396 let tx = tx_ref.clone();
397 thread_pool.spawn_fifo(move || {
398 let res = (|| {
399 let blob = BlobToInstall::new_from_file(
400 path.clone(),
401 fs_block_size,
402 compression_algorithm,
403 )?;
404 if blob.hash != hash {
405 let calculated_hash = &blob.hash;
406 let path = path.display();
407 return Err(anyhow!(
408 "Hash mismatch for {path}: calculated={calculated_hash}, expected={hash}"
409 ));
410 }
411 Ok((idx, blob))
412 })();
413 let _ = tx.unbounded_send(res);
414 });
415 } else {
416 tx_opt = None;
417 }
418 }
419 };
420
421 for _ in 0..MAX_IN_FLIGHT_BLOBS {
422 spawn_next();
423 }
424
425 let mut installed_blobs = Vec::with_capacity(num_blobs);
426 let mut next_idx = 0usize;
427 let mut pending = BTreeMap::new();
428
429 while let Some(res) = rx.next().await {
430 let (idx, blob) = res?;
431 if idx == next_idx {
432 next_idx += 1;
433 spawn_next();
434 installed_blobs.push(install_blob_with_json_output(fxblob, blob).await?);
435
436 while let Some(next_blob) = pending.remove(&next_idx) {
437 next_idx += 1;
438 spawn_next();
439 installed_blobs.push(install_blob_with_json_output(fxblob, next_blob).await?);
440 }
441 } else {
442 pending.insert(idx, blob);
443 }
444 }
445 assert!(pending.is_empty());
446 assert_eq!(installed_blobs.len(), num_blobs);
447 Ok(installed_blobs)
448}
449
450async fn install_blob_with_json_output(
451 fxblob: &FxBlobBuilder,
452 blob: BlobToInstall,
453) -> Result<BlobsJsonOutputEntry, Error> {
454 let handle = fxblob.install_blob(&blob).await?;
455 let properties = handle.get_properties().await.context("get properties")?;
456 let source_path = blob
457 .source
458 .expect("missing source path")
459 .to_str()
460 .context("blob path to utf8")?
461 .to_string();
462 Ok(BlobsJsonOutputEntry {
463 source_path,
464 merkle: blob.hash.to_string(),
465 bytes: blob.uncompressed_size,
466 size: properties.allocated_size,
467 file_size: blob.uncompressed_size,
468 compressed_file_size: properties.data_attribute_size,
469 merkle_tree_size: blob.metadata.serialized_size().context("blob metadata size")?,
470 used_space_in_blobfs: properties.allocated_size,
471 })
472}
473
474fn maybe_compress(
475 buf: Vec<u8>,
476 filesystem_block_size: BlockSize,
477 compression_algorithm: CompressionAlgorithm,
478) -> BlobData {
479 if buf.len() as u64 <= filesystem_block_size {
480 return BlobData::Uncompressed(buf); }
482 let chunked_archive_options = match compression_algorithm {
483 CompressionAlgorithm::Zstd => {
484 Type1Blob::CHUNKED_ARCHIVE_OPTIONS
486 }
487 CompressionAlgorithm::Lz4 => ChunkedArchiveOptions::V3 { compression_algorithm },
488 };
489 let archive =
490 ChunkedArchive::new(&buf, chunked_archive_options).expect("failed to compress data");
491 if filesystem_block_size.align_up(archive.compressed_data_size() as u64).unwrap()
492 >= buf.len() as u64
493 {
494 BlobData::Uncompressed(buf) } else {
496 match compression_algorithm {
497 CompressionAlgorithm::Zstd => BlobData::CompressedZstd(archive),
498 CompressionAlgorithm::Lz4 => BlobData::CompressedLz4(archive),
499 }
500 }
501}
502
503pub async fn extract_blobs(image: PathBuf, out_dir: PathBuf) -> anyhow::Result<()> {
505 if out_dir.exists() {
506 fs::remove_dir_all(&out_dir).context("Failed to remove output directory")?;
507 }
508 fs::create_dir_all(&out_dir)?;
509
510 let mut source = fs::File::open(&image)?;
514 let mut non_sparse_image = tempfile::NamedTempFile::new_in(&out_dir)?;
515 unsparse(&mut source, non_sparse_image.as_file_mut()).map_err(anyhow::Error::from)?;
516
517 let device = DeviceHolder::new(FileBackedDevice::new(
518 non_sparse_image.reopen()?,
519 BLOCK_SIZE.get() as u32,
520 ));
521 let fs = FxFilesystemBuilder::new().read_only(true).open(device).await?;
522 let vol =
523 root_volume(fs.clone()).await?.volume(BLOB_VOLUME_NAME, StoreOptions::default()).await?;
524 let root_dir = Directory::open(&vol, vol.root_directory_object_id()).await?;
525 let layer_set = root_dir.store().tree().layer_set();
526 let mut merger = layer_set.merger();
527 let mut iter = root_dir.iter(&mut merger).await?;
528 let blob_extraction_futures = futures::stream::FuturesUnordered::new();
529
530 while let Some((name, object_id, descriptor)) = iter.get() {
531 if *descriptor == fxfs::object_store::ObjectDescriptor::File {
532 let handle = fxfs::object_store::ObjectStore::open_object(
533 root_dir.owner(),
534 object_id,
535 fxfs::object_store::HandleOptions::default(),
536 None,
537 )
538 .await?;
539
540 let mut components = std::path::Path::new(name).components();
541 if !matches!(components.next(), Some(std::path::Component::Normal(..))) {
542 return Err(anyhow!("Invalid blob name: {}", name));
543 }
544 if components.next().is_some() {
545 return Err(anyhow!("Invalid blob name: {}", name));
546 }
547 let out_path = out_dir.join(name);
548 let mut file = std::fs::File::create(&out_path)?;
549 let read_buf = handle.contents(usize::MAX).await?;
550
551 let metadata = BlobMetadata::read_from(&handle).await?;
552 blob_extraction_futures.push(fasync::unblock(move || -> Result<(), Error> {
553 match metadata.format {
554 BlobFormat::ChunkedZstd {
555 uncompressed_size,
556 compressed_offsets,
557 chunk_size,
558 } => decompress_blob(
559 &read_buf,
560 uncompressed_size,
561 compressed_offsets,
562 chunk_size,
563 CompressionAlgorithm::Zstd,
564 &mut file,
565 ),
566 BlobFormat::ChunkedLz4 {
567 uncompressed_size,
568 compressed_offsets,
569 chunk_size,
570 } => decompress_blob(
571 &read_buf,
572 uncompressed_size,
573 compressed_offsets,
574 chunk_size,
575 CompressionAlgorithm::Lz4,
576 &mut file,
577 ),
578 BlobFormat::Uncompressed => {
579 file.write_all(&read_buf)?;
580 Ok(())
581 }
582 }
583 }));
584 }
585 iter.advance().await?;
586 }
587 blob_extraction_futures.try_collect::<()>().await?;
588 Ok(())
589}
590
591fn decompress_blob(
592 blob_data: &[u8],
593 uncompressed_size: u64,
594 compressed_offsets: Vec<u64>,
595 chunk_size: u64,
596 compression_algorithm: CompressionAlgorithm,
597 out: &mut std::fs::File,
598) -> Result<(), Error> {
599 let mut decompressor = compression_algorithm.decompressor();
600 let mut buf = vec![0; chunk_size as usize];
601 let mut total_decompressed_size = 0;
602 for i in 0..compressed_offsets.len() {
603 let start_offset = compressed_offsets[i] as usize;
604 let end_offset = if i + 1 == compressed_offsets.len() {
605 blob_data.len()
606 } else {
607 compressed_offsets[i + 1] as usize
608 };
609 let decompressed_size =
610 decompressor.decompress_into(&blob_data[start_offset..end_offset], &mut buf, i)?;
611 total_decompressed_size += decompressed_size;
612 out.write_all(&buf[..decompressed_size])?;
613 }
614 if total_decompressed_size != uncompressed_size as usize {
615 Err(anyhow!(
616 "Decompressed size does not match expected size {} {}",
617 total_decompressed_size,
618 uncompressed_size
619 ))
620 } else {
621 Ok(())
622 }
623}
624
625#[cfg(test)]
626mod tests {
627 use super::{BlobsJsonOutput, BlobsJsonOutputEntry, extract_blobs, make_blob_image};
628 use assert_matches::assert_matches;
629 use delivery_blob::compression::CompressionAlgorithm;
630 use fxfs::blob_metadata::FxfsBlobMetadataExt;
631 use fxfs::filesystem::FxFilesystem;
632 use fxfs::object_store::StoreOptions;
633 use fxfs::object_store::directory::Directory;
634 use fxfs::object_store::volume::root_volume;
635 use sparse::reader::SparseReader;
636 use std::fs::File;
637 use std::io::{Seek as _, SeekFrom, Write};
638 use std::path::Path;
639 use std::str::from_utf8;
640 use storage_device::DeviceHolder;
641 use storage_device::file_backed_device::FileBackedDevice;
642 use tempfile::TempDir;
643
644 #[fuchsia::test(threads = 10)]
645 async fn test_extract_blobs_zstd() {
646 let tmp = TempDir::new().unwrap();
647 let dir = tmp.path();
648
649 let input_blob_path = dir.join("input.txt");
650 let image_path = dir.join("fxfs1.blk");
651 let sparse_path = dir.join("fxfs1.sparse.blk");
652 let out_dir = dir.join("extracted_out");
653
654 let data = "C".repeat(128 * 1024);
655 std::fs::write(&input_blob_path, &data).unwrap();
656
657 let merkle_hash = fuchsia_merkle::root_from_slice(data.as_bytes());
658
659 make_blob_image(
660 image_path.to_str().unwrap(),
661 Some(sparse_path.to_str().unwrap()),
662 vec![(merkle_hash, input_blob_path.clone())],
663 dir.join("blobs1.json").to_str().unwrap(),
664 None,
665 Some(CompressionAlgorithm::Zstd),
666 )
667 .await
668 .expect("make_blob_image failed");
669
670 extract_blobs(sparse_path, out_dir.clone())
671 .await
672 .expect("Extraction failed inside extract_blobs");
673
674 let mut extracted_files = std::fs::read_dir(&out_dir).expect("out_dir should exist");
675 let first_entry = extracted_files
676 .next()
677 .expect("No files were extracted!")
678 .expect("Failed to read directory entry");
679
680 let extracted_blob_path = first_entry.path();
681 let final_len = std::fs::metadata(&extracted_blob_path).unwrap().len();
682
683 assert_eq!(
684 final_len,
685 data.len() as u64,
686 "Decompressed data size does not match original size",
687 );
688 }
689
690 #[fuchsia::test(threads = 10)]
691 async fn test_extract_blobs_lz4() {
692 let tmp = TempDir::new().unwrap();
693 let dir = tmp.path();
694
695 let input_blob_path = dir.join("input.txt");
696 let image_path = dir.join("fxfs1.blk");
697 let sparse_path = dir.join("fxfs1.sparse.blk");
698 let out_dir = dir.join("extracted_out");
699
700 let data = "C".repeat(128 * 1024);
701 std::fs::write(&input_blob_path, &data).unwrap();
702
703 let merkle_hash = fuchsia_merkle::root_from_slice(data.as_bytes());
704
705 make_blob_image(
706 image_path.to_str().unwrap(),
707 Some(sparse_path.to_str().unwrap()),
708 vec![(merkle_hash, input_blob_path.clone())],
709 dir.join("blobs1.json").to_str().unwrap(),
710 None,
711 Some(CompressionAlgorithm::Lz4),
712 )
713 .await
714 .expect("make_blob_image failed");
715
716 extract_blobs(sparse_path, out_dir.clone())
717 .await
718 .expect("Extraction failed inside extract_blobs");
719
720 let mut extracted_files = std::fs::read_dir(&out_dir).expect("out_dir should exist");
721 let first_entry = extracted_files
722 .next()
723 .expect("No files were extracted!")
724 .expect("Failed to read directory entry");
725
726 let extracted_blob_path = first_entry.path();
727 let final_len = std::fs::metadata(&extracted_blob_path).unwrap().len();
728
729 assert_eq!(
730 final_len,
731 data.len() as u64,
732 "Decompressed data size does not match original size",
733 );
734 }
735
736 #[fuchsia::test(threads = 10)]
737 async fn test_make_blob_image() {
738 let tmp = TempDir::new().unwrap();
739 let dir = tmp.path();
740 let blobs_in = {
741 let write_data = |path, data: &str| {
742 let mut file = File::create(&path).unwrap();
743 write!(file, "{}", data).unwrap();
744 let root = fuchsia_merkle::root_from_slice(data);
745 (root, path)
746 };
747 vec![
748 write_data(dir.join("stuff1.txt"), "Goodbye, stranger!"),
749 write_data(dir.join("stuff2.txt"), "It's been nice!"),
750 write_data(dir.join("stuff3.txt"), from_utf8(&['a' as u8; 65_537]).unwrap()),
751 ]
752 };
753
754 let dir = tmp.path();
755 let output_path = dir.join("fxfs.blk");
756 let sparse_path = dir.join("fxfs.sparse.blk");
757 let blobs_json_path = dir.join("blobs.json");
758 make_blob_image(
759 output_path.as_os_str().to_str().unwrap(),
760 Some(sparse_path.as_os_str().to_str().unwrap()),
761 blobs_in,
762 blobs_json_path.as_os_str().to_str().unwrap(),
763 None,
764 Some(CompressionAlgorithm::Zstd),
765 )
766 .await
767 .expect("make_blob_image failed");
768
769 let mut blobs_json = std::fs::OpenOptions::new()
771 .read(true)
772 .open(blobs_json_path)
773 .expect("Failed to open blob manifest");
774 let mut blobs: BlobsJsonOutput =
775 serde_json::from_reader(&mut blobs_json).expect("Failed to serialize to JSON output");
776
777 assert_eq!(blobs.len(), 3);
778 blobs.sort_by_key(|entry| entry.source_path.clone());
779
780 assert_eq!(Path::new(blobs[0].source_path.as_str()), dir.join("stuff1.txt"));
781 assert_matches!(
782 &blobs[0],
783 BlobsJsonOutputEntry {
784 merkle,
785 bytes: 18,
786 size: 4096,
787 file_size: 18,
788 merkle_tree_size: 0,
789 used_space_in_blobfs: 4096,
790 ..
791 } if merkle == "9a24fe2fb8da617f39d303750bbe23f4e03a8b5f4d52bc90b2e5e9e44daddb3a"
792 );
793 assert_eq!(Path::new(blobs[1].source_path.as_str()), dir.join("stuff2.txt"));
794 assert_matches!(
795 &blobs[1],
796 BlobsJsonOutputEntry {
797 merkle,
798 bytes: 15,
799 size: 4096,
800 file_size: 15,
801 merkle_tree_size: 0,
802 used_space_in_blobfs: 4096,
803 ..
804 } if merkle == "deebe5d5a0a42a51a293b511d0368e6f2b4da522ee0f05c6ae728c77d904f916"
805 );
806 assert_eq!(Path::new(blobs[2].source_path.as_str()), dir.join("stuff3.txt"));
807 assert_matches!(
808 &blobs[2],
809 BlobsJsonOutputEntry {
810 merkle,
811 bytes: 65537,
812 size: 8192,
815 file_size: 65537,
816 merkle_tree_size: 308,
817 used_space_in_blobfs: 8192,
818 ..
819 } if merkle == "1194c76d2d3b61f29df97a85ede7b2fd2b293b452f53072356e3c5c939c8131d"
820 );
821
822 let unsparsed_image = {
823 let sparse_image = std::fs::OpenOptions::new().read(true).open(sparse_path).unwrap();
824 let mut reader = SparseReader::new(sparse_image).expect("Failed to parse sparse image");
825
826 let unsparsed_image_path = dir.join("fxfs.unsparsed.blk");
827 let mut unsparsed_image = std::fs::OpenOptions::new()
828 .read(true)
829 .write(true)
830 .create(true)
831 .open(unsparsed_image_path)
832 .unwrap();
833
834 std::io::copy(&mut reader, &mut unsparsed_image).expect("Failed to unsparse");
835 unsparsed_image.seek(SeekFrom::Start(0)).unwrap();
836 unsparsed_image
837 };
838
839 let orig_image = std::fs::OpenOptions::new()
840 .read(true)
841 .open(output_path.clone())
842 .expect("Failed to open image");
843
844 assert_eq!(unsparsed_image.metadata().unwrap().len(), orig_image.metadata().unwrap().len());
845
846 for image in [orig_image, unsparsed_image] {
848 let device = DeviceHolder::new(FileBackedDevice::new(image, 4096));
849 let filesystem = FxFilesystem::open(device).await.unwrap();
850 let root_volume = root_volume(filesystem.clone()).await.expect("Opening root volume");
851 let vol =
852 root_volume.volume("blob", StoreOptions::default()).await.expect("Opening volume");
853 let directory = Directory::open(&vol, vol.root_directory_object_id())
854 .await
855 .expect("Opening root dir");
856 let entries = {
857 let layer_set = directory.store().tree().layer_set();
858 let mut merger = layer_set.merger();
859 let mut iter = directory.iter(&mut merger).await.expect("iter failed");
860 let mut entries = vec![];
861 while let Some((name, _, _)) = iter.get() {
862 entries.push(name.to_string());
863 iter.advance().await.expect("advance failed");
864 }
865 entries
866 };
867 assert_eq!(
868 &entries[..],
869 &[
870 "1194c76d2d3b61f29df97a85ede7b2fd2b293b452f53072356e3c5c939c8131d",
871 "9a24fe2fb8da617f39d303750bbe23f4e03a8b5f4d52bc90b2e5e9e44daddb3a",
872 "deebe5d5a0a42a51a293b511d0368e6f2b4da522ee0f05c6ae728c77d904f916",
873 ]
874 );
875 }
876 }
877
878 #[fuchsia::test(threads = 10)]
879 async fn test_make_uncompressed_blob_image() {
880 let tmp = TempDir::new().unwrap();
881 let dir = tmp.path();
882 let path = dir.join("large_blob.txt");
883 let mut file = File::create(&path).unwrap();
884 let data = vec![0xabu8; 32 * 1024 * 1024];
885 file.write_all(&data).unwrap();
886 let root = fuchsia_merkle::root_from_slice(&data);
887 let blobs_in = vec![(root, path)];
888
889 let compressed_path = dir.join("fxfs-compressed.blk");
890 let blobs_json_path = dir.join("blobs.json");
891 make_blob_image(
892 compressed_path.as_os_str().to_str().unwrap(),
893 None,
894 blobs_in.clone(),
895 blobs_json_path.as_os_str().to_str().unwrap(),
896 None,
897 Some(CompressionAlgorithm::Zstd),
898 )
899 .await
900 .expect("make_blob_image failed");
901
902 let uncompressed_path = dir.join("fxfs-uncompressed.blk");
903 make_blob_image(
904 uncompressed_path.as_os_str().to_str().unwrap(),
905 None,
906 blobs_in,
907 blobs_json_path.as_os_str().to_str().unwrap(),
908 None,
909 None,
910 )
911 .await
912 .expect("make_blob_image failed");
913
914 assert!(
915 std::fs::metadata(compressed_path).unwrap().len()
916 < std::fs::metadata(uncompressed_path).unwrap().len()
917 )
918 }
919
920 #[fuchsia::test(threads = 10)]
921 async fn test_make_blob_image_with_target_size() {
922 const TARGET_SIZE: u64 = 200 * 1024 * 1024;
923 let tmp = TempDir::new().unwrap();
924 let dir = tmp.path();
925 let path = dir.join("large_blob.txt");
926 let mut file = File::create(&path).unwrap();
927 let data = vec![0xabu8; 8 * 1024 * 1024];
928 file.write_all(&data).unwrap();
929 let root = fuchsia_merkle::root_from_slice(&data);
930 let blobs_in = vec![(root, path)];
931
932 let image_path = dir.join("fxfs.blk");
933 let sparse_image_path = dir.join("fxfs.sparse.blk");
934 let blobs_json_path = dir.join("blobs.json");
935 make_blob_image(
936 image_path.as_os_str().to_str().unwrap(),
937 Some(sparse_image_path.as_os_str().to_str().unwrap()),
938 blobs_in.clone(),
939 blobs_json_path.as_os_str().to_str().unwrap(),
940 Some(200 * 1024 * 1024),
941 Some(CompressionAlgorithm::Zstd),
942 )
943 .await
944 .expect("make_blob_image failed");
945
946 let image_size = std::fs::metadata(image_path).unwrap().len();
949 let sparse_image_size = std::fs::metadata(sparse_image_path).unwrap().len();
950 assert_eq!(image_size, TARGET_SIZE);
951 assert!(sparse_image_size < TARGET_SIZE, "Sparse image size: {sparse_image_size}");
952 }
953
954 #[fuchsia::test(threads = 10)]
955 async fn test_extract_blobs_path_traversal() {
956 use super::{
957 BLOB_VOLUME_NAME, BLOCK_SIZE, BlobFormat, BlobMetadata, DirectWriter,
958 FxFilesystemBuilder, HandleOptions, LockKey, NewChildStoreOptions, SuperBlockInstance,
959 create_sparse_image,
960 };
961 use fxfs::object_handle::WriteBytes;
962 use fxfs::object_store::transaction::lock_keys;
963
964 let tmp = TempDir::new().unwrap();
965 let dir = tmp.path();
966 let image_size = 10 * 1024 * 1024;
967
968 let image_path = dir.join("malicious.blk");
969
970 let output_image = std::fs::OpenOptions::new()
972 .read(true)
973 .write(true)
974 .create(true)
975 .truncate(true)
976 .open(&image_path)
977 .unwrap();
978 output_image.set_len(image_size).unwrap();
979
980 let device =
981 DeviceHolder::new(FileBackedDevice::new(output_image, BLOCK_SIZE.get() as u32));
982 let fs = FxFilesystemBuilder::new()
983 .format(true)
984 .trim_config(None)
985 .image_builder_mode(Some(SuperBlockInstance::A))
986 .open(device)
987 .await
988 .unwrap();
989 fs.enable_allocations();
990 let root_volume = root_volume(fs.clone()).await.unwrap();
991 let vol = root_volume
992 .new_volume(BLOB_VOLUME_NAME, NewChildStoreOptions::default())
993 .await
994 .unwrap();
995 let blob_directory = Directory::open(&vol, vol.root_directory_object_id()).await.unwrap();
996
997 let malicious_name = "../prevent_escaped_write.txt";
999 let keys = lock_keys![LockKey::object(
1000 blob_directory.store().store_object_id(),
1001 blob_directory.object_id(),
1002 )];
1003 let mut transaction =
1004 blob_directory.store().new_transaction(keys, Default::default()).await.unwrap();
1005 let handle = blob_directory
1006 .create_child_file_with_options(
1007 &mut transaction,
1008 malicious_name,
1009 HandleOptions { skip_checksums: true, ..Default::default() },
1010 )
1011 .await
1012 .unwrap();
1013 transaction.commit().await.unwrap();
1014
1015 {
1017 let mut writer = DirectWriter::new(&handle, Default::default()).await;
1018 writer.write_bytes(b"malicious data").await.unwrap();
1019 writer.complete().await.unwrap();
1020 }
1021 let metadata = BlobMetadata { merkle_leaves: vec![], format: BlobFormat::Uncompressed };
1023 metadata.write_to(&handle).await.unwrap();
1024
1025 fs.close().await.unwrap();
1026
1027 let sparse_path = dir.join("malicious.sparse.blk");
1028 create_sparse_image(
1029 sparse_path.to_str().unwrap(),
1030 image_path.to_str().unwrap(),
1031 image_size,
1032 image_size,
1033 BLOCK_SIZE,
1034 )
1035 .unwrap();
1036
1037 let extract_dir = dir.join("normal_out");
1039 std::fs::create_dir(&extract_dir).unwrap();
1040
1041 let err = extract_blobs(sparse_path, extract_dir.clone()).await.unwrap_err();
1042 assert_eq!(err.to_string(), "Invalid blob name: ../prevent_escaped_write.txt");
1043
1044 let escaped_path = dir.join("prevent_escaped_write.txt");
1046 assert!(!escaped_path.exists());
1047 }
1048
1049 #[fuchsia::test(threads = 10)]
1050 async fn test_make_blob_image_deterministic_across_orders_and_version_stamp() {
1051 let tmp = TempDir::new().unwrap();
1052 let dir = tmp.path();
1053
1054 const META_FAR_SIZE: usize = 64 * 1024;
1058 let mut blobs = Vec::new();
1059 let sizes = [
1060 880 * 1024usize,
1061 880 * 1024,
1062 (1024 + 148) * 1024,
1063 1024,
1064 4096,
1065 64 * 1024,
1066 65_537,
1067 250 * 1024,
1068 700 * 1024,
1069 1300 * 1024,
1070 2500 * 1024,
1071 512 * 1024,
1072 1750 * 1024,
1073 ];
1074 for (i, &size) in sizes.iter().enumerate() {
1075 let path = dir.join(format!("blob_{i:02}.bin"));
1076 let mut state = (i as u64 + 1).wrapping_mul(0x9e3779b97f4a7c15);
1077 let mut data = vec![0u8; size];
1078 for chunk in data.chunks_mut(8) {
1079 state ^= state << 13;
1080 state ^= state >> 7;
1081 state ^= state << 17;
1082 let bytes = state.to_le_bytes();
1083 chunk.copy_from_slice(&bytes[..chunk.len()]);
1084 }
1085 std::fs::write(&path, &data).unwrap();
1086 let hash = fuchsia_merkle::root_from_slice(&data);
1087 blobs.push((hash, path));
1088 }
1089 let min_blob_hash = blobs.iter().map(|(h, _)| *h).min().unwrap();
1090 let max_blob_hash = blobs.iter().map(|(h, _)| *h).max().unwrap();
1091
1092 let meta_path = dir.join("meta.far");
1099 let mut meta_v1 = vec![0u8; META_FAR_SIZE];
1100 meta_v1[..19].copy_from_slice(b"8668399462445543315");
1101 let meta_hash_v1 = fuchsia_merkle::root_from_slice(&meta_v1);
1102 assert!(
1103 meta_hash_v1 < min_blob_hash,
1104 "expected meta_hash_v1 ({meta_hash_v1}) < min_blob_hash ({min_blob_hash})"
1105 );
1106 std::fs::write(&meta_path, &meta_v1).unwrap();
1107
1108 let mut blobs_forward = blobs.clone();
1109 blobs_forward.push((meta_hash_v1, meta_path.clone()));
1110
1111 let image1 = dir.join("fxfs_1.blk");
1112 let sparse1 = dir.join("fxfs_1.sparse.blk");
1113 let json1 = dir.join("blobs_1.json");
1114 make_blob_image(
1115 image1.to_str().unwrap(),
1116 Some(sparse1.to_str().unwrap()),
1117 blobs_forward,
1118 json1.to_str().unwrap(),
1119 None,
1120 Some(CompressionAlgorithm::Zstd),
1121 )
1122 .await
1123 .expect("make_blob_image 1 failed");
1124
1125 let mut meta_v2 = vec![0u8; META_FAR_SIZE];
1128 meta_v2[..19].copy_from_slice(b"8668399462445543330");
1129 let meta_hash_v2 = fuchsia_merkle::root_from_slice(&meta_v2);
1130 assert!(
1131 max_blob_hash < meta_hash_v2,
1132 "expected max_blob_hash ({max_blob_hash}) < meta_hash_v2 ({meta_hash_v2})"
1133 );
1134 std::fs::write(&meta_path, &meta_v2).unwrap();
1135
1136 let mut blobs_reversed = blobs;
1137 blobs_reversed.push((meta_hash_v2, meta_path.clone()));
1138 blobs_reversed.reverse();
1139
1140 let image2 = dir.join("fxfs_2.blk");
1141 let sparse2 = dir.join("fxfs_2.sparse.blk");
1142 let json2 = dir.join("blobs_2.json");
1143 make_blob_image(
1144 image2.to_str().unwrap(),
1145 Some(sparse2.to_str().unwrap()),
1146 blobs_reversed,
1147 json2.to_str().unwrap(),
1148 None,
1149 Some(CompressionAlgorithm::Zstd),
1150 )
1151 .await
1152 .expect("make_blob_image 2 failed");
1153
1154 assert_eq!(
1155 std::fs::metadata(&image1).unwrap().len(),
1156 std::fs::metadata(&image2).unwrap().len(),
1157 "Raw Fxfs image size should be deterministic"
1158 );
1159 assert_eq!(
1160 std::fs::metadata(&sparse1).unwrap().len(),
1161 std::fs::metadata(&sparse2).unwrap().len(),
1162 "Sparse Fxfs image size should be deterministic"
1163 );
1164
1165 let blobs_out_1: BlobsJsonOutput =
1166 serde_json::from_reader(std::fs::File::open(&json1).unwrap()).unwrap();
1167 let blobs_out_2: BlobsJsonOutput =
1168 serde_json::from_reader(std::fs::File::open(&json2).unwrap()).unwrap();
1169 assert_eq!(blobs_out_1.len(), blobs_out_2.len());
1170
1171 let meta_path_str = meta_path.to_str().unwrap();
1172 let mut found_meta = false;
1173 for (e1, e2) in blobs_out_1.iter().zip(blobs_out_2.iter()) {
1174 assert_eq!(e1.source_path, e2.source_path);
1175 assert_eq!(e1.bytes, e2.bytes);
1176 assert_eq!(e1.size, e2.size);
1177 assert_eq!(e1.file_size, e2.file_size);
1178 assert_eq!(e1.compressed_file_size, e2.compressed_file_size);
1179 assert_eq!(e1.merkle_tree_size, e2.merkle_tree_size);
1180 assert_eq!(e1.used_space_in_blobfs, e2.used_space_in_blobfs);
1181 if e1.source_path == meta_path_str {
1182 found_meta = true;
1183 assert_eq!(e1.bytes, META_FAR_SIZE);
1184 assert_eq!(e1.used_space_in_blobfs, 8192);
1185 assert_eq!(e1.merkle, meta_hash_v1.to_string());
1186 assert_eq!(e2.merkle, meta_hash_v2.to_string());
1187 } else {
1188 assert_eq!(e1, e2);
1189 }
1190 }
1191 assert!(found_meta, "meta.far entry should be present in blobs.json");
1192 }
1193}