1use 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#[derive(Debug, thiserror::Error)]
41pub enum VerifiedDrainError {
42 #[error("layer content does not match declared diff_id: expected {expected}, got {actual}")]
44 DiffIdMismatch {
45 expected: String,
47 actual: String,
49 },
50 #[error(transparent)]
52 Other(#[from] anyhow::Error),
53}
54
55fn 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
77pub 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 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 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 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 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_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 }
253 }
254 }
255 Ok(())
256}
257
258fn 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
271fn 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
299pub 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 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 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 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 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
390pub(crate) fn should_inline(size: u64) -> bool {
396 (size as usize) <= INLINE_CONTENT_MAX_V0
397}
398
399#[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 let file = std::fs::File::from(fd);
420
421 if !should_inline(size) {
422 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 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
461fn 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
477pub 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 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
537pub type FinalizeResult<ObjectID> = (
543 crate::ContentAndVerity<ObjectID>,
544 crate::ContentAndVerity<ObjectID>,
545);
546
547pub 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 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 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 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 #[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 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 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 #[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 #[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; 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 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 async fn assert_produce_eq_cat(
836 repo: &Arc<Repository<Sha256HashValue>>,
837 verity: &Sha256HashValue,
838 ) {
839 use std::os::fd::AsFd as _;
840 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 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 #[tokio::test]
878 async fn test_produce_reconstruct_eq_cat() {
879 let (repo, _tempdir) = create_test_repo();
880
881 let cases: &[(&str, &[usize])] = &[
883 ("empty", &[]),
885 ("all_inline", &[0, 1, 10, 64]),
887 ("single_external", &[65]),
889 ("large_external", &[4096, 200_000]),
891 ("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 assert_produce_eq_cat(&repo, &verity).await;
904 }
905 }
906
907 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 produce_layer_splitdirfdstream(&repo_a, &verity, 0, wf)
940 });
941
942 (pipe_read, vec![objects_owned], handle)
943 }
944
945 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 async fn drain_producer(handle: tokio::task::JoinHandle<anyhow::Result<()>>) {
959 let _ = handle.await.expect("producer task panicked");
960 }
961
962 #[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 let tar_bytes = build_tar_layer(&[10, 128 * 1024]); 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 join_producer(producer).await;
1003
1004 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 assert_eq!(
1016 verity_a, verity_b,
1017 "verity hash must be identical across repos"
1018 );
1019 }
1020
1021 #[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 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 join_producer(producer).await;
1064
1065 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 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 #[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 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 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 #[tokio::test]
1154 async fn test_finalize_oci_image() {
1155 let (repo, _tempdir) = create_test_repo();
1156
1157 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 assert!(!manifest_digest.to_string().is_empty());
1191 assert!(!out_config_digest.to_string().is_empty());
1192
1193 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 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 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 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}