puzzlefs_lib/
builder.rs

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    // breadth first search for sharing, don't cross filesystems just to be safe, order by file
40    // name. we only return directories here, so we can more easily do delta generation to detect
41    // what's missing in an existing puzzlefs.
42    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
49// a struct to hold a directory's information before it can be rendered into a InodeSpecific::Dir
50// (aka the offset is unknown because we haven't accumulated all the inodes yet)
51struct 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
67// similar to the above, but holding file metadata
68struct 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            // get next file
142            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    // If there are no files left we also expect there are no chunks left
161    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    // host to puzzlefs inode mapping for hard link deteciton
180    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    // we specially create the "/" InodeMode::Dir object, since we will not iterate over it as a
200    // child of some other directory
201    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        // .unwrap() here because we assume no programmer errors in this function (i.e. it is a
218        // puzzlefs bug here)
219        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        // sort the entries so we have reproducible puzzlefs images
237        new_dirents.sort_by_key(|a| a.file_name());
238
239        // add whiteout information
240        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            // now that we know the ino of this thing, let's put it in the parent directory (assuming
273            // this is not "/" for our image, aka inode #1)
274            if cur_ino != 1 {
275                // is this a hard link? if so, just use the existing ino we have rendered. otherewise,
276                // use a new one
277                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 it was a hard link, we don't need to actually render it again
301                if host_to_pfs.contains_key(&md.ino()) {
302                    continue;
303                }
304            }
305
306            host_to_pfs.insert(md.ino(), cur_ino);
307
308            // render as much of the inode as we can
309            // TODO: here are a bunch of optimizations we should do: no need to re-render things
310            // that are the same (whole inodes, metadata, etc.). For now we just re-render the
311            // whole metadata tree.
312            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    // TODO: not render this whole thing in memory, stick it all in the same blob, etc.
360    let mut sorted_dirs = dirs.into_values().collect::<Vec<_>>();
361
362    // render dirs
363    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    // render files
371    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
425// add_rootfs_delta adds whatever the delta between the current rootfs and the puzzlefs
426// representation from the tag is.
427pub 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 fsverity is enabled, ignore the error
474        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    // first enable fs verity for the puzzlefs image manifest
488    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 fsverity is enabled, ignore the error
520            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
530// TODO: figure out how to guard this with #[cfg(test)]
531pub 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        // TODO: verify the hash value here since it's only one thing? problem is as we change the
551        // encoding/add stuff to it, the hash will keep changing and we'll have to update the
552        // test...
553        //
554        // but once all that's stabalized, we should verify the metadata hash too.
555        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        // there should be a blob that matches the hash of the test data, since it all gets input
561        // as one chunk and there's only one file
562        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        // we can at least deserialize inodes and they look sane
582        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    // given the same directory, test whether building it multiple times results in the same puzzlefs image
678    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    // given the same directory contents, test whether building them from multiple paths results in the same puzzlefs image
701    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            // if /tmp and the current dir reside on different filesystems there are better chances
754            // for read_dir (which uses readdir under the hood) to yield a different order of the files
755            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}