1use anyhow::{Context, Error, anyhow};
6use delivery_blob::Type1Blob;
7pub use delivery_blob::compression::CompressionAlgorithm;
8use delivery_blob::compression::{ChunkedArchive, ChunkedArchiveOptions};
9use fuchsia_async as fasync;
10use fuchsia_merkle::{Hash, MerkleRootBuilder};
11use futures::{SinkExt as _, StreamExt as _, TryStreamExt as _, try_join};
12use fxfs::blob_metadata::{BlobFormat, BlobMetadata, BlobMetadataLeafHashCollector};
13use fxfs::errors::FxfsError;
14use fxfs::filesystem::{FxFilesystemBuilder, OpenFxFilesystem};
15use fxfs::object_handle::{ObjectHandle, ReadObjectHandle, WriteBytes};
16use fxfs::object_store::directory::Directory;
17use fxfs::object_store::journal::RESERVED_SPACE;
18use fxfs::object_store::journal::super_block::SuperBlockInstance;
19use fxfs::object_store::transaction::{LockKey, lock_keys};
20use fxfs::object_store::volume::root_volume;
21use fxfs::object_store::{
22 DataObjectHandle, DirectWriter, HandleOptions, NewChildStoreOptions, ObjectStore, StoreOptions,
23};
24use rayon::ThreadPoolBuilder;
25use rayon::prelude::*;
26use serde::{Deserialize, Serialize};
27use sparse::unsparse;
28use std::fs;
29use std::io::{BufWriter, Read, Write};
30use std::path::PathBuf;
31use storage_device::DeviceHolder;
32use storage_device::file_backed_device::FileBackedDevice;
33
34pub const BLOB_VOLUME_NAME: &str = "blob";
35
36const BLOCK_SIZE: u32 = 4096;
37
38const READ_BUFFER_SIZE: u64 = 512;
39
40#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
41struct BlobsJsonOutputEntry {
42 source_path: String,
43 merkle: String,
44 bytes: usize,
45 size: u64,
46 file_size: usize,
47 compressed_file_size: u64,
48 merkle_tree_size: usize,
49 used_space_in_blobfs: u64,
51}
52
53type BlobsJsonOutput = Vec<BlobsJsonOutputEntry>;
54
55pub async fn make_blob_image(
66 output_image_path: &str,
67 sparse_output_image_path: Option<&str>,
68 blobs: Vec<(Hash, PathBuf)>,
69 json_output_path: &str,
70 target_size: Option<u64>,
71 compression_algorithm: Option<CompressionAlgorithm>,
72) -> Result<(), Error> {
73 let output_image = std::fs::OpenOptions::new()
74 .read(true)
75 .write(true)
76 .create(true)
77 .truncate(true)
78 .open(output_image_path)?;
79
80 let mut target_size = target_size.unwrap_or_default();
81
82 if target_size > 0 && target_size < BLOCK_SIZE as u64 {
83 return Err(anyhow!("Size {} is too small", target_size));
84 }
85 if target_size % BLOCK_SIZE as u64 > 0 {
86 return Err(anyhow!("Invalid size {} is not block-aligned", target_size));
87 }
88 let block_count = if target_size != 0 {
89 output_image.set_len(target_size).context("Failed to resize image")?;
91 target_size / BLOCK_SIZE as u64
92 } else {
93 const FOUR_GIGS: u64 = 4 * 1024 * 1024 * 1024;
98 FOUR_GIGS / BLOCK_SIZE as u64
99 };
100
101 let device = DeviceHolder::new(FileBackedDevice::new_with_block_count(
102 output_image,
103 BLOCK_SIZE,
104 block_count,
105 ));
106 let fxblob = FxBlobBuilder::new(device).await?;
107 let blobs_json = install_blobs(&fxblob, blobs, compression_algorithm).await.map_err(|e| {
108 if target_size != 0 && FxfsError::NoSpace.matches(&e) {
109 e.context(format!(
110 "Configured image size {} is too small to fit the base system image.",
111 target_size
112 ))
113 } else {
114 e
115 }
116 })?;
117 let actual_size = fxblob.finalize().await?.1;
118
119 if target_size == 0 {
120 target_size = (actual_size + RESERVED_SPACE) * 2;
123 }
124
125 if let Some(sparse_path) = sparse_output_image_path {
126 create_sparse_image(sparse_path, output_image_path, actual_size, target_size, BLOCK_SIZE)
127 .context("Failed to create sparse image")?;
128 }
129
130 if target_size != actual_size {
131 debug_assert!(target_size > actual_size);
132 let output_image =
133 std::fs::OpenOptions::new().read(true).write(true).open(output_image_path)?;
134 output_image.set_len(target_size).context("Failed to resize image")?;
135 }
136
137 let mut json_output = BufWriter::new(
138 std::fs::File::create(json_output_path).context("Failed to create JSON output file")?,
139 );
140 serde_json::to_writer_pretty(&mut json_output, &blobs_json)
141 .context("Failed to serialize to JSON output")?;
142
143 Ok(())
144}
145
146fn create_sparse_image(
147 sparse_output_image_path: &str,
148 image_path: &str,
149 actual_size: u64,
150 target_size: u64,
151 block_size: u32,
152) -> Result<(), Error> {
153 let image = std::fs::OpenOptions::new()
154 .read(true)
155 .open(image_path)
156 .with_context(|| format!("Failed to open {:?}", image_path))?;
157 let mut output = std::fs::OpenOptions::new()
158 .read(true)
159 .write(true)
160 .create(true)
161 .truncate(true)
162 .open(sparse_output_image_path)
163 .with_context(|| format!("Failed to create {:?}", sparse_output_image_path))?;
164 sparse::builder::SparseImageBuilder::new()
165 .set_block_size(block_size)
166 .add_source(sparse::builder::DataSource::Reader {
167 reader: Box::new(image),
168 size: actual_size,
169 })
170 .add_source(sparse::builder::DataSource::Skip(target_size - actual_size))
171 .build(&mut output)
172 .map_err(anyhow::Error::from)
173}
174
175pub struct FxBlobBuilder {
177 blob_directory: Directory<ObjectStore>,
178 filesystem: OpenFxFilesystem,
179}
180
181impl FxBlobBuilder {
182 pub async fn new(device: DeviceHolder) -> Result<Self, Error> {
184 let filesystem = FxFilesystemBuilder::new()
185 .format(true)
186 .trim_config(None)
187 .image_builder_mode(Some(SuperBlockInstance::A))
188 .open(device)
189 .await
190 .context("Failed to format filesystem")?;
191 filesystem.enable_allocations();
192 let root_volume = root_volume(filesystem.clone()).await?;
193 let vol = root_volume
194 .new_volume(BLOB_VOLUME_NAME, NewChildStoreOptions::default())
195 .await
196 .context("Failed to create volume")?;
197 let blob_directory = Directory::open(&vol, vol.root_directory_object_id())
198 .await
199 .context("Unable to open root blob directory")?;
200 Ok(Self { blob_directory, filesystem })
201 }
202
203 pub async fn finalize(self) -> Result<(DeviceHolder, u64), Error> {
207 self.filesystem.close().await?;
208 let actual_size = self.filesystem.allocator().maximum_offset();
209 Ok((self.filesystem.take_device().await, actual_size))
210 }
211
212 pub async fn install_blob(
214 &self,
215 blob: &BlobToInstall,
216 ) -> Result<DataObjectHandle<ObjectStore>, Error> {
217 let handle;
218 let keys = lock_keys![LockKey::object(
219 self.blob_directory.store().store_object_id(),
220 self.blob_directory.object_id(),
221 )];
222 let mut transaction = self
223 .blob_directory
224 .store()
225 .new_transaction(keys, Default::default())
226 .await
227 .context("new transaction")?;
228 handle = self
229 .blob_directory
230 .create_child_file_with_options(
231 &mut transaction,
232 &blob.hash.to_string(),
233 HandleOptions { skip_checksums: true, ..Default::default() },
235 )
236 .await
237 .context("create child file")?;
238 transaction.commit().await.context("transaction commit")?;
239
240 {
242 let mut writer = DirectWriter::new(&handle, Default::default()).await;
243 match &blob.data {
244 BlobData::Uncompressed(data) => {
245 writer.write_bytes(data).await.context("write blob contents")?;
246 }
247 BlobData::CompressedZstd(archive) | BlobData::CompressedLz4(archive) => {
248 for chunk in archive.chunks() {
249 writer
250 .write_bytes(&chunk.compressed_data)
251 .await
252 .context("write blob contents")?;
253 }
254 }
255 }
256 writer.complete().await.context("flush blob contents")?;
257 }
258
259 blob.metadata.write_to(&handle).await.context("write blob metadata")?;
261
262 Ok(handle)
263 }
264
265 pub fn generate_blob(
267 &self,
268 data: Vec<u8>,
269 compression_algorithm: Option<CompressionAlgorithm>,
270 ) -> Result<BlobToInstall, Error> {
271 BlobToInstall::new(data, self.filesystem.block_size() as usize, compression_algorithm)
272 }
273}
274
275enum BlobData {
276 Uncompressed(Vec<u8>),
277 CompressedZstd(ChunkedArchive),
278 CompressedLz4(ChunkedArchive),
279}
280
281fn compressed_offsets(chunked_archive: &ChunkedArchive) -> Vec<u64> {
282 let mut offsets = Vec::with_capacity(chunked_archive.chunks().len());
283 let mut offset: u64 = 0;
284 for chunk in chunked_archive.chunks() {
285 offsets.push(offset);
286 offset += chunk.compressed_data.len() as u64;
287 }
288 offsets
289}
290
291pub struct BlobToInstall {
293 hash: Hash,
295 data: BlobData,
297 uncompressed_size: usize,
299 metadata: BlobMetadata,
301 source: Option<PathBuf>,
304}
305
306impl BlobToInstall {
307 pub fn new(
309 data: Vec<u8>,
310 fs_block_size: usize,
311 compression_algorithm: Option<CompressionAlgorithm>,
312 ) -> Result<Self, Error> {
313 let (hash, hashes) =
314 MerkleRootBuilder::new(BlobMetadataLeafHashCollector::new()).complete(&data);
315
316 let uncompressed_size = data.len();
317 let data = if let Some(compression_algorithm) = compression_algorithm {
318 maybe_compress(data, fs_block_size, compression_algorithm)
319 } else {
320 BlobData::Uncompressed(data)
321 };
322 let metadata = match &data {
323 BlobData::Uncompressed(_) => {
324 BlobMetadata { merkle_leaves: hashes, format: BlobFormat::Uncompressed }
325 }
326 BlobData::CompressedZstd(chunked_archive) => BlobMetadata {
327 merkle_leaves: hashes,
328 format: BlobFormat::ChunkedZstd {
329 uncompressed_size: uncompressed_size as u64,
330 chunk_size: chunked_archive.chunk_size() as u64,
331 compressed_offsets: compressed_offsets(&chunked_archive),
332 },
333 },
334 BlobData::CompressedLz4(chunked_archive) => BlobMetadata {
335 merkle_leaves: hashes,
336 format: BlobFormat::ChunkedLz4 {
337 uncompressed_size: uncompressed_size as u64,
338 chunk_size: chunked_archive.chunk_size() as u64,
339 compressed_offsets: compressed_offsets(&chunked_archive),
340 },
341 },
342 };
343 Ok(BlobToInstall { hash, data, uncompressed_size, metadata, source: None })
344 }
345
346 pub fn new_from_file(
349 path: PathBuf,
350 fs_block_size: usize,
351 compression_algorithm: Option<CompressionAlgorithm>,
352 ) -> Result<Self, Error> {
353 let mut data = Vec::new();
354 std::fs::File::open(&path)
355 .with_context(|| format!("Unable to open `{:?}'", path))?
356 .read_to_end(&mut data)
357 .with_context(|| format!("Unable to read contents of `{:?}'", path))?;
358 let blob = Self::new(data, fs_block_size, compression_algorithm)?;
359 Ok(Self { source: Some(path), ..blob })
360 }
361
362 pub fn hash(&self) -> Hash {
363 self.hash.clone()
364 }
365}
366
367async fn install_blobs(
368 fxblob: &FxBlobBuilder,
369 blobs: Vec<(Hash, PathBuf)>,
370 compression_algorithm: Option<CompressionAlgorithm>,
371) -> Result<BlobsJsonOutput, Error> {
372 let num_blobs = blobs.len();
373 let fs_block_size = fxblob.filesystem.block_size() as usize;
374 let (tx, rx) = futures::channel::mpsc::channel::<BlobToInstall>(0);
376 let num_threads: usize = std::thread::available_parallelism().unwrap().into();
378 let thread_pool = ThreadPoolBuilder::new().num_threads(num_threads).build().unwrap();
379 let generate = fasync::unblock(move || {
380 thread_pool.install(|| {
381 blobs.par_iter().try_for_each(|(hash, path)| {
382 let blob = BlobToInstall::new_from_file(
383 path.clone(),
384 fs_block_size,
385 compression_algorithm,
386 )?;
387 if &blob.hash != hash {
388 let calculated_hash = &blob.hash;
389 let path = path.display();
390 return Err(anyhow!(
391 "Hash mismatch for {path}: calculated={calculated_hash}, expected={hash}"
392 ));
393 }
394 futures::executor::block_on(tx.clone().send(blob))
395 .context("send blob to install task")
396 })
397 })?;
398 Ok(())
399 });
400 const MAX_INSTALL_CONCURRENCY: usize = 10;
402 let install = rx
403 .map(|blob| install_blob_with_json_output(fxblob, blob))
404 .buffer_unordered(MAX_INSTALL_CONCURRENCY)
405 .try_collect::<BlobsJsonOutput>();
406 let (installed_blobs, _) = try_join!(install, generate)?;
407 assert_eq!(installed_blobs.len(), num_blobs);
408 Ok(installed_blobs)
409}
410
411async fn install_blob_with_json_output(
412 fxblob: &FxBlobBuilder,
413 blob: BlobToInstall,
414) -> Result<BlobsJsonOutputEntry, Error> {
415 let handle = fxblob.install_blob(&blob).await?;
416 let properties = handle.get_properties().await.context("get properties")?;
417 let source_path = blob
418 .source
419 .expect("missing source path")
420 .to_str()
421 .context("blob path to utf8")?
422 .to_string();
423 Ok(BlobsJsonOutputEntry {
424 source_path,
425 merkle: blob.hash.to_string(),
426 bytes: blob.uncompressed_size,
427 size: properties.allocated_size,
428 file_size: blob.uncompressed_size,
429 compressed_file_size: properties.data_attribute_size,
430 merkle_tree_size: blob.metadata.serialized_size().context("blob metadata size")?,
431 used_space_in_blobfs: properties.allocated_size,
432 })
433}
434
435fn maybe_compress(
436 buf: Vec<u8>,
437 filesystem_block_size: usize,
438 compression_algorithm: CompressionAlgorithm,
439) -> BlobData {
440 if buf.len() <= filesystem_block_size {
441 return BlobData::Uncompressed(buf); }
443 let chunked_archive_options = match compression_algorithm {
444 CompressionAlgorithm::Zstd => {
445 Type1Blob::CHUNKED_ARCHIVE_OPTIONS
447 }
448 CompressionAlgorithm::Lz4 => ChunkedArchiveOptions::V3 { compression_algorithm },
449 };
450 let archive =
451 ChunkedArchive::new(&buf, chunked_archive_options).expect("failed to compress data");
452 if archive.compressed_data_size().checked_next_multiple_of(filesystem_block_size).unwrap()
453 >= buf.len()
454 {
455 BlobData::Uncompressed(buf) } else {
457 match compression_algorithm {
458 CompressionAlgorithm::Zstd => BlobData::CompressedZstd(archive),
459 CompressionAlgorithm::Lz4 => BlobData::CompressedLz4(archive),
460 }
461 }
462}
463
464pub async fn extract_blobs(image: PathBuf, out_dir: PathBuf) -> anyhow::Result<()> {
466 if out_dir.exists() {
467 fs::remove_dir_all(&out_dir).context("Failed to remove output directory")?;
468 }
469 fs::create_dir_all(&out_dir)?;
470
471 let mut source = fs::File::open(&image)?;
475 let mut non_sparse_image = tempfile::NamedTempFile::new_in(&out_dir)?;
476 unsparse(&mut source, non_sparse_image.as_file_mut()).map_err(anyhow::Error::from)?;
477
478 let device = DeviceHolder::new(FileBackedDevice::new(non_sparse_image.reopen()?, BLOCK_SIZE));
479 let fs = FxFilesystemBuilder::new().read_only(true).open(device).await?;
480 let vol =
481 root_volume(fs.clone()).await?.volume(BLOB_VOLUME_NAME, StoreOptions::default()).await?;
482 let root_dir = Directory::open(&vol, vol.root_directory_object_id()).await?;
483 let layer_set = root_dir.store().tree().layer_set();
484 let mut merger = layer_set.merger();
485 let mut iter = root_dir.iter(&mut merger).await?;
486 let blob_extraction_futures = futures::stream::FuturesUnordered::new();
487
488 while let Some((name, object_id, descriptor)) = iter.get() {
489 if *descriptor == fxfs::object_store::ObjectDescriptor::File {
490 let handle = fxfs::object_store::ObjectStore::open_object(
491 root_dir.owner(),
492 object_id,
493 fxfs::object_store::HandleOptions::default(),
494 None,
495 )
496 .await?;
497
498 let mut components = std::path::Path::new(name).components();
499 if !matches!(components.next(), Some(std::path::Component::Normal(..))) {
500 return Err(anyhow!("Invalid blob name: {}", name));
501 }
502 if components.next().is_some() {
503 return Err(anyhow!("Invalid blob name: {}", name));
504 }
505 let out_path = out_dir.join(name);
506 let mut file = std::fs::File::create(&out_path)?;
507 let mut read_buf = Vec::new();
508 let mut offset = 0;
509 let mut buf =
510 handle.allocate_buffer((handle.block_size() * READ_BUFFER_SIZE) as usize).await;
511 loop {
512 let bytes = handle.read(offset, buf.as_mut()).await?;
513 if bytes == 0 {
514 break;
515 }
516 offset += bytes as u64;
517 read_buf.extend_from_slice(&buf.as_slice()[..bytes]);
518 }
519
520 let metadata = BlobMetadata::read_from(&handle).await?;
521 blob_extraction_futures.push(fasync::unblock(move || -> Result<(), Error> {
522 match metadata.format {
523 BlobFormat::ChunkedZstd {
524 uncompressed_size,
525 compressed_offsets,
526 chunk_size,
527 } => decompress_blob(
528 &read_buf,
529 uncompressed_size,
530 compressed_offsets,
531 chunk_size,
532 CompressionAlgorithm::Zstd,
533 &mut file,
534 ),
535 BlobFormat::ChunkedLz4 {
536 uncompressed_size,
537 compressed_offsets,
538 chunk_size,
539 } => decompress_blob(
540 &read_buf,
541 uncompressed_size,
542 compressed_offsets,
543 chunk_size,
544 CompressionAlgorithm::Lz4,
545 &mut file,
546 ),
547 BlobFormat::Uncompressed => {
548 file.write_all(&read_buf)?;
549 Ok(())
550 }
551 }
552 }));
553 }
554 iter.advance().await?;
555 }
556 blob_extraction_futures.try_collect::<()>().await?;
557 Ok(())
558}
559
560fn decompress_blob(
561 blob_data: &[u8],
562 uncompressed_size: u64,
563 compressed_offsets: Vec<u64>,
564 chunk_size: u64,
565 compression_algorithm: CompressionAlgorithm,
566 out: &mut std::fs::File,
567) -> Result<(), Error> {
568 let mut decompressor = compression_algorithm.decompressor();
569 let mut buf = vec![0; chunk_size as usize];
570 let mut total_decompressed_size = 0;
571 for i in 0..compressed_offsets.len() {
572 let start_offset = compressed_offsets[i] as usize;
573 let end_offset = if i + 1 == compressed_offsets.len() {
574 blob_data.len()
575 } else {
576 compressed_offsets[i + 1] as usize
577 };
578 let decompressed_size =
579 decompressor.decompress_into(&blob_data[start_offset..end_offset], &mut buf, i)?;
580 total_decompressed_size += decompressed_size;
581 out.write_all(&buf[..decompressed_size])?;
582 }
583 if total_decompressed_size != uncompressed_size as usize {
584 Err(anyhow!(
585 "Decompressed size does not match expected size {} {}",
586 total_decompressed_size,
587 uncompressed_size
588 ))
589 } else {
590 Ok(())
591 }
592}
593
594#[cfg(test)]
595mod tests {
596 use super::{BlobsJsonOutput, BlobsJsonOutputEntry, extract_blobs, make_blob_image};
597 use assert_matches::assert_matches;
598 use delivery_blob::compression::CompressionAlgorithm;
599 use fuchsia_async as fasync;
600 use fxfs::filesystem::FxFilesystem;
601 use fxfs::object_store::StoreOptions;
602 use fxfs::object_store::directory::Directory;
603 use fxfs::object_store::volume::root_volume;
604 use sparse::reader::SparseReader;
605 use std::fs::File;
606 use std::io::{Seek as _, SeekFrom, Write};
607 use std::path::Path;
608 use std::str::from_utf8;
609 use storage_device::DeviceHolder;
610 use storage_device::file_backed_device::FileBackedDevice;
611 use tempfile::TempDir;
612
613 #[fasync::run(10, test)]
614 async fn test_extract_blobs_zstd() {
615 let tmp = TempDir::new().unwrap();
616 let dir = tmp.path();
617
618 let input_blob_path = dir.join("input.txt");
619 let image_path = dir.join("fxfs1.blk");
620 let sparse_path = dir.join("fxfs1.sparse.blk");
621 let out_dir = dir.join("extracted_out");
622
623 let data = "C".repeat(128 * 1024);
624 std::fs::write(&input_blob_path, &data).unwrap();
625
626 let merkle_hash = fuchsia_merkle::root_from_slice(data.as_bytes());
627
628 make_blob_image(
629 image_path.to_str().unwrap(),
630 Some(sparse_path.to_str().unwrap()),
631 vec![(merkle_hash, input_blob_path.clone())],
632 dir.join("blobs1.json").to_str().unwrap(),
633 None,
634 Some(CompressionAlgorithm::Zstd),
635 )
636 .await
637 .expect("make_blob_image failed");
638
639 extract_blobs(sparse_path, out_dir.clone())
640 .await
641 .expect("Extraction failed inside extract_blobs");
642
643 let mut extracted_files = std::fs::read_dir(&out_dir).expect("out_dir should exist");
644 let first_entry = extracted_files
645 .next()
646 .expect("No files were extracted!")
647 .expect("Failed to read directory entry");
648
649 let extracted_blob_path = first_entry.path();
650 let final_len = std::fs::metadata(&extracted_blob_path).unwrap().len();
651
652 assert_eq!(
653 final_len,
654 data.len() as u64,
655 "Decompressed data size does not match original size",
656 );
657 }
658
659 #[fasync::run(10, test)]
660 async fn test_extract_blobs_lz4() {
661 let tmp = TempDir::new().unwrap();
662 let dir = tmp.path();
663
664 let input_blob_path = dir.join("input.txt");
665 let image_path = dir.join("fxfs1.blk");
666 let sparse_path = dir.join("fxfs1.sparse.blk");
667 let out_dir = dir.join("extracted_out");
668
669 let data = "C".repeat(128 * 1024);
670 std::fs::write(&input_blob_path, &data).unwrap();
671
672 let merkle_hash = fuchsia_merkle::root_from_slice(data.as_bytes());
673
674 make_blob_image(
675 image_path.to_str().unwrap(),
676 Some(sparse_path.to_str().unwrap()),
677 vec![(merkle_hash, input_blob_path.clone())],
678 dir.join("blobs1.json").to_str().unwrap(),
679 None,
680 Some(CompressionAlgorithm::Lz4),
681 )
682 .await
683 .expect("make_blob_image failed");
684
685 extract_blobs(sparse_path, out_dir.clone())
686 .await
687 .expect("Extraction failed inside extract_blobs");
688
689 let mut extracted_files = std::fs::read_dir(&out_dir).expect("out_dir should exist");
690 let first_entry = extracted_files
691 .next()
692 .expect("No files were extracted!")
693 .expect("Failed to read directory entry");
694
695 let extracted_blob_path = first_entry.path();
696 let final_len = std::fs::metadata(&extracted_blob_path).unwrap().len();
697
698 assert_eq!(
699 final_len,
700 data.len() as u64,
701 "Decompressed data size does not match original size",
702 );
703 }
704
705 #[fasync::run(10, test)]
706 async fn test_make_blob_image() {
707 let tmp = TempDir::new().unwrap();
708 let dir = tmp.path();
709 let blobs_in = {
710 let write_data = |path, data: &str| {
711 let mut file = File::create(&path).unwrap();
712 write!(file, "{}", data).unwrap();
713 let root = fuchsia_merkle::root_from_slice(data);
714 (root, path)
715 };
716 vec![
717 write_data(dir.join("stuff1.txt"), "Goodbye, stranger!"),
718 write_data(dir.join("stuff2.txt"), "It's been nice!"),
719 write_data(dir.join("stuff3.txt"), from_utf8(&['a' as u8; 65_537]).unwrap()),
720 ]
721 };
722
723 let dir = tmp.path();
724 let output_path = dir.join("fxfs.blk");
725 let sparse_path = dir.join("fxfs.sparse.blk");
726 let blobs_json_path = dir.join("blobs.json");
727 make_blob_image(
728 output_path.as_os_str().to_str().unwrap(),
729 Some(sparse_path.as_os_str().to_str().unwrap()),
730 blobs_in,
731 blobs_json_path.as_os_str().to_str().unwrap(),
732 None,
733 Some(CompressionAlgorithm::Zstd),
734 )
735 .await
736 .expect("make_blob_image failed");
737
738 let mut blobs_json = std::fs::OpenOptions::new()
740 .read(true)
741 .open(blobs_json_path)
742 .expect("Failed to open blob manifest");
743 let mut blobs: BlobsJsonOutput =
744 serde_json::from_reader(&mut blobs_json).expect("Failed to serialize to JSON output");
745
746 assert_eq!(blobs.len(), 3);
747 blobs.sort_by_key(|entry| entry.source_path.clone());
748
749 assert_eq!(Path::new(blobs[0].source_path.as_str()), dir.join("stuff1.txt"));
750 assert_matches!(
751 &blobs[0],
752 BlobsJsonOutputEntry {
753 merkle,
754 bytes: 18,
755 size: 4096,
756 file_size: 18,
757 merkle_tree_size: 0,
758 used_space_in_blobfs: 4096,
759 ..
760 } if merkle == "9a24fe2fb8da617f39d303750bbe23f4e03a8b5f4d52bc90b2e5e9e44daddb3a"
761 );
762 assert_eq!(Path::new(blobs[1].source_path.as_str()), dir.join("stuff2.txt"));
763 assert_matches!(
764 &blobs[1],
765 BlobsJsonOutputEntry {
766 merkle,
767 bytes: 15,
768 size: 4096,
769 file_size: 15,
770 merkle_tree_size: 0,
771 used_space_in_blobfs: 4096,
772 ..
773 } if merkle == "deebe5d5a0a42a51a293b511d0368e6f2b4da522ee0f05c6ae728c77d904f916"
774 );
775 assert_eq!(Path::new(blobs[2].source_path.as_str()), dir.join("stuff3.txt"));
776 assert_matches!(
777 &blobs[2],
778 BlobsJsonOutputEntry {
779 merkle,
780 bytes: 65537,
781 size: 8192,
784 file_size: 65537,
785 merkle_tree_size: 308,
786 used_space_in_blobfs: 8192,
787 ..
788 } if merkle == "1194c76d2d3b61f29df97a85ede7b2fd2b293b452f53072356e3c5c939c8131d"
789 );
790
791 let unsparsed_image = {
792 let sparse_image = std::fs::OpenOptions::new().read(true).open(sparse_path).unwrap();
793 let mut reader = SparseReader::new(sparse_image).expect("Failed to parse sparse image");
794
795 let unsparsed_image_path = dir.join("fxfs.unsparsed.blk");
796 let mut unsparsed_image = std::fs::OpenOptions::new()
797 .read(true)
798 .write(true)
799 .create(true)
800 .open(unsparsed_image_path)
801 .unwrap();
802
803 std::io::copy(&mut reader, &mut unsparsed_image).expect("Failed to unsparse");
804 unsparsed_image.seek(SeekFrom::Start(0)).unwrap();
805 unsparsed_image
806 };
807
808 let orig_image = std::fs::OpenOptions::new()
809 .read(true)
810 .open(output_path.clone())
811 .expect("Failed to open image");
812
813 assert_eq!(unsparsed_image.metadata().unwrap().len(), orig_image.metadata().unwrap().len());
814
815 for image in [orig_image, unsparsed_image] {
817 let device = DeviceHolder::new(FileBackedDevice::new(image, 4096));
818 let filesystem = FxFilesystem::open(device).await.unwrap();
819 let root_volume = root_volume(filesystem.clone()).await.expect("Opening root volume");
820 let vol =
821 root_volume.volume("blob", StoreOptions::default()).await.expect("Opening volume");
822 let directory = Directory::open(&vol, vol.root_directory_object_id())
823 .await
824 .expect("Opening root dir");
825 let entries = {
826 let layer_set = directory.store().tree().layer_set();
827 let mut merger = layer_set.merger();
828 let mut iter = directory.iter(&mut merger).await.expect("iter failed");
829 let mut entries = vec![];
830 while let Some((name, _, _)) = iter.get() {
831 entries.push(name.to_string());
832 iter.advance().await.expect("advance failed");
833 }
834 entries
835 };
836 assert_eq!(
837 &entries[..],
838 &[
839 "1194c76d2d3b61f29df97a85ede7b2fd2b293b452f53072356e3c5c939c8131d",
840 "9a24fe2fb8da617f39d303750bbe23f4e03a8b5f4d52bc90b2e5e9e44daddb3a",
841 "deebe5d5a0a42a51a293b511d0368e6f2b4da522ee0f05c6ae728c77d904f916",
842 ]
843 );
844 }
845 }
846
847 #[fasync::run(10, test)]
848 async fn test_make_uncompressed_blob_image() {
849 let tmp = TempDir::new().unwrap();
850 let dir = tmp.path();
851 let path = dir.join("large_blob.txt");
852 let mut file = File::create(&path).unwrap();
853 let data = vec![0xabu8; 32 * 1024 * 1024];
854 file.write_all(&data).unwrap();
855 let root = fuchsia_merkle::root_from_slice(&data);
856 let blobs_in = vec![(root, path)];
857
858 let compressed_path = dir.join("fxfs-compressed.blk");
859 let blobs_json_path = dir.join("blobs.json");
860 make_blob_image(
861 compressed_path.as_os_str().to_str().unwrap(),
862 None,
863 blobs_in.clone(),
864 blobs_json_path.as_os_str().to_str().unwrap(),
865 None,
866 Some(CompressionAlgorithm::Zstd),
867 )
868 .await
869 .expect("make_blob_image failed");
870
871 let uncompressed_path = dir.join("fxfs-uncompressed.blk");
872 make_blob_image(
873 uncompressed_path.as_os_str().to_str().unwrap(),
874 None,
875 blobs_in,
876 blobs_json_path.as_os_str().to_str().unwrap(),
877 None,
878 None,
879 )
880 .await
881 .expect("make_blob_image failed");
882
883 assert!(
884 std::fs::metadata(compressed_path).unwrap().len()
885 < std::fs::metadata(uncompressed_path).unwrap().len()
886 )
887 }
888
889 #[fasync::run(10, test)]
890 async fn test_make_blob_image_with_target_size() {
891 const TARGET_SIZE: u64 = 200 * 1024 * 1024;
892 let tmp = TempDir::new().unwrap();
893 let dir = tmp.path();
894 let path = dir.join("large_blob.txt");
895 let mut file = File::create(&path).unwrap();
896 let data = vec![0xabu8; 8 * 1024 * 1024];
897 file.write_all(&data).unwrap();
898 let root = fuchsia_merkle::root_from_slice(&data);
899 let blobs_in = vec![(root, path)];
900
901 let image_path = dir.join("fxfs.blk");
902 let sparse_image_path = dir.join("fxfs.sparse.blk");
903 let blobs_json_path = dir.join("blobs.json");
904 make_blob_image(
905 image_path.as_os_str().to_str().unwrap(),
906 Some(sparse_image_path.as_os_str().to_str().unwrap()),
907 blobs_in.clone(),
908 blobs_json_path.as_os_str().to_str().unwrap(),
909 Some(200 * 1024 * 1024),
910 Some(CompressionAlgorithm::Zstd),
911 )
912 .await
913 .expect("make_blob_image failed");
914
915 let image_size = std::fs::metadata(image_path).unwrap().len();
918 let sparse_image_size = std::fs::metadata(sparse_image_path).unwrap().len();
919 assert_eq!(image_size, TARGET_SIZE);
920 assert!(sparse_image_size < TARGET_SIZE, "Sparse image size: {sparse_image_size}");
921 }
922
923 #[fasync::run(10, test)]
924 async fn test_extract_blobs_path_traversal() {
925 use super::{
926 BLOB_VOLUME_NAME, BLOCK_SIZE, BlobFormat, BlobMetadata, DirectWriter,
927 FxFilesystemBuilder, HandleOptions, LockKey, NewChildStoreOptions, SuperBlockInstance,
928 create_sparse_image,
929 };
930 use fxfs::object_handle::WriteBytes;
931 use fxfs::object_store::transaction::lock_keys;
932
933 let tmp = TempDir::new().unwrap();
934 let dir = tmp.path();
935 let image_size = 10 * 1024 * 1024;
936
937 let image_path = dir.join("malicious.blk");
938
939 let output_image = std::fs::OpenOptions::new()
941 .read(true)
942 .write(true)
943 .create(true)
944 .truncate(true)
945 .open(&image_path)
946 .unwrap();
947 output_image.set_len(image_size).unwrap();
948
949 let device = DeviceHolder::new(FileBackedDevice::new(output_image, BLOCK_SIZE));
950 let fs = FxFilesystemBuilder::new()
951 .format(true)
952 .trim_config(None)
953 .image_builder_mode(Some(SuperBlockInstance::A))
954 .open(device)
955 .await
956 .unwrap();
957 fs.enable_allocations();
958 let root_volume = root_volume(fs.clone()).await.unwrap();
959 let vol = root_volume
960 .new_volume(BLOB_VOLUME_NAME, NewChildStoreOptions::default())
961 .await
962 .unwrap();
963 let blob_directory = Directory::open(&vol, vol.root_directory_object_id()).await.unwrap();
964
965 let malicious_name = "../prevent_escaped_write.txt";
967 let keys = lock_keys![LockKey::object(
968 blob_directory.store().store_object_id(),
969 blob_directory.object_id(),
970 )];
971 let mut transaction =
972 blob_directory.store().new_transaction(keys, Default::default()).await.unwrap();
973 let handle = blob_directory
974 .create_child_file_with_options(
975 &mut transaction,
976 malicious_name,
977 HandleOptions { skip_checksums: true, ..Default::default() },
978 )
979 .await
980 .unwrap();
981 transaction.commit().await.unwrap();
982
983 {
985 let mut writer = DirectWriter::new(&handle, Default::default()).await;
986 writer.write_bytes(b"malicious data").await.unwrap();
987 writer.complete().await.unwrap();
988 }
989 let metadata = BlobMetadata { merkle_leaves: vec![], format: BlobFormat::Uncompressed };
991 metadata.write_to(&handle).await.unwrap();
992
993 fs.close().await.unwrap();
994
995 let sparse_path = dir.join("malicious.sparse.blk");
996 create_sparse_image(
997 sparse_path.to_str().unwrap(),
998 image_path.to_str().unwrap(),
999 image_size,
1000 image_size,
1001 BLOCK_SIZE,
1002 )
1003 .unwrap();
1004
1005 let extract_dir = dir.join("normal_out");
1007 std::fs::create_dir(&extract_dir).unwrap();
1008
1009 let err = extract_blobs(sparse_path, extract_dir.clone()).await.unwrap_err();
1010 assert_eq!(err.to_string(), "Invalid blob name: ../prevent_escaped_write.txt");
1011
1012 let escaped_path = dir.join("prevent_escaped_write.txt");
1014 assert!(!escaped_path.exists());
1015 }
1016}