1use crate::common::{AVG_CHUNK_SIZE, MAX_CHUNK_SIZE, MIN_CHUNK_SIZE};
2use crate::compression::{Compression, Noop, Zstd};
3use crate::fsverity_helpers::{
4 check_fs_verity, fsverity_enable, InnerHashAlgorithm, FS_VERITY_BLOCK_SIZE_DEFAULT,
5};
6use crate::oci::Digest;
7use std::any::Any;
8use std::backtrace::Backtrace;
9use std::cmp::min;
10use std::collections::{BTreeMap, HashMap};
11use std::ffi::{OsStr, OsString};
12use std::fs;
13use std::io;
14use std::os::fd::AsRawFd;
15use std::os::unix::ffi::{OsStrExt, OsStringExt};
16use std::os::unix::fs::MetadataExt;
17use std::path::Path;
18use std::sync::Arc;
19
20use walkdir::WalkDir;
21
22use crate::format::{
23 BlobRef, DirEnt, DirList, FileChunk, FileChunkList, Ino, Inode, InodeAdditional, InodeMode,
24 Result, Rootfs, VerityData, WireFormatError,
25};
26use crate::metadata_capnp;
27use crate::oci::media_types;
28use crate::oci::{Descriptor, Image};
29use crate::reader::{PuzzleFS, PUZZLEFS_IMAGE_MANIFEST_VERSION};
30use ocidir::oci_spec::image::{ImageManifest, Platform};
31
32use nix::errno::Errno;
33
34use fastcdc::v2020::StreamCDC;
35mod filesystem;
36use filesystem::FilesystemStream;
37
38fn walker(rootfs: &Path) -> WalkDir {
39 WalkDir::new(rootfs)
43 .contents_first(false)
44 .follow_links(false)
45 .same_file_system(true)
46 .sort_by(|a, b| a.file_name().cmp(b.file_name()))
47}
48
49struct Dir {
52 ino: u64,
53 dir_list: DirList,
54 md: fs::Metadata,
55 additional: Option<InodeAdditional>,
56}
57
58impl Dir {
59 fn add_entry(&mut self, name: OsString, ino: Ino) {
60 self.dir_list.entries.push(DirEnt {
61 name: OsString::into_vec(name),
62 ino,
63 });
64 }
65}
66
67struct File {
69 ino: u64,
70 chunk_list: FileChunkList,
71 md: fs::Metadata,
72 additional: Option<InodeAdditional>,
73}
74
75struct Other {
76 ino: u64,
77 md: fs::Metadata,
78 additional: Option<InodeAdditional>,
79}
80
81fn serialize_metadata(rootfs: Rootfs) -> Result<Vec<u8>> {
82 let mut message = ::capnp::message::Builder::new_default();
83 let mut capnp_rootfs = message.init_root::<metadata_capnp::rootfs::Builder<'_>>();
84
85 rootfs.fill_capnp(&mut capnp_rootfs)?;
86
87 let mut buf = Vec::new();
88 ::capnp::serialize::write_message(&mut buf, &message)?;
89 Ok(buf)
90}
91
92fn process_chunks<C: Compression + Any>(
93 oci: &Image,
94 mut chunker: StreamCDC,
95 files: &mut [File],
96 verity_data: &mut VerityData,
97 image_manifest: &mut ImageManifest,
98) -> Result<()> {
99 let mut file_iter = files.iter_mut();
100 let mut file_used = 0;
101 let mut file = None;
102 for f in file_iter.by_ref() {
103 if f.md.size() > 0 {
104 file = Some(f);
105 break;
106 }
107 }
108
109 'outer: for result in &mut chunker {
110 let chunk = result.unwrap();
111 let mut chunk_used: u64 = 0;
112
113 let (desc, fs_verity_digest, compressed) =
114 oci.put_blob::<C>(&chunk.data, image_manifest, media_types::Chunk {})?;
115 let digest = Digest::try_from(desc.digest().digest())?.underlying();
116
117 let verity_hash = fs_verity_digest;
118 verity_data.insert(digest, verity_hash);
119
120 while chunk_used < chunk.length as u64 {
121 let room = min(
122 file.as_ref().unwrap().md.len() - file_used,
123 chunk.length as u64 - chunk_used,
124 );
125
126 let blob = BlobRef {
127 offset: chunk_used,
128 digest,
129 compressed,
130 };
131
132 file.as_mut()
133 .unwrap()
134 .chunk_list
135 .chunks
136 .push(FileChunk { blob, len: room });
137
138 chunk_used += room;
139 file_used += room;
140
141 if file_used == file.as_ref().unwrap().md.len() {
143 file_used = 0;
144 file = None;
145
146 for f in file_iter.by_ref() {
147 if f.md.size() > 0 {
148 file = Some(f);
149 break;
150 }
151 }
152
153 if file.is_none() {
154 break 'outer;
155 }
156 }
157 }
158 }
159
160 assert!(chunker.next().is_none());
162
163 Ok(())
164}
165
166fn build_delta<C: Compression + Any>(
167 rootfs: &Path,
168 oci: &Image,
169 mut existing: Option<PuzzleFS>,
170 verity_data: &mut VerityData,
171 image_manifest: &mut ImageManifest,
172) -> Result<Vec<Inode>> {
173 let mut dirs = HashMap::<u64, Dir>::new();
174 let mut files = Vec::<File>::new();
175 let mut others = Vec::<Other>::new();
176 let mut pfs_inodes = Vec::<Inode>::new();
177 let mut fs_stream = FilesystemStream::new();
178
179 let mut host_to_pfs = HashMap::<u64, Ino>::new();
181
182 let mut next_ino: u64 = existing
183 .as_mut()
184 .map(|pfs| pfs.max_inode().map(|i| i + 1))
185 .unwrap_or_else(|| Ok(2))?;
186
187 fn lookup_existing(existing: &mut Option<PuzzleFS>, p: &Path) -> Result<Option<Inode>> {
188 existing
189 .as_mut()
190 .map(|pfs| pfs.lookup(p))
191 .transpose()
192 .map(|o| o.flatten())
193 }
194
195 let rootfs_dirs = walker(rootfs)
196 .into_iter()
197 .filter_entry(|de| de.metadata().map(|md| md.is_dir()).unwrap_or(true));
198
199 let root_metadata = fs::symlink_metadata(rootfs)?;
202 let root_additional = InodeAdditional::new(rootfs, &root_metadata)?;
203 dirs.insert(
204 root_metadata.ino(),
205 Dir {
206 ino: 1,
207 md: root_metadata,
208 dir_list: DirList {
209 entries: Vec::<DirEnt>::new(),
210 look_below: false,
211 },
212 additional: root_additional,
213 },
214 );
215
216 let rootfs_relative = |p: &Path| {
217 Path::new("/").join(p.strip_prefix(rootfs).unwrap())
220 };
221
222 for dir in rootfs_dirs {
223 let d = dir.map_err(io::Error::from)?;
224 let dir_path = rootfs_relative(d.path());
225 let existing_dirents: Vec<_> = lookup_existing(&mut existing, &dir_path)?
226 .and_then(|ex| -> Option<Vec<_>> {
227 if let InodeMode::Dir { dir_list } = ex.mode {
228 Some(dir_list.entries)
229 } else {
230 None
231 }
232 })
233 .unwrap_or_default();
234
235 let mut new_dirents = fs::read_dir(d.path())?.collect::<io::Result<Vec<fs::DirEntry>>>()?;
236 new_dirents.sort_by_key(|a| a.file_name());
238
239 let this_metadata = fs::symlink_metadata(d.path())?;
241 let this_dir = dirs
242 .get_mut(&this_metadata.ino())
243 .ok_or_else(|| WireFormatError::from_errno(Errno::ENOENT))?;
244 for dir_ent in existing_dirents {
245 if !(new_dirents).iter().any(|new| {
246 new.path().file_name().unwrap_or_else(|| OsStr::new(""))
247 == OsStr::from_bytes(&dir_ent.name)
248 }) {
249 pfs_inodes.push(Inode::new_whiteout(dir_ent.ino));
250 this_dir.add_entry(OsString::from_vec(dir_ent.name), dir_ent.ino);
251 }
252 }
253
254 for e in new_dirents {
255 let md = e.metadata()?;
256
257 let existing_inode = existing
258 .as_mut()
259 .map(|pfs| {
260 let puzzlefs_path = rootfs_relative(&e.path());
261 pfs.lookup(&puzzlefs_path)
262 })
263 .transpose()?
264 .flatten();
265
266 let cur_ino = existing_inode.map(|ex| ex.ino).unwrap_or_else(|| {
267 let next = next_ino;
268 next_ino += 1;
269 next
270 });
271
272 if cur_ino != 1 {
275 let the_ino = host_to_pfs.get(&md.ino()).copied().unwrap_or(cur_ino);
278 let parent_path = e.path().parent().map(|p| p.to_path_buf()).ok_or_else(|| {
279 io::Error::new(
280 io::ErrorKind::Other,
281 format!("no parent for {}", e.path().display()),
282 )
283 })?;
284 let parent = dirs
285 .get_mut(&fs::symlink_metadata(parent_path)?.ino())
286 .ok_or_else(|| {
287 io::Error::new(
288 io::ErrorKind::Other,
289 format!("no pfs inode for {}", e.path().display()),
290 )
291 })?;
292 parent.add_entry(
293 e.path()
294 .file_name()
295 .unwrap_or_else(|| OsStr::new(""))
296 .to_os_string(),
297 the_ino,
298 );
299
300 if host_to_pfs.contains_key(&md.ino()) {
302 continue;
303 }
304 }
305
306 host_to_pfs.insert(md.ino(), cur_ino);
307
308 let additional = InodeAdditional::new(&e.path(), &md)?;
313
314 if md.is_dir() {
315 dirs.insert(
316 md.ino(),
317 Dir {
318 ino: cur_ino,
319 md,
320 dir_list: DirList {
321 entries: Vec::<DirEnt>::new(),
322 look_below: false,
323 },
324 additional,
325 },
326 );
327 } else if md.is_file() {
328 fs_stream.push(&e.path());
329
330 let file = File {
331 ino: cur_ino,
332 md,
333 chunk_list: FileChunkList {
334 chunks: Vec::<FileChunk>::new(),
335 },
336 additional,
337 };
338
339 files.push(file);
340 } else {
341 let o = Other {
342 ino: cur_ino,
343 md,
344 additional,
345 };
346 others.push(o);
347 }
348 }
349 }
350
351 let fcdc = StreamCDC::new(
352 Box::new(fs_stream),
353 MIN_CHUNK_SIZE,
354 AVG_CHUNK_SIZE,
355 MAX_CHUNK_SIZE,
356 );
357 process_chunks::<C>(oci, fcdc, &mut files, verity_data, image_manifest)?;
358
359 let mut sorted_dirs = dirs.into_values().collect::<Vec<_>>();
361
362 pfs_inodes.extend(
364 sorted_dirs
365 .drain(..)
366 .map(|d| Ok(Inode::new_dir(d.ino, &d.md, d.dir_list, d.additional)?))
367 .collect::<Result<Vec<Inode>>>()?,
368 );
369
370 pfs_inodes.extend(
372 files
373 .drain(..)
374 .map(|f| {
375 Ok(Inode::new_file(
376 f.ino,
377 &f.md,
378 f.chunk_list.chunks,
379 f.additional,
380 )?)
381 })
382 .collect::<Result<Vec<Inode>>>()?,
383 );
384
385 pfs_inodes.extend(
386 others
387 .drain(..)
388 .map(|o| Ok(Inode::new_other(o.ino, &o.md, o.additional)?))
389 .collect::<Result<Vec<Inode>>>()?,
390 );
391
392 pfs_inodes.sort_by(|a, b| a.ino.cmp(&b.ino));
393
394 Ok(pfs_inodes)
395}
396
397pub fn build_initial_rootfs<C: Compression + Any>(
398 rootfs: &Path,
399 oci: &Image,
400 tag: &str,
401) -> Result<Descriptor> {
402 let mut verity_data: VerityData = BTreeMap::new();
403 let mut image_manifest = oci.get_empty_manifest()?;
404 let inodes = build_delta::<C>(rootfs, oci, None, &mut verity_data, &mut image_manifest)?;
405
406 let rootfs_buf = serialize_metadata(Rootfs {
407 metadatas: vec![inodes],
408 fs_verity_data: verity_data,
409 manifest_version: PUZZLEFS_IMAGE_MANIFEST_VERSION,
410 })?;
411
412 let rootfs_descriptor = oci
413 .put_blob::<Noop>(
414 rootfs_buf.as_slice(),
415 &mut image_manifest,
416 media_types::Rootfs {},
417 )?
418 .0;
419 oci.0
420 .insert_manifest(image_manifest, Some(tag), Platform::default())?;
421
422 Ok(rootfs_descriptor)
423}
424
425pub fn add_rootfs_delta<C: Compression + Any>(
428 rootfs_path: &Path,
429 oci: Image,
430 tag: &str,
431 base_layer: &str,
432) -> Result<(Descriptor, Arc<Image>)> {
433 let mut verity_data: VerityData = BTreeMap::new();
434 let mut image_manifest = oci.get_empty_manifest()?;
435
436 let pfs = PuzzleFS::open(oci, base_layer, None)?;
437 let oci = Arc::clone(&pfs.oci);
438 let mut rootfs = Rootfs::try_from(oci.open_rootfs_blob(base_layer, None)?)?;
439
440 let inodes = build_delta::<C>(
441 rootfs_path,
442 &oci,
443 Some(pfs),
444 &mut verity_data,
445 &mut image_manifest,
446 )?;
447
448 if !rootfs.metadatas.iter().any(|x| *x == inodes) {
449 rootfs.metadatas.insert(0, inodes);
450 }
451
452 rootfs.fs_verity_data.extend(verity_data);
453 let rootfs_buf = serialize_metadata(rootfs)?;
454 let rootfs_descriptor = oci
455 .put_blob::<Noop>(
456 rootfs_buf.as_slice(),
457 &mut image_manifest,
458 media_types::Rootfs {},
459 )?
460 .0;
461 oci.0
462 .insert_manifest(image_manifest, Some(tag), Platform::default())?;
463 Ok((rootfs_descriptor, oci))
464}
465
466fn enable_verity_for_file(file: &cap_std::fs::File) -> Result<()> {
467 if let Err(e) = fsverity_enable(
468 file.as_raw_fd(),
469 FS_VERITY_BLOCK_SIZE_DEFAULT,
470 InnerHashAlgorithm::Sha256,
471 &[],
472 ) {
473 if e.kind() != std::io::ErrorKind::AlreadyExists {
475 return Err(WireFormatError::from(e));
476 }
477 }
478 Ok(())
479}
480
481fn enable_and_check_verity_for_file(file: &cap_std::fs::File, expected: &[u8]) -> Result<()> {
482 enable_verity_for_file(file)?;
483 check_fs_verity(file, expected)
484}
485
486pub fn enable_fs_verity(oci: Image, tag: &str, manifest_root_hash: &str) -> Result<()> {
487 let manifest_fd = oci.get_image_manifest_fd(tag)?;
489 enable_and_check_verity_for_file(&manifest_fd, &hex::decode(manifest_root_hash)?[..])?;
490
491 let pfs = PuzzleFS::open(oci, tag, None)?;
492 let oci = Arc::clone(&pfs.oci);
493 let rootfs = oci.open_rootfs_blob(tag, None)?;
494
495 let rootfs_fd = oci.get_pfs_rootfs(tag, None)?;
496 let rootfs_verity = oci.get_pfs_rootfs_verity(tag)?;
497
498 enable_and_check_verity_for_file(&rootfs_fd, &rootfs_verity[..])?;
499
500 let manifest = oci
501 .0
502 .find_manifest_with_tag(tag)?
503 .ok_or_else(|| WireFormatError::MissingManifest(tag.to_string(), Backtrace::capture()))?;
504 let config_digest = manifest.config().digest().digest();
505 let config_digest_path = oci.blob_path().join(config_digest);
506 enable_verity_for_file(&oci.0.dir.open(config_digest_path)?)?;
507
508 for (content_addressed_file, verity_hash) in rootfs.get_verity_data()? {
509 let file_path = oci
510 .blob_path()
511 .join(Digest::new(&content_addressed_file).to_string());
512 let fd = oci.0.dir.open(&file_path)?;
513 if let Err(e) = fsverity_enable(
514 fd.as_raw_fd(),
515 FS_VERITY_BLOCK_SIZE_DEFAULT,
516 InnerHashAlgorithm::Sha256,
517 &[],
518 ) {
519 if e.kind() != std::io::ErrorKind::AlreadyExists {
521 return Err(WireFormatError::from(e));
522 }
523 }
524 check_fs_verity(&fd, &verity_hash)?;
525 }
526
527 Ok(())
528}
529
530pub fn build_test_fs(path: &Path, image: &Image, tag: &str) -> Result<Descriptor> {
532 build_initial_rootfs::<Zstd>(path, image, tag)
533}
534
535#[cfg(test)]
536pub mod tests {
537 use super::*;
538
539 use tempfile::tempdir;
540
541 use crate::reader::WalkPuzzleFS;
542 use cap_std::fs::MetadataExt;
543 use std::path::PathBuf;
544 use tempfile::TempDir;
545
546 type DefaultCompression = Zstd;
547
548 #[test]
549 fn test_fs_generation() -> anyhow::Result<()> {
550 let dir = tempdir().unwrap();
556 let image = Image::new(dir.path()).unwrap();
557 build_test_fs(Path::new("src/builder/test/test-1"), &image, "test-tag").unwrap();
558 let rootfs = image.open_rootfs_blob("test-tag", None).unwrap();
559
560 const FILE_DIGEST: &str =
563 "3eee1082ab3babf6c1595f1069d11ebc2a60135890a11e402e017ddd831a220d";
564
565 let md = image
566 .0
567 .dir
568 .symlink_metadata(image.blob_path().join(FILE_DIGEST))
569 .unwrap();
570 assert!(md.is_file());
571
572 let mut decompressor = image
573 .open_compressed_blob::<DefaultCompression>(
574 &Digest::try_from(FILE_DIGEST).unwrap(),
575 None,
576 )
577 .unwrap();
578
579 let mut inodes = Vec::new();
580
581 for i in 0..2 {
583 inodes.push(rootfs.find_inode(i + 1)?);
584 }
585
586 assert_eq!(inodes[0].ino, 1);
587 if let InodeMode::Dir { ref dir_list } = inodes[0].mode {
588 assert_eq!(dir_list.entries.len(), 1);
589 assert_eq!(dir_list.entries[0].ino, 2);
590 assert_eq!(dir_list.entries[0].name, b"SekienAkashita.jpg");
591 } else {
592 panic!("bad inode mode: {:?}", inodes[0].mode);
593 }
594 assert_eq!(inodes[0].uid, md.uid());
595 assert_eq!(inodes[0].gid, md.gid());
596
597 assert_eq!(inodes[1].ino, 2);
598 assert_eq!(inodes[1].uid, md.uid());
599 assert_eq!(inodes[1].gid, md.gid());
600 if let InodeMode::File { ref chunks } = inodes[1].mode {
601 assert_eq!(chunks.len(), 1);
602 assert_eq!(
603 chunks[0].len,
604 decompressor.get_uncompressed_length().unwrap()
605 );
606 Ok(())
607 } else {
608 panic!("bad inode mode: {:?}", inodes[1].mode);
609 }
610 }
611
612 #[test]
613 fn test_delta_generation() {
614 let dir = tempdir().unwrap();
615 let image = Image::new(dir.path()).unwrap();
616 let tag = "test";
617 build_test_fs(Path::new("src/builder/test/test-1"), &image, tag).unwrap();
618
619 let delta_dir = dir.path().join(Path::new("delta"));
620 fs::create_dir_all(delta_dir.join(Path::new("foo"))).unwrap();
621 fs::copy(
622 Path::new("src/builder/test/test-1/SekienAkashita.jpg"),
623 delta_dir.join("SekienAkashita.jpg"),
624 )
625 .unwrap();
626
627 let new_tag = "test2";
628 let (_desc, image) =
629 add_rootfs_delta::<DefaultCompression>(&delta_dir, image, new_tag, tag).unwrap();
630 let delta = Rootfs::try_from(image.open_rootfs_blob(new_tag, None).unwrap()).unwrap();
631 assert_eq!(delta.metadatas.len(), 2);
632
633 let image = Image::new(dir.path()).unwrap();
634 let mut pfs = PuzzleFS::open(image, new_tag, None).unwrap();
635 assert_eq!(pfs.max_inode().unwrap(), 3);
636 let mut walker = WalkPuzzleFS::walk(&mut pfs).unwrap();
637
638 let root = walker.next().unwrap().unwrap();
639 assert_eq!(root.path.to_string_lossy(), "/");
640 assert_eq!(root.inode.ino, 1);
641 assert_eq!(root.inode.dir_entries().unwrap().len(), 2);
642
643 let jpg_file = walker.next().unwrap().unwrap();
644 assert_eq!(jpg_file.path.to_string_lossy(), "/SekienAkashita.jpg");
645 assert_eq!(jpg_file.inode.ino, 2);
646 assert_eq!(jpg_file.inode.file_len().unwrap(), 109466);
647
648 let foo_dir = walker.next().unwrap().unwrap();
649 assert_eq!(foo_dir.path.to_string_lossy(), "/foo");
650 assert_eq!(foo_dir.inode.ino, 3);
651 assert_eq!(foo_dir.inode.dir_entries().unwrap().len(), 0);
652
653 assert!(walker.next().is_none());
654 }
655
656 fn do_vecs_match<T: PartialEq>(a: &[T], b: &[T]) -> bool {
657 if a.len() != b.len() {
658 return false;
659 }
660
661 let matching = a.iter().zip(b.iter()).filter(|&(a, b)| a == b).count();
662 matching == a.len()
663 }
664
665 fn get_image_blobs(image: &Image) -> Vec<OsString> {
666 WalkDir::new(image.blob_path())
667 .contents_first(false)
668 .follow_links(false)
669 .same_file_system(true)
670 .sort_by(|a, b| a.file_name().cmp(b.file_name()))
671 .into_iter()
672 .skip(1)
673 .map(|x| OsString::from(x.unwrap().path().file_stem().unwrap()))
674 .collect::<Vec<OsString>>()
675 }
676
677 fn same_dir_reproducible(path: &Path) -> bool {
679 let dirs: [_; 10] = std::array::from_fn(|_| tempdir().unwrap());
680 let mut sha_suite = Vec::new();
681 let images = dirs
682 .iter()
683 .map(|dir| Image::new(dir.path()).unwrap())
684 .collect::<Vec<Image>>();
685
686 for (i, image) in images.iter().enumerate() {
687 build_test_fs(path, image, "test").unwrap();
688 let ents = get_image_blobs(image);
689 sha_suite.push(ents);
690
691 if i != 0 && !do_vecs_match(&sha_suite[i - 1], &sha_suite[i]) {
692 println!("not matching at iteration: {i}");
693 return false;
694 }
695 }
696
697 true
698 }
699
700 fn same_dir_contents_reproducible(path: &[PathBuf]) -> bool {
702 let dirs = path.iter().map(|_| tempdir().unwrap()).collect::<Vec<_>>();
703 let mut sha_suite = Vec::new();
704 let images = dirs
705 .iter()
706 .map(|dir| Image::new(dir.path()).unwrap())
707 .collect::<Vec<Image>>();
708
709 for (i, image) in images.iter().enumerate() {
710 build_test_fs(&path[i], image, "test").unwrap();
711 let ents = get_image_blobs(image);
712 sha_suite.push(ents);
713
714 if i != 0 && !do_vecs_match(&sha_suite[i - 1], &sha_suite[i]) {
715 println!("not matching at iteration: {i}");
716 return false;
717 }
718 }
719
720 true
721 }
722
723 #[test]
724 fn test_reproducibility() {
725 fn build_dummy_fs(dir: &Path) -> PathBuf {
726 let rootfs = dir.join("rootfs");
727 let subdirs = ["foo", "bar", "baz"];
728 let files = ["foo_file", "bar_file", "baz_file"];
729
730 for subdir in subdirs {
731 let path = rootfs.join(subdir);
732 fs::create_dir_all(path).unwrap();
733 }
734
735 for file in files {
736 let path = rootfs.join(file);
737 fs::write(path, b"some file contents").unwrap();
738 }
739
740 rootfs
741 }
742
743 let dir = tempdir().unwrap();
744 let rootfs = build_dummy_fs(dir.path());
745
746 assert!(
747 same_dir_reproducible(&rootfs),
748 "build not reproducible for {}",
749 rootfs.display()
750 );
751
752 let dirs: [_; 10] = std::array::from_fn(|i| match i % 2 == 0 {
753 true => tempdir().unwrap(),
756 false => TempDir::new_in(".").unwrap(),
757 });
758 let rootfses = dirs
759 .iter()
760 .map(|dir| build_dummy_fs(dir.path()))
761 .collect::<Vec<PathBuf>>();
762
763 assert!(
764 same_dir_contents_reproducible(&rootfses),
765 "build not reproducible"
766 );
767 }
768}