Skip to main content

fxfs_make_blob_image/
lib.rs

1// Copyright 2023 The Fuchsia Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5#![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    // For consistency with the legacy blobfs tooling, we still use the name `blobfs`.
53    used_space_in_blobfs: u64,
54}
55
56type BlobsJsonOutput = Vec<BlobsJsonOutputEntry>;
57
58/// Generates an Fxfs image containing a blob volume with the blobs specified in `manifest_path`.
59/// Creates the block image at `output_image_path` and writes a blobs.json file to
60/// `json_output_path`.
61/// If `target_size` bytes is set, the raw image will be set to exactly this size (and an error is
62/// returned if the contents exceed that size).  If unset (or 0), the image will be truncated to
63/// twice the size of its contents, which is a heuristic that gives us roughly enough space for
64/// normal usage of the image.
65/// If `sparse_output_image_path` is set, an image will also be emitted in the Android sparse
66/// format, which is suitable for flashing via fastboot.  The sparse image's logical size and
67/// contents are identical to the raw image, but its actual size will likely be smaller.
68pub 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        // Truncate the image to the target size now.
93        output_image.set_len(target_size).context("Failed to resize image")?;
94        target_size / BLOCK_SIZE
95    } else {
96        // Arbitrarily use 4GiB for the initial block device size, but don't truncate the file yet,
97        // so it becomes exactly as large as needed to contain the contents.  We'll truncate it down
98        // to 2x contents later.
99        // 4G just needs to be large enough to fit pretty much any image.
100        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        // Apply a default heuristic of 2x the actual image size.  This is necessary to use the
124        // Fxfs image, since if it's completely full it can't be modified.
125        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
178/// Builder used to construct a new Fxblob instance ready for flashing to a device.
179pub struct FxBlobBuilder {
180    blob_directory: Directory<ObjectStore>,
181    filesystem: OpenFxFilesystem,
182}
183
184impl FxBlobBuilder {
185    /// Creates a new [`FxBlobBuilder`] backed by the given `device`.
186    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    /// Finalizes building the FxBlob instance this builder represents. The filesystem will not be
207    /// usable unless this is called. Returns the filesystem's DeviceHolder and the last offset in
208    /// bytes which was used on the device.
209    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    /// Installs the given `blob` into the filesystem, returning a handle to the new object.
216    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                // Checksums are redundant for blobs, which are already content-verified.
237                HandleOptions { skip_checksums: true, ..Default::default() },
238            )
239            .await
240            .context("create child file")?;
241        transaction.commit().await.context("transaction commit")?;
242
243        // Write the blob data directly into the object handle.
244        {
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        // Write the metadata to the object handle.
263        blob.metadata.write_to(&handle).await.context("write blob metadata")?;
264
265        Ok(handle)
266    }
267
268    /// Helper function to quickly create a blob to install from in-memory data. Mainly for testing.
269    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
294/// Represents a blob ready to be installed into an FxBlob instance.
295pub struct BlobToInstall {
296    /// The validated Merkle root of this blob.
297    hash: Hash,
298    /// On-disk representation of the blob data (either compressed or uncompressed).
299    data: BlobData,
300    /// Uncompressed size of the blob's data.
301    uncompressed_size: usize,
302    /// Holds the merkle leaves and compressed offsets.
303    metadata: BlobMetadata,
304    /// Path, if any, corresponding to the on-disk location of the source for this blob. Only set
305    /// if created via [`Self::new_from_file`].
306    source: Option<PathBuf>,
307}
308
309impl BlobToInstall {
310    /// Create a new blob ready for installation with [`FxBlobBuilder::install_blob`].
311    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    /// Create a new blob ready for installation with [`FxBlobBuilder::install_blob`] from an
350    /// existing file on disk.
351    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    // Sort primarily by source path (with hash as a tie-breaker) so that blobs whose contents and
378    // hashes change across builds (such as version-stamped `meta.far` files) remain in the same
379    // installation order and do not perturb allocator layout.
380    blobs.sort_unstable_by(|(h1, p1), (h2, p2)| p1.cmp(p2).then_with(|| h1.cmp(h2)));
381
382    // Generate blobs in parallel on a Rayon thread pool within a bounded sliding window
383    // `[next_idx .. next_idx + MAX_IN_FLIGHT_BLOBS)`. If a completed blob is the head blob
384    // (`idx == next_idx`), it skips buffering and writes directly to the image (followed by any
385    // contiguous ready blobs). Intra-blob chunk compression in `ChunkedArchive::new` also fans out
386    // across all `num_threads` workers on the shared pool.
387    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); // No savings, return original data.
481    }
482    let chunked_archive_options = match compression_algorithm {
483        CompressionAlgorithm::Zstd => {
484            // TODO(https://fxbug.dev/450626615) Use chunked-compression V3.
485            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) // Compression expanded the file, return original data.
495    } else {
496        match compression_algorithm {
497            CompressionAlgorithm::Zstd => BlobData::CompressedZstd(archive),
498            CompressionAlgorithm::Lz4 => BlobData::CompressedLz4(archive),
499        }
500    }
501}
502
503/// Extract blobs from the Fxfs image in the product bundle to the output directory.
504pub 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    // TODO (https://fxbug.dev/483735826):
511    // Update the fxfs crate so that you can hand it a sparse image and
512    // it will be able to parse that and iterate over the contents
513    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            /*target_size=*/ None,
764            Some(CompressionAlgorithm::Zstd),
765        )
766        .await
767        .expect("make_blob_image failed");
768
769        // Check that the blob manifest contains the entries we expect.
770        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                // This is technically sensitive to compression, but a string of 'a' should
813                // always compress down to a single block.
814                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        // Verify the images created are valid Fxfs images and contains the blobs we expect.
847        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            /*target_size=*/ 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            /*target_size=*/ None,
909            /*compression_algorithm=*/ 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            /*target_size=*/ Some(200 * 1024 * 1024),
941            Some(CompressionAlgorithm::Zstd),
942        )
943        .await
944        .expect("make_blob_image failed");
945
946        // The fxfs image is small but gets padded with zeros up to the target size. The zeros
947        // should be replaced with a don't care chunk in the sparse format making it much smaller.
948        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        // Create a minimal Fxfs image with a malicious filename.
971        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        // Create a file with a malicious name.
998        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        // Write some placeholder data.
1016        {
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        // Write uncompressed metadata (simplest).
1022        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        // Now try to extract it. It should fail.
1038        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        // Ensure the file was NOT created outside the output directory.
1045        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        // Create a mix of small, medium, and multi-MiB incompressible blobs that span multiple
1055        // 1 MiB DirectWriter chunks and 2 MiB BestFit allocator slabs (including 880 KiB + 1172 KiB
1056        // combinations that pack 2 MiB slabs differently if reordered).
1057        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        // Include a 64 KiB FAR-like "meta.far" blob (mostly zero-padded, so it Zstd-compresses
1093        // down to a single 4 KiB block on disk) whose build stamp (and Merkle hash) changes
1094        // between builds while its path remains identical.
1095        // Choose two build stamps such that `meta_hash_v1 < min_blob_hash` and
1096        // `max_blob_hash < meta_hash_v2`, so that sorting by `hash` would shift `meta.far`'s
1097        // installation position across the other blobs while sorting by `(path, hash)` preserves it.
1098        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        // Now update `meta.far` with a different build ID (changing its Merkle root so it sorts
1126        // after `max_blob_hash`) and reverse the input blob order.
1127        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}