Skip to main content

composefs_oci/
layer_sync.rs

1//! Shared, content-agnostic splitdirfdstream producer and consumer for layer
2//! synchronisation.
3//!
4//! This module holds the blocking logic for producing a `splitdirfdstream` from a
5//! stored layer and for draining one back into a repository.  It depends only on
6//! the (tiny) `composefs-splitdirfdstream` crate, not the containers-storage /
7//! podman stack, so it is always compiled — including in builds that do not
8//! enable the `containers-storage` feature.
9//!
10//! Callers:
11//! * `crates/composefs-oci/src/cstor.rs` — the containers-storage import path
12//!   (only built with the `containers-storage` feature).
13//! * `composefs-ctl` varlink server — repo-to-repo layer synchronisation via
14//!   `GetLayer`/`PutLayer`, and the `oci copy` command.
15
16use std::os::unix::fs::FileExt;
17use std::os::unix::io::OwnedFd;
18use std::sync::Arc;
19
20use anyhow::{Context, Result};
21use cap_std_ext::cap_std;
22use composefs::digest::{Digest as _, Sha256};
23use composefs_splitdirfdstream::{Chunk, SplitdirfdstreamReader, SplitdirfdstreamWriter};
24use rustix::fs::{MemfdFlags, fstat, memfd_create};
25
26use composefs::{
27    INLINE_CONTENT_MAX_V0,
28    fsverity::FsVerityHashValue,
29    repository::{ImportContext, ObjectStoreMethod, Repository},
30    splitstream::{SplitStreamData, SplitStreamWriter},
31};
32
33use crate::skopeo::TAR_LAYER_CONTENT_TYPE;
34use crate::{ImportStats, OciDigest, layer_identifier, sha256_output_to_digest};
35
36/// Errors returned by the diff-id-verifying drain path.
37///
38/// Separates the integrity-check failure (wrong content) from I/O or
39/// repository errors so that callers can surface a clear diagnostic.
40#[derive(Debug, thiserror::Error)]
41pub enum VerifiedDrainError {
42    /// The reconstructed layer content does not match the declared `diff_id`.
43    #[error("layer content does not match declared diff_id: expected {expected}, got {actual}")]
44    DiffIdMismatch {
45        /// The diff_id that was declared by the sender.
46        expected: String,
47        /// The sha256 of the data that was actually received.
48        actual: String,
49    },
50    /// Any other I/O or repository error.
51    #[error(transparent)]
52    Other(#[from] anyhow::Error),
53}
54
55/// Verify that `fd` is a directory before using it as a base for `openat`.
56///
57/// If it is not a directory, returns a detailed error showing the fd index,
58/// file type bits, and size — making it easy to distinguish real diff-dirs
59/// from dummy `/dev/null` or `memfd` fds that ended up at the wrong slot.
60fn assert_is_dir(fd: &impl rustix::fd::AsFd, slot: u32, name: &str) -> anyhow::Result<()> {
61    use rustix::fs::FileType;
62    let st = fstat(fd).with_context(|| format!("fstat overlay_dir[{slot}]"))?;
63    let ft = FileType::from_raw_mode(st.st_mode);
64    if ft != FileType::Directory {
65        anyhow::bail!(
66            "overlay_dir[{slot}] is not a directory (file type: {ft:?}, \
67             mode={:#o}, size={}) — cannot open {name:?}; \
68             this is likely a bug where a dummy fd (e.g. /dev/null or memfd) \
69             was stored at a slot that should hold a real diff-dir fd",
70            st.st_mode,
71            st.st_size
72        );
73    }
74    Ok(())
75}
76
77/// Drain the `splitdirfdstream` pipe and write the resulting layer splitstream.
78///
79/// This is the blocking half of a layer-import operation — it should be run on a
80/// `spawn_blocking` thread so the pipe can fill concurrently with the producer.
81///
82/// # Arguments
83/// * `repo` — Repository to import into.
84/// * `pipe_read` — Read end of the splitdirfdstream pipe.
85/// * `dir_fds` — Sparse dirfds region passed wholesale by the transport layer.
86///   `dir_fds[dirfd_index]` resolves the real diff-directory fd for each
87///   [`Chunk::FileBackedData`] chunk.  Dummy fds at gap slots are never opened.
88/// * `diff_id` — OCI diff-id of the layer (used as the stream content identifier).
89/// * `zerocopy` — If `true`, use reflink/zerocopy when storing large objects.
90/// * `ctx` — Import context (passed back to the caller on success).
91///
92/// # Returns
93/// A tuple of `(verity_hash, import_stats, import_context)` on success.
94pub fn drain_splitdirfdstream<ObjectID: FsVerityHashValue>(
95    repo: Arc<Repository<ObjectID>>,
96    pipe_read: OwnedFd,
97    dir_fds: Vec<OwnedFd>,
98    diff_id: &OciDigest,
99    zerocopy: bool,
100    mut ctx: ImportContext,
101) -> Result<(ObjectID, ImportStats, ImportContext)> {
102    // Wrap each fd as a cap_std Dir. Dummy fds (at sparse gap slots) are never
103    // opened by the logic below; only the slot named by dirfd_index is accessed.
104    let overlay_dirs: Vec<cap_std::fs::Dir> =
105        dir_fds.into_iter().map(cap_std::fs::Dir::from).collect();
106
107    let mut writer = repo.create_stream(TAR_LAYER_CONTENT_TYPE)?;
108    let content_id = layer_identifier(diff_id);
109    let mut reader = SplitdirfdstreamReader::new(std::fs::File::from(pipe_read));
110    let mut inline_buf = Vec::new();
111    let mut stats = ImportStats::default();
112
113    drain_splitdirfdstream_inner(
114        &repo,
115        &mut writer,
116        &mut reader,
117        &overlay_dirs,
118        zerocopy,
119        &mut stats,
120        &mut ctx,
121        &mut inline_buf,
122        None,
123    )?;
124
125    let verity = repo.write_stream(writer, &content_id, None)?;
126    Ok((verity, stats, ctx))
127}
128
129#[allow(clippy::too_many_arguments)]
130fn drain_splitdirfdstream_inner<ObjectID: FsVerityHashValue>(
131    repo: &Arc<Repository<ObjectID>>,
132    writer: &mut SplitStreamWriter<ObjectID>,
133    reader: &mut SplitdirfdstreamReader<std::fs::File>,
134    overlay_dirs: &[cap_std::fs::Dir],
135    zerocopy: bool,
136    stats: &mut ImportStats,
137    ctx: &mut ImportContext,
138    inline_buf: &mut Vec<u8>,
139    mut hasher: Option<&mut Sha256>,
140) -> Result<()> {
141    while let Some(chunk) = reader.next_chunk().context("splitdirfdstream read error")? {
142        match chunk {
143            Chunk::Metadata(data) => {
144                if let Some(ref mut h) = hasher {
145                    h.update(data);
146                }
147                stats.bytes_inlined += data.len() as u64;
148                writer.write_inline(data);
149            }
150            Chunk::InlineData(data) => {
151                // Non-world-readable file transported inline by the producer.
152                // We already have the bytes — no fd needed for the small case.
153                if let Some(ref mut h) = hasher {
154                    h.update(data);
155                }
156                let length = data.len() as u64;
157                if should_inline(length) {
158                    stats.bytes_inlined += length;
159                    writer.write_inline(data);
160                } else {
161                    // Large payload received as Chunk::InlineData (producer
162                    // determined the consumer can't safely open this file by
163                    // name — see consumer_has_cap_dac_override in
164                    // GetLayerParams), so unlike Chunk::FileBackedData below,
165                    // there's no real on-disk file backing this data. We
166                    // materialise it into a memfd purely to hand
167                    // process_file_content an fd; that memfd can never be
168                    // zero-copyable (reflink/hardlink), regardless of the
169                    // caller's `zerocopy` flag, so always pass `false` here.
170                    process_file_content(
171                        repo,
172                        writer,
173                        stats,
174                        ctx,
175                        file_content_to_memfd(data)?,
176                        length,
177                        "<file-content>",
178                        false,
179                        inline_buf,
180                    )?;
181                }
182            }
183            Chunk::FileBackedData {
184                dirfd_index,
185                length,
186                filename,
187            } => {
188                let name = std::str::from_utf8(filename).with_context(|| {
189                    format!("non-utf8 filename in splitdirfdstream: {filename:?}")
190                })?;
191                let dir = overlay_dirs.get(dirfd_index as usize).with_context(|| {
192                    format!(
193                        "dirfd_index {dirfd_index} out of range (dir_fds.len={})",
194                        overlay_dirs.len()
195                    )
196                })?;
197                assert_is_dir(dir, dirfd_index, name)?;
198                let fd = dir
199                    .open(name)
200                    .map(OwnedFd::from)
201                    .with_context(|| format!("open {name:?} in overlay dir[{dirfd_index}]"))?;
202
203                use rustix::fs::FileType;
204                let st = fstat(&fd).with_context(|| format!("fstat {name:?}"))?;
205                let ft = FileType::from_raw_mode(st.st_mode);
206                if ft != FileType::RegularFile {
207                    anyhow::bail!(
208                        "object {name:?} in overlay dir[{dirfd_index}] is not a regular file (file type: {ft:?}, mode={:#o})",
209                        st.st_mode
210                    );
211                }
212
213                let fd_to_process = if let Some(ref mut h) = hasher {
214                    // Defend against a producer that declares a `length` that
215                    // disagrees with the actual object size.  The reflink store
216                    // path already rejects a mismatch, but the copy fallback would
217                    // store the whole file while we only hashed `length` bytes,
218                    // so the committed object could differ from the verified
219                    // bytes.  Pin the two together by requiring length == size.
220                    let obj_file = std::fs::File::from(fd);
221                    let actual_size = st.st_size as u64;
222                    if actual_size != length {
223                        anyhow::bail!(
224                            "object {name}: declared length {length} != actual size {actual_size}"
225                        );
226                    }
227
228                    // Hash the object file content using positioned reads.  This
229                    // does not disturb the fd cursor, so process_file_content can
230                    // independently read/reflink the same fd afterwards.
231                    hash_fd_contents(&obj_file, length, h)
232                        .with_context(|| format!("hashing object {name}"))?;
233
234                    obj_file.into()
235                } else {
236                    fd
237                };
238
239                process_file_content(
240                    repo,
241                    writer,
242                    stats,
243                    ctx,
244                    fd_to_process,
245                    length,
246                    name,
247                    zerocopy,
248                    inline_buf,
249                )?;
250                // NOTE: padding after the file content is already emitted by the
251                // producer as a following Inline chunk; do NOT add extra padding here.
252            }
253        }
254    }
255    Ok(())
256}
257
258/// Materialise `data` into an anonymous `memfd` for use with
259/// [`process_file_content`].
260///
261/// The memfd is created `CLOEXEC` and the data written at offset 0.
262/// `process_file_content` uses positioned reads (`pread`/`read_at`) so the
263/// write cursor position does not matter.
264fn file_content_to_memfd(data: &[u8]) -> Result<OwnedFd> {
265    let memfd = memfd_create(c"composefs-filecontent", MemfdFlags::CLOEXEC)
266        .context("memfd_create for FileContent chunk")?;
267    rustix::io::write(&memfd, data).context("writing FileContent to memfd")?;
268    Ok(memfd)
269}
270
271/// Hash `len` bytes of `fd` starting at offset 0 into `hasher`, using
272/// positioned reads so the fd cursor is not disturbed.
273///
274/// Uses [`FileExt::read_at`] in a loop with a 64 KiB stack buffer to
275/// avoid allocating for large objects.
276fn hash_fd_contents(fd: &std::fs::File, len: u64, hasher: &mut Sha256) -> Result<()> {
277    const BUF_SIZE: usize = 65536;
278    let mut buf = [0u8; BUF_SIZE];
279    let mut remaining = len;
280    let mut offset = 0u64;
281
282    while remaining > 0 {
283        let to_read = remaining.min(BUF_SIZE as u64) as usize;
284        let n = fd
285            .read_at(&mut buf[..to_read], offset)
286            .context("read_at while hashing fd contents")?;
287        if n == 0 {
288            anyhow::bail!(
289                "unexpected EOF at offset {offset} hashing fd (expected {len} bytes total)"
290            );
291        }
292        hasher.update(&buf[..n]);
293        offset += n as u64;
294        remaining -= n as u64;
295    }
296    Ok(())
297}
298
299/// Like [`drain_splitdirfdstream`], but additionally verifies that the
300/// reconstructed (uncompressed tar) content hashes to `diff_id`, and only
301/// commits the layer splitstream to the repository if it matches.
302///
303/// On mismatch the partially-written stream is discarded and
304/// [`VerifiedDrainError::DiffIdMismatch`] is returned.
305///
306/// # Integrity model
307///
308/// The diff_id is the sha256 of the uncompressed tar bytes — the same byte
309/// stream that `cat` on the splitstream produces.  As chunks are processed,
310/// every logical byte is fed into a running SHA-256 hasher:
311/// * **Inline** chunks: hash the raw bytes verbatim.
312/// * **External** chunks: hash the full object file (all `length` bytes) via
313///   positioned reads, then pass the same fd to [`process_file_content`].
314///   Using positioned reads (`read_at`) means the fd cursor is not consumed,
315///   so `process_file_content` can read the file independently.
316///
317/// # Note on partial objects
318///
319/// If verification fails, large external objects that were already written to
320/// the `objects/` directory of the destination repo are left in place.  They
321/// are orphaned (not referenced by any committed splitstream) and will be
322/// reclaimed by the next GC run.  We do NOT attempt to clean them up here
323/// because doing so correctly (without racing with concurrent imports) would
324/// be complex and the GC already handles this case.
325pub fn drain_splitdirfdstream_verified<ObjectID: FsVerityHashValue>(
326    repo: Arc<Repository<ObjectID>>,
327    pipe_read: OwnedFd,
328    dir_fds: Vec<OwnedFd>,
329    diff_id: &OciDigest,
330    zerocopy: bool,
331    mut ctx: ImportContext,
332) -> Result<(ObjectID, ImportStats, ImportContext), VerifiedDrainError> {
333    // We hash with SHA-256, which is the only algorithm OCI diff-ids use in
334    // practice (and the only one the rest of this crate assumes). Reject other
335    // algorithms up front with a clear error rather than producing a confusing
336    // "mismatch" between a sha256 hash and e.g. a sha512 diff-id string.
337    let algorithm = diff_id.algorithm().as_ref();
338    if algorithm != "sha256" {
339        return Err(VerifiedDrainError::Other(anyhow::anyhow!(
340            "unsupported diff_id algorithm {algorithm:?}: only sha256 is supported"
341        )));
342    }
343
344    // Wrap each fd as a cap_std Dir. Dummy fds at sparse gap slots are never
345    // opened by the logic below; only the slot named by dirfd_index is accessed.
346    let overlay_dirs: Vec<cap_std::fs::Dir> =
347        dir_fds.into_iter().map(cap_std::fs::Dir::from).collect();
348
349    let mut writer = repo
350        .create_stream(TAR_LAYER_CONTENT_TYPE)
351        .context("create_stream")?;
352    let content_id = layer_identifier(diff_id);
353    let mut reader = SplitdirfdstreamReader::new(std::fs::File::from(pipe_read));
354    let mut inline_buf = Vec::new();
355    let mut stats = ImportStats::default();
356    let mut hasher = Sha256::new();
357
358    drain_splitdirfdstream_inner(
359        &repo,
360        &mut writer,
361        &mut reader,
362        &overlay_dirs,
363        zerocopy,
364        &mut stats,
365        &mut ctx,
366        &mut inline_buf,
367        Some(&mut hasher),
368    )?;
369
370    // Verify the accumulated hash against the declared diff_id.
371    let actual_digest = sha256_output_to_digest(hasher.finalize());
372    let actual_str = actual_digest.to_string();
373    let expected_str = diff_id.to_string();
374    if actual_str != expected_str {
375        // Drop `writer` without calling write_stream: the stream is not
376        // committed.  Large objects already written to objects/ are orphaned
377        // but GC will reclaim them.
378        return Err(VerifiedDrainError::DiffIdMismatch {
379            expected: expected_str,
380            actual: actual_str,
381        });
382    }
383
384    let verity = repo
385        .write_stream(writer, &content_id, None)
386        .context("write_stream")?;
387    Ok((verity, stats, ctx))
388}
389
390/// Decide whether a file of the given `size` should be stored inline in the
391/// splitstream rather than as a separate object in the object store.
392///
393/// Files with `size <= INLINE_CONTENT_MAX_V0` are embedded directly in the
394/// splitstream; larger files become external objects.
395pub(crate) fn should_inline(size: u64) -> bool {
396    (size as usize) <= INLINE_CONTENT_MAX_V0
397}
398
399/// Store the content of one file fd into the splitstream, choosing inline vs
400/// external storage based on the file size.
401///
402/// For files at or below [`composefs::INLINE_CONTENT_MAX_V0`] bytes the
403/// content is read into `inline_buf` and embedded directly in `writer`.
404/// Larger files are stored as external objects in `repo` and referenced
405/// by hash.
406#[allow(clippy::too_many_arguments)]
407pub fn process_file_content<ObjectID: FsVerityHashValue>(
408    repo: &Arc<Repository<ObjectID>>,
409    writer: &mut SplitStreamWriter<ObjectID>,
410    stats: &mut ImportStats,
411    ctx: &mut ImportContext,
412    fd: OwnedFd,
413    size: u64,
414    name: &str,
415    zerocopy: bool,
416    inline_buf: &mut Vec<u8>,
417) -> Result<()> {
418    // Convert fd to File for operations
419    let file = std::fs::File::from(fd);
420
421    if !should_inline(size) {
422        // Large file: store as external object
423        let (object_id, method) = if zerocopy {
424            repo.ensure_object_from_file_zerocopy(&file, size, ctx)
425        } else {
426            repo.ensure_object_from_file(&file, size, ctx)
427        }
428        .with_context(|| format!("Failed to store object for {}", name))?;
429
430        match method {
431            ObjectStoreMethod::Reflinked => {
432                stats.objects_reflinked += 1;
433                stats.bytes_reflinked += size;
434            }
435            ObjectStoreMethod::Hardlinked => {
436                stats.objects_hardlinked += 1;
437                stats.bytes_hardlinked += size;
438            }
439            ObjectStoreMethod::Copied => {
440                stats.objects_copied += 1;
441                stats.bytes_copied += size;
442            }
443            ObjectStoreMethod::AlreadyPresent => {
444                stats.objects_already_present += 1;
445            }
446        }
447
448        writer.add_external_size(size);
449        writer.write_reference(object_id)?;
450    } else {
451        // Small file: read and embed inline (reuse buffer across calls)
452        inline_buf.resize(size as usize, 0);
453        file.read_exact_at(inline_buf, 0)?;
454        stats.bytes_inlined += size;
455        writer.write_inline(inline_buf);
456    }
457
458    Ok(())
459}
460
461/// Return the on-disk size of the object identified by `id` in `repo`.
462///
463/// Opens the object file and `fstat`s it to obtain the size without reading
464/// the full content.  This is used by [`produce_layer_splitdirfdstream`] to
465/// populate the `length` field of external chunks in the output stream.
466fn object_size<ObjectID: FsVerityHashValue>(
467    repo: &Repository<ObjectID>,
468    id: &ObjectID,
469) -> Result<u64> {
470    let fd = repo
471        .open_object(id)
472        .context("Opening object for size query")?;
473    let stat = fstat(&fd).context("fstat on object fd")?;
474    Ok(stat.st_size as u64)
475}
476
477/// Produce a `splitdirfdstream` for the layer splitstream identified by
478/// `layer_verity`, writing it to `out`.
479///
480/// External objects are emitted as references of the form `"xx/yyyy"` beneath
481/// the repository's `objects/` directory.  The consumer must supply the objects
482/// directory at `dirfd_index` within the sparse dirfds region (obtained from
483/// [`composefs_splitdirfdstream::build_layer_fd_layout`]'s `real_indices[0]`).
484///
485/// This is the read/serve side that mirrors [`drain_splitdirfdstream`]: given
486/// the same repo, a stream produced here and then passed through
487/// [`composefs_splitdirfdstream::reconstruct`] with the repo's objects dir will
488/// yield byte-identical output to calling
489/// [`composefs::splitstream::SplitStreamReader::cat`] on the same layer.
490///
491/// # Arguments
492/// * `repo`              — Repository that holds the layer and its objects.
493/// * `layer_verity`      — fs-verity hash of the layer splitstream to serve.
494/// * `objects_dirfd_index` — Slot index within the sparse dirfds region where
495///   the repository objects directory fd is placed (from `real_indices[0]`).
496/// * `out`               — Destination writer for the produced `splitdirfdstream`.
497pub fn produce_layer_splitdirfdstream<ObjectID: FsVerityHashValue, W: std::io::Write>(
498    repo: &Repository<ObjectID>,
499    layer_verity: &ObjectID,
500    objects_dirfd_index: u32,
501    out: W,
502) -> Result<()> {
503    // No `expected_content_type` filter here: layer streams may be tarballs
504    // (`TAR_LAYER_CONTENT_TYPE`) or, for OCI artifacts, arbitrary blobs
505    // (`OCI_BLOB_CONTENT_TYPE`/`BLOB_CONTENT_TYPE`). This function walks
506    // chunks generically and does not care which; enforcing one type here
507    // would silently produce an empty stream for the other (the caller only
508    // logs a warning, since by the time `produce` runs the fds have already
509    // been handed to the client).
510    let mut reader = repo
511        .open_stream("", Some(layer_verity), None)
512        .context("Opening layer splitstream")?;
513    let mut writer = SplitdirfdstreamWriter::new(out);
514
515    reader
516        .for_each_chunk(|chunk| {
517            match chunk {
518                SplitStreamData::Inline(data) => {
519                    writer.write_metadata(&data).map_err(anyhow::Error::from)?;
520                }
521                SplitStreamData::External(id) => {
522                    let size = object_size(repo, &id)?;
523                    let pathname = id.to_object_pathname();
524                    writer
525                        .write_file_backed_data(objects_dirfd_index, size, pathname.as_bytes())
526                        .map_err(anyhow::Error::from)?;
527                }
528            }
529            Ok(())
530        })
531        .context("Walking layer splitstream chunks")?;
532
533    writer.finish().map_err(anyhow::Error::from)?;
534    Ok(())
535}
536
537/// A type alias for the (manifest, config) digest-and-verity pair returned by
538/// [`finalize_oci_image`].
539///
540/// The first element is `(manifest_digest, manifest_verity)`;
541/// the second is `(config_digest, config_verity)`.
542pub type FinalizeResult<ObjectID> = (
543    crate::ContentAndVerity<ObjectID>,
544    crate::ContentAndVerity<ObjectID>,
545);
546
547/// Finalize an OCI image whose layers are already imported into `repo`.
548///
549/// Given the raw manifest and config JSON (exact bytes, so their sha256
550/// digests are preserved) and the ordered (diff_id, layer_verity) pairs,
551/// this writes the config and manifest splitstreams, generates the composefs
552/// EROFS image, and optionally tags the manifest under `name`. Idempotent.
553///
554/// Returns `((manifest_digest, manifest_verity), (config_digest, config_verity))`.
555pub fn finalize_oci_image<ObjectID: FsVerityHashValue>(
556    repo: &Arc<Repository<ObjectID>>,
557    manifest_json: &[u8],
558    config_json: &[u8],
559    layer_refs: &[(OciDigest, ObjectID)],
560    name: Option<&str>,
561) -> anyhow::Result<FinalizeResult<ObjectID>> {
562    use crate::oci_image::manifest_identifier;
563    use crate::skopeo::{OCI_CONFIG_CONTENT_TYPE, OCI_MANIFEST_CONTENT_TYPE};
564    use crate::{config_identifier, sha256_content_digest};
565
566    let config_digest = sha256_content_digest(config_json);
567    let content_id = config_identifier(&config_digest);
568
569    let config_verity = if let Some(existing) = repo.has_stream(&content_id)? {
570        existing
571    } else {
572        let mut writer = repo.create_stream(OCI_CONFIG_CONTENT_TYPE)?;
573
574        for (diff_id, verity) in layer_refs {
575            let key: &str = diff_id.as_ref();
576            writer.add_named_stream_ref(key, verity);
577        }
578
579        writer.write_external(config_json)?;
580        repo.write_stream(writer, &content_id, None)?
581    };
582
583    let manifest_digest = sha256_content_digest(manifest_json);
584
585    let manifest_content_id = manifest_identifier(&manifest_digest);
586    let manifest_verity = if let Some(existing) = repo.has_stream(&manifest_content_id)? {
587        existing
588    } else {
589        let mut writer = repo.create_stream(OCI_MANIFEST_CONTENT_TYPE)?;
590
591        let config_ref_key = format!("config:{config_digest}");
592        writer.add_named_stream_ref(&config_ref_key, &config_verity);
593
594        for (diff_id, verity) in layer_refs {
595            let key: &str = diff_id.as_ref();
596            writer.add_named_stream_ref(key, verity);
597        }
598
599        writer.write_external(manifest_json)?;
600        repo.write_stream(writer, &manifest_content_id, None)?
601    };
602
603    // Generate the composefs EROFS image and tag the manifest.
604    // Skip if the image already has an EROFS ref (idempotent re-finalize).
605    let existing_erofs = crate::composefs_erofs_for_manifest(
606        repo,
607        &manifest_digest,
608        Some(&manifest_verity),
609        repo.erofs_version(),
610    )?;
611    if existing_erofs.is_none() {
612        let erofs = crate::ensure_oci_composefs_erofs(
613            repo,
614            &manifest_digest,
615            Some(&manifest_verity),
616            name,
617        )?;
618        if erofs.is_none() {
619            // Not a container image (e.g. an artifact) — tag directly.
620            if let Some(n) = name {
621                crate::oci_image::tag_image(repo, &manifest_digest, n)?;
622            }
623        }
624    } else if let Some(n) = name {
625        crate::oci_image::tag_image(repo, &manifest_digest, n)?;
626    }
627
628    // Re-read verities: ensure_oci_composefs_erofs rewrites config and
629    // manifest splitstreams (adding the EROFS ref), so the verities captured
630    // above may be stale.
631    let config_verity = repo
632        .has_stream(&content_id)?
633        .context("config splitstream missing after finalization")?;
634    let manifest_verity = repo
635        .has_stream(&manifest_content_id)?
636        .context("manifest splitstream missing after finalization")?;
637
638    Ok((
639        (manifest_digest, manifest_verity),
640        (config_digest, config_verity),
641    ))
642}
643
644#[cfg(test)]
645mod tests {
646    use super::*;
647
648    use std::io::Write as _;
649
650    use composefs::fsverity::Sha256HashValue;
651    use composefs::repository::RepositoryConfig;
652    use composefs_splitdirfdstream::reconstruct;
653
654    /// The pure inline-vs-external predicate, tested exhaustively around the
655    /// boundary.  This is the exact expression branched on inside
656    /// [`process_file_content`], so locking it down here guards the decision
657    /// independently of the (heavier) end-to-end test below.
658    #[test]
659    fn test_should_inline_boundary() {
660        assert!(should_inline(0), "size 0 should be inlined");
661        assert!(should_inline(1), "size 1 should be inlined");
662        assert!(
663            should_inline(INLINE_CONTENT_MAX_V0 as u64),
664            "size == INLINE_CONTENT_MAX_V0 ({INLINE_CONTENT_MAX_V0}) should be inlined"
665        );
666        assert!(
667            !should_inline(INLINE_CONTENT_MAX_V0 as u64 + 1),
668            "size INLINE_CONTENT_MAX_V0+1 ({}) should NOT be inlined",
669            INLINE_CONTENT_MAX_V0 + 1
670        );
671        for size in [128u64, 4096, 65536, 1024 * 1024] {
672            assert!(
673                !should_inline(size),
674                "size {size} should NOT be inlined (well above threshold)"
675            );
676        }
677    }
678
679    /// Create an insecure (no fs-verity) repository in a fresh tempdir.
680    ///
681    /// Returns the repo alongside the `TempDir`, which the caller must keep
682    /// alive for the duration of the test.
683    fn create_test_repo() -> (Arc<Repository<Sha256HashValue>>, tempfile::TempDir) {
684        let tempdir = tempfile::TempDir::new().unwrap();
685        let (repo, _) = Repository::init_path(
686            rustix::fs::CWD,
687            &tempdir.path().join("repo"),
688            RepositoryConfig::default().set_insecure(),
689        )
690        .unwrap();
691        (Arc::new(repo), tempdir)
692    }
693
694    /// A file fd backed by `len` bytes of deterministic content.
695    fn tmpfile_of(len: usize) -> OwnedFd {
696        let mut f = tempfile::tempfile().unwrap();
697        let data: Vec<u8> = (0..len).map(|i| (i % 251) as u8).collect();
698        f.write_all(&data).unwrap();
699        f.into()
700    }
701
702    /// Data-driven end-to-end check of `process_file_content`'s storage
703    /// decision: small files land inline (no object written, `bytes_inlined`
704    /// grows), large files become external objects (`objects_*` grows,
705    /// `bytes_inlined` unchanged).
706    #[test]
707    fn test_process_file_content_inline_vs_external() {
708        let (repo, _tempdir) = create_test_repo();
709
710        let cases = [
711            (0usize, false),
712            (1, false),
713            (INLINE_CONTENT_MAX_V0, false),
714            (INLINE_CONTENT_MAX_V0 + 1, true),
715            (4096, true),
716            (256 * 1024, true),
717        ];
718
719        for (size, expect_external) in cases {
720            let mut writer = repo.create_stream(TAR_LAYER_CONTENT_TYPE).unwrap();
721            let mut stats = ImportStats::default();
722            let mut ctx = ImportContext::default();
723            let mut inline_buf = Vec::new();
724
725            let before_inlined = stats.bytes_inlined;
726            process_file_content(
727                &repo,
728                &mut writer,
729                &mut stats,
730                &mut ctx,
731                tmpfile_of(size),
732                size as u64,
733                "test-file",
734                false,
735                &mut inline_buf,
736            )
737            .unwrap();
738
739            let objects_written = stats.objects_reflinked
740                + stats.objects_hardlinked
741                + stats.objects_copied
742                + stats.objects_already_present;
743
744            if expect_external {
745                assert_eq!(
746                    stats.bytes_inlined, before_inlined,
747                    "size {size}: external file must not change bytes_inlined"
748                );
749                assert_eq!(
750                    objects_written, 1,
751                    "size {size}: exactly one external object expected"
752                );
753            } else {
754                assert_eq!(
755                    stats.bytes_inlined,
756                    before_inlined + size as u64,
757                    "size {size}: inline file must add its bytes to bytes_inlined"
758                );
759                assert_eq!(
760                    objects_written, 0,
761                    "size {size}: inline file must not write an object"
762                );
763            }
764        }
765    }
766
767    /// Regression test: a memfd-backed fd must not error under the copy
768    /// fallback path when the caller correctly identifies it as
769    /// non-zerocopy-able (`zerocopy=false`), since memfds live on tmpfs
770    /// where reflink/hardlink are structurally impossible.
771    #[test]
772    fn test_memfd_file_content_uses_copy_fallback() {
773        let (repo, _tempdir) = create_test_repo();
774        let size: usize = INLINE_CONTENT_MAX_V0 + 1; // just above inline threshold
775        let data: Vec<u8> = (0..size).map(|i| (i % 251) as u8).collect();
776
777        let memfd = file_content_to_memfd(&data).unwrap();
778
779        let mut writer = repo.create_stream(TAR_LAYER_CONTENT_TYPE).unwrap();
780        let mut stats = ImportStats::default();
781        let mut ctx = ImportContext::default();
782        let mut inline_buf = Vec::new();
783
784        process_file_content(
785            &repo,
786            &mut writer,
787            &mut stats,
788            &mut ctx,
789            memfd,
790            size as u64,
791            "memfd-test",
792            false,
793            &mut inline_buf,
794        )
795        .unwrap();
796
797        assert_eq!(stats.objects_copied, 1, "memfd content should be copied");
798        assert_eq!(stats.bytes_copied, size as u64);
799    }
800
801    // -------------------------------------------------------------------------
802    // Helpers for the produce->reconstruct==cat round-trip tests
803    // -------------------------------------------------------------------------
804
805    /// Build a tar layer where each file has the specified size (bytes).
806    ///
807    /// File names are derived from the size so each case is identifiable
808    /// in assertion output.  Content is deterministic: repeating bytes
809    /// `i % 251`.
810    fn build_tar_layer(file_sizes: &[usize]) -> Vec<u8> {
811        let mut builder = ::tar::Builder::new(vec![]);
812        for &size in file_sizes {
813            let content: Vec<u8> = (0..size).map(|i| (i % 251) as u8).collect();
814            let mut header = ::tar::Header::new_ustar();
815            header.set_uid(0);
816            header.set_gid(0);
817            header.set_mode(0o644);
818            header.set_entry_type(::tar::EntryType::Regular);
819            header.set_size(size as u64);
820            builder
821                .append_data(
822                    &mut header,
823                    format!("file_{size}_{:08x}", size),
824                    &content[..],
825                )
826                .unwrap();
827        }
828        builder.into_inner().unwrap()
829    }
830
831    /// Assert that produce -> reconstruct yields the same bytes as `cat`.
832    ///
833    /// This is the determinism contract: given a layer already imported into
834    /// `repo` with verity hash `verity`, the two paths must be identical.
835    async fn assert_produce_eq_cat(
836        repo: &Arc<Repository<Sha256HashValue>>,
837        verity: &Sha256HashValue,
838    ) {
839        use std::os::fd::AsFd as _;
840        // --- expected: what cat() produces ---
841        let mut expected = Vec::<u8>::new();
842        let mut reader = repo
843            .open_stream("", Some(verity), Some(TAR_LAYER_CONTENT_TYPE))
844            .expect("open_stream for cat");
845        reader
846            .cat(repo, &mut expected)
847            .expect("cat on layer splitstream");
848
849        // --- actual: produce -> reconstruct ---
850        // For the single-repo test path the objects dir is always at index 0
851        // (one real dir, seed-determined slot may vary, but for this helper we
852        // just use dirfd_index=0 directly to keep the test simple).
853        let mut stream_buf = Vec::<u8>::new();
854        produce_layer_splitdirfdstream(repo, verity, 0, &mut stream_buf)
855            .expect("produce_layer_splitdirfdstream");
856
857        let objects_dir_fd = repo.objects_dir().expect("objects_dir");
858        let dirfds = [objects_dir_fd.as_fd()];
859        let mut actual = Vec::<u8>::new();
860        reconstruct(stream_buf.as_slice(), &dirfds, &mut actual)
861            .expect("reconstruct splitdirfdstream");
862
863        similar_asserts::assert_eq!(
864            actual,
865            expected,
866            "produce->reconstruct must equal cat for verity={verity:?}"
867        );
868    }
869
870    /// Data-driven round-trip test: produce -> reconstruct == cat for each
871    /// layer shape.
872    ///
873    /// The test cases cover:
874    /// - all-inline (only files <= INLINE_CONTENT_MAX_V0)
875    /// - only-external (one file larger than INLINE_CONTENT_MAX_V0)
876    /// - mixed (various sizes including crossing the threshold)
877    #[tokio::test]
878    async fn test_produce_reconstruct_eq_cat() {
879        let (repo, _tempdir) = create_test_repo();
880
881        // (label, file sizes in bytes)
882        let cases: &[(&str, &[usize])] = &[
883            // All inline: empty layer (no files)
884            ("empty", &[]),
885            // All inline: only small files (all <= INLINE_CONTENT_MAX_V0 = 64)
886            ("all_inline", &[0, 1, 10, 64]),
887            // Single external object (> 64 bytes)
888            ("single_external", &[65]),
889            // Larger external objects
890            ("large_external", &[4096, 200_000]),
891            // Mixed: small + large + various sizes
892            ("mixed", &[0, 10, 64, 65, 4096, 200_000]),
893        ];
894
895        for (label, sizes) in cases {
896            let tar_bytes = build_tar_layer(sizes);
897            let diff_id = crate::sha256_content_digest(&tar_bytes);
898            let (verity, _stats) = crate::import_layer(&repo, &diff_id, None, tar_bytes.as_slice())
899                .await
900                .unwrap_or_else(|e| panic!("import_layer failed for {label}: {e}"));
901
902            // Run the determinism check for this layer shape.
903            assert_produce_eq_cat(&repo, &verity).await;
904        }
905    }
906
907    // -------------------------------------------------------------------------
908    // Tests for drain_splitdirfdstream_verified
909    // -------------------------------------------------------------------------
910
911    /// Produce a splitdirfdstream from `repo_a` for `verity` into a pipe,
912    /// returning the pipe read end, repo_a's objects dir fd, and a join handle
913    /// for the producer task.
914    ///
915    /// The producer runs on a `spawn_blocking` task so the pipe can fill
916    /// concurrently with the consumer. The caller MUST await the returned
917    /// handle (after draining the pipe, to avoid a full-pipe deadlock) and
918    /// assert the producer succeeded — the task is tracked, not detached, so
919    /// producer errors are surfaced rather than swallowed.
920    fn produce_to_pipe(
921        repo_a: Arc<Repository<Sha256HashValue>>,
922        verity: Sha256HashValue,
923    ) -> (
924        OwnedFd,
925        Vec<OwnedFd>,
926        tokio::task::JoinHandle<anyhow::Result<()>>,
927    ) {
928        use std::os::fd::AsFd as _;
929
930        let (pipe_read, pipe_write) =
931            rustix::pipe::pipe_with(rustix::pipe::PipeFlags::CLOEXEC).expect("pipe");
932
933        let objects_dir = repo_a.objects_dir().expect("objects_dir");
934        let objects_owned = rustix::io::dup(objects_dir.as_fd()).expect("dup objects_dir");
935
936        let handle = tokio::task::spawn_blocking(move || {
937            let wf = std::fs::File::from(pipe_write);
938            // dirfd_index=0 for single-repo tests (objects dir at slot 0).
939            produce_layer_splitdirfdstream(&repo_a, &verity, 0, wf)
940        });
941
942        (pipe_read, vec![objects_owned], handle)
943    }
944
945    /// Await a producer handle from [`produce_to_pipe`] and assert success.
946    async fn join_producer(handle: tokio::task::JoinHandle<anyhow::Result<()>>) {
947        handle
948            .await
949            .expect("producer task panicked")
950            .expect("producer must succeed");
951    }
952
953    /// Await a producer handle without asserting its outcome.
954    ///
955    /// Used when the consumer rejects the stream early (before reading it):
956    /// the producer then sees a broken pipe and errors, which is expected. We
957    /// still join it so no task is left untracked.
958    async fn drain_producer(handle: tokio::task::JoinHandle<anyhow::Result<()>>) {
959        let _ = handle.await.expect("producer task panicked");
960    }
961
962    /// Positive test: produce → verified drain with the CORRECT diff_id.
963    ///
964    /// repo_a imports a layer (with a large external object), then produces
965    /// it as a splitdirfdstream into repo_b via `drain_splitdirfdstream_verified`.
966    /// Asserts:
967    /// - the call succeeds,
968    /// - repo_b now has the layer stream committed.
969    #[tokio::test]
970    async fn test_verified_drain_correct_diff_id() {
971        let (repo_a, _td_a) = create_test_repo();
972        let (repo_b, _td_b) = create_test_repo();
973
974        // Build a layer with both inline and external content.
975        let tar_bytes = build_tar_layer(&[10, 128 * 1024]); // 10 B inline + 128 KiB external
976        let diff_id = crate::sha256_content_digest(&tar_bytes);
977
978        let (verity_a, _) = crate::import_layer(&repo_a, &diff_id, None, tar_bytes.as_slice())
979            .await
980            .expect("import_layer into repo_a");
981
982        let (pipe_read, dir_fds, producer) = produce_to_pipe(repo_a, verity_a.clone());
983
984        let repo_b_clone = repo_b.clone();
985        let diff_id_clone = diff_id.clone();
986        let result = tokio::task::spawn_blocking(move || {
987            drain_splitdirfdstream_verified(
988                repo_b_clone,
989                pipe_read,
990                dir_fds,
991                &diff_id_clone,
992                false,
993                composefs::repository::ImportContext::default(),
994            )
995        })
996        .await
997        .expect("spawn_blocking");
998
999        let (verity_b, _stats, _ctx) = result.expect("verified drain must succeed");
1000
1001        // Producer must have completed cleanly (drain succeeded, so it did).
1002        join_producer(producer).await;
1003
1004        // The layer must now be committed in repo_b.
1005        let content_id = crate::layer_content_id(&diff_id);
1006        assert!(
1007            repo_b
1008                .has_stream(&content_id)
1009                .expect("has_stream")
1010                .is_some(),
1011            "repo_b must have the layer stream after verified drain"
1012        );
1013
1014        // Both repos resolved to the same verity hash.
1015        assert_eq!(
1016            verity_a, verity_b,
1017            "verity hash must be identical across repos"
1018        );
1019    }
1020
1021    /// Negative test: produce → verified drain with a WRONG diff_id.
1022    ///
1023    /// The drain must return `DiffIdMismatch` and repo_b must NOT have the
1024    /// stream committed.
1025    #[tokio::test]
1026    async fn test_verified_drain_wrong_diff_id() {
1027        let (repo_a, _td_a) = create_test_repo();
1028        let (repo_b, _td_b) = create_test_repo();
1029
1030        let tar_bytes = build_tar_layer(&[10, 128 * 1024]);
1031        let correct_diff_id = crate::sha256_content_digest(&tar_bytes);
1032
1033        let (verity_a, _) =
1034            crate::import_layer(&repo_a, &correct_diff_id, None, tar_bytes.as_slice())
1035                .await
1036                .expect("import_layer into repo_a");
1037
1038        // Construct a diff_id that is a valid sha256 digest but definitely wrong.
1039        let wrong_diff_id: crate::OciDigest =
1040            "sha256:0000000000000000000000000000000000000000000000000000000000000000"
1041                .parse()
1042                .unwrap();
1043
1044        let (pipe_read, dir_fds, producer) = produce_to_pipe(repo_a, verity_a);
1045
1046        let repo_b_clone = repo_b.clone();
1047        let wrong_diff_id_clone = wrong_diff_id.clone();
1048        let result = tokio::task::spawn_blocking(move || {
1049            drain_splitdirfdstream_verified(
1050                repo_b_clone,
1051                pipe_read,
1052                dir_fds,
1053                &wrong_diff_id_clone,
1054                false,
1055                composefs::repository::ImportContext::default(),
1056            )
1057        })
1058        .await
1059        .expect("spawn_blocking");
1060
1061        // The drain hashes the whole stream before rejecting, so it consumes
1062        // the entire pipe and the producer completes cleanly.
1063        join_producer(producer).await;
1064
1065        // Must fail with DiffIdMismatch.
1066        match result {
1067            Err(VerifiedDrainError::DiffIdMismatch { expected, actual }) => {
1068                assert_eq!(
1069                    expected,
1070                    wrong_diff_id.to_string(),
1071                    "expected field must be the wrong diff_id"
1072                );
1073                assert_eq!(
1074                    actual,
1075                    correct_diff_id.to_string(),
1076                    "actual field must be the real content hash"
1077                );
1078            }
1079            other => panic!("expected DiffIdMismatch, got {other:?}"),
1080        }
1081
1082        // The stream must NOT be committed in repo_b.
1083        let wrong_content_id = crate::layer_content_id(&wrong_diff_id);
1084        assert!(
1085            repo_b
1086                .has_stream(&wrong_content_id)
1087                .expect("has_stream")
1088                .is_none(),
1089            "repo_b must NOT have a committed stream for the wrong diff_id"
1090        );
1091    }
1092
1093    /// A non-sha256 diff_id must be rejected up front with a clear error
1094    /// rather than producing a confusing hash "mismatch".
1095    #[tokio::test]
1096    async fn test_verified_drain_rejects_non_sha256() {
1097        let (repo_a, _td_a) = create_test_repo();
1098        let (repo_b, _td_b) = create_test_repo();
1099
1100        let tar_bytes = build_tar_layer(&[10, 128 * 1024]);
1101        let diff_id = crate::sha256_content_digest(&tar_bytes);
1102        let (verity_a, _) = crate::import_layer(&repo_a, &diff_id, None, tar_bytes.as_slice())
1103            .await
1104            .expect("import_layer into repo_a");
1105
1106        // A valid sha512 digest (64 bytes hex) — well-formed but unsupported.
1107        let sha512_diff_id: crate::OciDigest = format!("sha512:{}", "0".repeat(128))
1108            .parse()
1109            .expect("valid sha512 digest");
1110
1111        let (pipe_read, dir_fds, producer) = produce_to_pipe(repo_a, verity_a);
1112
1113        let repo_b_clone = repo_b.clone();
1114        let result = tokio::task::spawn_blocking(move || {
1115            drain_splitdirfdstream_verified(
1116                repo_b_clone,
1117                pipe_read,
1118                dir_fds,
1119                &sha512_diff_id,
1120                false,
1121                composefs::repository::ImportContext::default(),
1122            )
1123        })
1124        .await
1125        .expect("spawn_blocking");
1126
1127        // The drain rejects before reading the pipe, so the producer may error
1128        // with a broken pipe; join it regardless so it isn't left untracked.
1129        drain_producer(producer).await;
1130
1131        match result {
1132            Err(VerifiedDrainError::Other(e)) => {
1133                let msg = format!("{e:#}");
1134                assert!(
1135                    msg.contains("sha256") && msg.contains("sha512"),
1136                    "error should explain the algorithm restriction, got: {msg}"
1137                );
1138            }
1139            other => panic!("expected Other(unsupported algorithm) error, got {other:?}"),
1140        }
1141    }
1142
1143    // -------------------------------------------------------------------------
1144    // Tests for finalize_oci_image
1145    // -------------------------------------------------------------------------
1146
1147    /// End-to-end test for `finalize_oci_image`:
1148    ///
1149    /// 1. Import 2 synthetic tar layers with valid OCI structure.
1150    /// 2. Build matching config + manifest JSON.
1151    /// 3. Call `finalize_oci_image`.
1152    /// 4. Assert config/manifest splitstreams exist and EROFS was produced.
1153    #[tokio::test]
1154    async fn test_finalize_oci_image() {
1155        let (repo, _tempdir) = create_test_repo();
1156
1157        // Import two layers: one all-inline (10 B), one external (128 KiB).
1158        let tar1 = crate::test_util::build_oci_tar_layer(10);
1159        let tar2 = crate::test_util::build_oci_tar_layer(128 * 1024);
1160
1161        let diff_id1 = crate::sha256_content_digest(&tar1);
1162        let diff_id2 = crate::sha256_content_digest(&tar2);
1163
1164        let (verity1, _) = crate::import_layer(&repo, &diff_id1, None, tar1.as_slice())
1165            .await
1166            .expect("import layer 1");
1167        let (verity2, _) = crate::import_layer(&repo, &diff_id2, None, tar2.as_slice())
1168            .await
1169            .expect("import layer 2");
1170
1171        let diff_ids = vec![diff_id1.to_string(), diff_id2.to_string()];
1172        let config_json = crate::test_util::make_config_json(&diff_ids);
1173        let config_digest = crate::sha256_content_digest(&config_json);
1174        let manifest_json =
1175            crate::test_util::make_manifest_json(&config_json, config_digest.as_ref(), &diff_ids);
1176
1177        let layer_refs = vec![(diff_id1.clone(), verity1), (diff_id2.clone(), verity2)];
1178
1179        let ((manifest_digest, manifest_verity), (out_config_digest, config_verity)) =
1180            finalize_oci_image(
1181                &repo,
1182                &manifest_json,
1183                &config_json,
1184                &layer_refs,
1185                Some("test:v1"),
1186            )
1187            .expect("finalize_oci_image");
1188
1189        // Digests must be non-empty.
1190        assert!(!manifest_digest.to_string().is_empty());
1191        assert!(!out_config_digest.to_string().is_empty());
1192
1193        // Splitstreams must exist.
1194        use crate::oci_image::manifest_identifier;
1195        let manifest_id = manifest_identifier(&manifest_digest);
1196        let config_id = crate::config_identifier(&out_config_digest);
1197
1198        assert!(
1199            repo.has_stream(&manifest_id)
1200                .expect("has_stream manifest")
1201                .is_some(),
1202            "manifest splitstream must exist"
1203        );
1204        assert!(
1205            repo.has_stream(&config_id)
1206                .expect("has_stream config")
1207                .is_some(),
1208            "config splitstream must exist"
1209        );
1210
1211        // Verify the returned verities match what's stored.
1212        let stored_manifest_verity = repo
1213            .has_stream(&manifest_id)
1214            .unwrap()
1215            .expect("manifest verity must be stored");
1216        assert_eq!(
1217            manifest_verity, stored_manifest_verity,
1218            "returned manifest_verity must match stored"
1219        );
1220        let stored_config_verity = repo
1221            .has_stream(&config_id)
1222            .unwrap()
1223            .expect("config verity must be stored");
1224        assert_eq!(
1225            config_verity, stored_config_verity,
1226            "returned config_verity must match stored"
1227        );
1228
1229        // EROFS must have been generated for this container image.
1230        let erofs = crate::composefs_erofs_for_manifest(
1231            &repo,
1232            &manifest_digest,
1233            Some(&manifest_verity),
1234            repo.erofs_version(),
1235        )
1236        .expect("composefs_erofs_for_manifest");
1237        assert!(
1238            erofs.is_some(),
1239            "EROFS image must exist after finalize_oci_image for a container image"
1240        );
1241
1242        // Idempotency: calling again must succeed and return the same digests.
1243        let ((md2, _mv2), (cd2, _cv2)) = finalize_oci_image(
1244            &repo,
1245            &manifest_json,
1246            &config_json,
1247            &layer_refs,
1248            Some("test:v1"),
1249        )
1250        .expect("finalize_oci_image idempotent");
1251        assert_eq!(manifest_digest, md2, "idempotent call: manifest_digest");
1252        assert_eq!(out_config_digest, cd2, "idempotent call: config_digest");
1253    }
1254}