1use std::collections::HashMap;
21use std::path::{Path, PathBuf};
22use std::sync::Arc;
23
24use anyhow::{Context as _, Result};
25use composefs::fsverity::{Algorithm, FsVerityHashValue, Sha256HashValue, Sha512HashValue};
26use composefs::repository::{FsckResult, Repository, RepositoryConfig, system_path, user_path};
27use rustix::fs::CWD;
28use serde::{Deserialize, Serialize};
29
30use crate::{App, HashType, open_repo_at, resolve_hash_type};
31
32#[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
39pub struct FsckReply {
40 pub ok: bool,
42 pub has_metadata: bool,
44 pub objects_checked: u64,
46 pub objects_corrupted: u64,
48 pub streams_checked: u64,
50 pub streams_corrupted: u64,
52 pub images_checked: u64,
54 pub images_corrupted: u64,
56 pub broken_links: u64,
58 pub missing_objects: u64,
60 pub errors: Vec<String>,
68}
69
70impl From<&FsckResult> for FsckReply {
71 fn from(result: &FsckResult) -> Self {
72 Self {
73 ok: result.is_ok(),
74 has_metadata: result.has_metadata(),
75 objects_checked: result.objects_checked(),
76 objects_corrupted: result.objects_corrupted(),
77 streams_checked: result.streams_checked(),
78 streams_corrupted: result.streams_corrupted(),
79 images_checked: result.images_checked(),
80 images_corrupted: result.images_corrupted(),
81 broken_links: result.broken_links(),
82 missing_objects: result.missing_objects(),
83 errors: result.errors().iter().map(|e| e.to_string()).collect(),
84 }
85 }
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
93pub struct GcReply {
94 pub result: composefs::repository::GcResult,
96 pub dry_run: bool,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
102pub struct ImageObjectsReply {
103 pub object_ids: Vec<String>,
106}
107
108#[derive(Debug, zlink::ReplyError, zlink::introspect::ReplyError)]
110#[zlink(interface = "org.composefs.Repository")]
111pub enum RepositoryError {
112 RepoNotFound {
114 message: String,
116 },
117 InvalidHandle {
119 handle: u64,
121 },
122 InvalidSpec {
124 message: String,
126 },
127 NoSuchRef {
129 reference: String,
131 },
132 InternalError {
134 message: String,
136 },
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
159pub struct OpenRepositoryReply {
160 pub handle: u64,
162
163 #[serde(default, skip_serializing_if = "Option::is_none")]
168 pub hash_algorithm: Option<String>,
169
170 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub objects_device_id: Option<u64>,
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
181pub struct InitRepositoryReply {
182 pub created: bool,
185}
186
187#[derive(Debug, Clone)]
192pub(crate) enum OpenRepo {
193 Sha256(Arc<Repository<Sha256HashValue>>),
195 Sha512(Arc<Repository<Sha512HashValue>>),
197}
198
199impl OpenRepo {
200 fn hash_algorithm(&self) -> &'static str {
202 match self {
203 OpenRepo::Sha256(_) => "sha256",
204 OpenRepo::Sha512(_) => "sha512",
205 }
206 }
207
208 fn objects_device_id(&self) -> Option<u64> {
210 let stat_it = |fd: &std::os::fd::OwnedFd| -> Option<u64> {
211 rustix::fs::fstat(fd).ok().map(|s| s.st_dev)
212 };
213 match self {
214 OpenRepo::Sha256(r) => r.objects_dir().ok().and_then(stat_it),
215 OpenRepo::Sha512(r) => r.objects_dir().ok().and_then(stat_it),
216 }
217 }
218}
219
220#[derive(Debug)]
222struct HandleEntry {
223 repo: OpenRepo,
225 #[allow(dead_code)]
229 owner: Option<usize>,
230}
231
232#[derive(Debug, Clone)]
234struct OpenOptions {
235 insecure: bool,
237 require_verity: bool,
239 no_upgrade: bool,
241}
242
243impl OpenOptions {
244 fn from_app(args: &App) -> Self {
246 Self {
247 insecure: args.insecure,
248 require_verity: args.require_verity,
249 no_upgrade: args.no_upgrade,
250 }
251 }
252}
253
254impl Default for OpenOptions {
255 fn default() -> Self {
258 Self {
259 insecure: false,
260 require_verity: false,
261 no_upgrade: false,
262 }
263 }
264}
265
266#[derive(Debug)]
273pub(crate) struct CfsctlService {
274 repos: HashMap<u64, HandleEntry>,
276 next_handle: u64,
278 open_opts: OpenOptions,
280}
281
282impl Default for CfsctlService {
283 fn default() -> Self {
284 Self::new()
285 }
286}
287
288impl CfsctlService {
289 fn with_open_opts(open_opts: OpenOptions) -> Self {
295 Self {
296 repos: HashMap::new(),
297 next_handle: 0,
298 open_opts,
299 }
300 }
301
302 pub(crate) fn from_app(args: &App) -> Self {
309 Self::with_open_opts(OpenOptions::from_app(args))
310 }
311
312 pub(crate) fn activated() -> Self {
316 Self::with_open_opts(OpenOptions::default())
317 }
318
319 pub(crate) fn new() -> Self {
321 Self::with_open_opts(OpenOptions::default())
322 }
323
324 #[cfg(test)]
329 pub(crate) fn insecure_for_test() -> Self {
330 Self::with_open_opts(OpenOptions {
331 insecure: true,
332 require_verity: false,
333 no_upgrade: false,
334 })
335 }
336
337 fn next_handle(&mut self) -> u64 {
339 self.next_handle += 1;
340 self.next_handle
341 }
342
343 fn lookup_repo(&self, handle: u64) -> std::result::Result<OpenRepo, RepositoryError> {
348 self.repos
349 .get(&handle)
350 .map(|entry| entry.repo.clone())
351 .ok_or(RepositoryError::InvalidHandle { handle })
352 }
353
354 #[cfg(feature = "oci")]
359 fn lookup_oci(&self, handle: u64) -> std::result::Result<OpenRepo, oci::OciError> {
360 self.repos
361 .get(&handle)
362 .map(|entry| entry.repo.clone())
363 .ok_or(oci::OciError::InvalidHandle { handle })
364 }
365
366 fn do_open(
373 &mut self,
374 path: &Path,
375 owner: Option<usize>,
376 ) -> std::result::Result<OpenRepositoryReply, RepositoryError> {
377 let hash_type = resolve_hash_type(path, None, !self.open_opts.no_upgrade).map_err(|e| {
378 RepositoryError::RepoNotFound {
379 message: format!("{e:#}"),
380 }
381 })?;
382 let repo = match hash_type {
383 HashType::Sha256 => OpenRepo::Sha256(Arc::new(
384 open_repo_at::<Sha256HashValue>(
385 path,
386 self.open_opts.insecure,
387 self.open_opts.require_verity,
388 self.open_opts.no_upgrade,
389 )
390 .map_err(|e| RepositoryError::RepoNotFound {
391 message: format!("{e:#}"),
392 })?,
393 )),
394 HashType::Sha512 => OpenRepo::Sha512(Arc::new(
395 open_repo_at::<Sha512HashValue>(
396 path,
397 self.open_opts.insecure,
398 self.open_opts.require_verity,
399 self.open_opts.no_upgrade,
400 )
401 .map_err(|e| RepositoryError::RepoNotFound {
402 message: format!("{e:#}"),
403 })?,
404 )),
405 };
406 let handle = self.next_handle();
407 let hash_algorithm = Some(repo.hash_algorithm().to_string());
408 let objects_device_id = repo.objects_device_id();
409 self.repos.insert(handle, HandleEntry { repo, owner });
410 Ok(OpenRepositoryReply {
411 handle,
412 hash_algorithm,
413 objects_device_id,
414 })
415 }
416
417 fn resolve_selector(
422 path: Option<String>,
423 user: Option<bool>,
424 system: Option<bool>,
425 ) -> std::result::Result<PathBuf, RepositoryError> {
426 let user = user.unwrap_or(false);
427 let system = system.unwrap_or(false);
428 match (path, user, system) {
429 (Some(p), false, false) => Ok(PathBuf::from(p)),
430 (None, true, false) => user_path().map_err(|e| RepositoryError::InvalidSpec {
431 message: format!("{e:#}"),
432 }),
433 (None, false, true) => Ok(system_path()),
434 _ => Err(RepositoryError::InvalidSpec {
435 message: "exactly one of `path`, `user`, `system` must be set".into(),
436 }),
437 }
438 }
439}
440
441async fn run_fsck<ObjectID: FsVerityHashValue>(
443 repo: &Repository<ObjectID>,
444 metadata_only: bool,
445) -> std::result::Result<FsckResult, RepositoryError> {
446 let result = if metadata_only {
447 repo.fsck_metadata_only().await
448 } else {
449 repo.fsck().await
450 };
451 result.map_err(|e| RepositoryError::InternalError {
452 message: format!("{e:#}"),
453 })
454}
455
456async fn run_gc<ObjectID: FsVerityHashValue>(
458 repo: &Repository<ObjectID>,
459 dry_run: bool,
460 roots: Vec<String>,
461) -> std::result::Result<GcReply, RepositoryError> {
462 let root_refs: Vec<&str> = roots.iter().map(String::as_str).collect();
463 let result = if dry_run {
464 repo.gc_dry_run(&root_refs)
465 } else {
466 repo.gc(&root_refs)
467 }
468 .map_err(|e| RepositoryError::InternalError {
469 message: format!("{e:#}"),
470 })?;
471 Ok(GcReply { result, dry_run })
472}
473
474async fn run_image_objects<ObjectID: FsVerityHashValue>(
476 repo: &Repository<ObjectID>,
477 name: String,
478) -> std::result::Result<ImageObjectsReply, RepositoryError> {
479 let objects = repo.objects_for_image(&name).map_err(|e| {
480 if let Some(nf) = e.downcast_ref::<composefs::ImageNotFound>() {
481 RepositoryError::NoSuchRef {
482 reference: nf.name.clone(),
483 }
484 } else {
485 RepositoryError::InternalError {
486 message: format!("{e:#}"),
487 }
488 }
489 })?;
490 let mut object_ids: Vec<String> = objects.iter().map(|id| id.to_id()).collect();
491 object_ids.sort();
492 Ok(ImageObjectsReply { object_ids })
493}
494
495#[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
497pub struct ImageRefEntry {
498 pub name: String,
500 pub digest: String,
502}
503
504#[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
506pub struct ListImageRefsReply {
507 pub images: Vec<ImageRefEntry>,
509}
510
511pub fn run_list_image_refs<ObjectID: FsVerityHashValue>(
513 repo: &Repository<ObjectID>,
514) -> std::result::Result<ListImageRefsReply, RepositoryError> {
515 let refs = repo
516 .list_image_refs("")
517 .map_err(|e| RepositoryError::InternalError {
518 message: format!("{e:#}"),
519 })?;
520 let images = refs
521 .into_iter()
522 .map(|(name, target)| {
523 let digest = target.rsplit('/').next().unwrap_or(&target).to_string();
524 ImageRefEntry { name, digest }
525 })
526 .collect();
527 Ok(ListImageRefsReply { images })
528}
529
530#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, zlink::introspect::Type)]
534pub struct MountParams {
535 pub overlay: Option<bool>,
538 pub read_write: Option<bool>,
540}
541
542impl MountParams {
543 fn to_mount_options(
545 &self,
546 fds: Vec<std::os::fd::OwnedFd>,
547 ) -> std::result::Result<composefs::mount::MountOptions, RepositoryError> {
548 let overlay = self.overlay.unwrap_or(false);
549
550 let mut expected_fds = 0;
551 if overlay {
552 expected_fds += 2;
553 }
554
555 if fds.len() != expected_fds {
556 return Err(RepositoryError::InvalidSpec {
557 message: format!(
558 "Mount expects {expected_fds} fds for the requested options, got {}",
559 fds.len()
560 ),
561 });
562 }
563
564 let mut options = composefs::mount::MountOptions::default();
565 let mut fd_iter = fds.into_iter();
566 if overlay {
567 let upperdir = fd_iter.next().unwrap();
568 let workdir = fd_iter.next().unwrap();
569 options.set_overlay(upperdir, workdir);
570 }
571 options.set_read_write(self.read_write.unwrap_or(false));
572
573 Ok(options)
574 }
575}
576
577#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, zlink::introspect::Type)]
579pub struct MountReply {
580 pub fd_index: u32,
582}
583
584fn run_mount<ObjectID: FsVerityHashValue>(
585 repo: &Repository<ObjectID>,
586 name: &str,
587 params: &MountParams,
588 fds: Vec<std::os::fd::OwnedFd>,
589) -> std::result::Result<(MountReply, Vec<std::os::fd::OwnedFd>), RepositoryError> {
590 let options = params.to_mount_options(fds)?;
591
592 let mount_fd =
593 repo.mount_with_options(name, &options)
594 .map_err(|e| RepositoryError::InternalError {
595 message: format!("{e:#}"),
596 })?;
597
598 Ok((MountReply { fd_index: 0 }, vec![mount_fd]))
599}
600
601#[cfg(feature = "oci")]
602fn run_oci_mount<ObjectID: composefs::fsverity::FsVerityHashValue>(
603 repo: &Repository<ObjectID>,
604 image: &str,
605 bootable: bool,
606 params: &MountParams,
607 fds: Vec<std::os::fd::OwnedFd>,
608) -> std::result::Result<(MountReply, Vec<std::os::fd::OwnedFd>), oci::OciError> {
609 let img = if image.starts_with("sha256:") {
610 let digest: composefs_oci::OciDigest =
611 image.parse().map_err(|e| oci::OciError::InternalError {
612 message: format!("Invalid manifest digest: {e}"),
613 })?;
614 composefs_oci::OciImage::open(repo, &digest, None)
615 } else {
616 composefs_oci::OciImage::open_ref(repo, image)
617 }
618 .map_err(|e| oci::OciError::NoSuchImage {
619 image: format!("{image}: {e:#}"),
620 })?;
621
622 let erofs_id = if bootable {
623 img.boot_image_ref(repo.erofs_version())
624 } else {
625 img.image_ref(repo.erofs_version())
626 }
627 .ok_or_else(|| oci::OciError::InternalError {
628 message: if bootable {
629 "No boot EROFS image linked".into()
630 } else {
631 "No composefs EROFS image linked".into()
632 },
633 })?;
634
635 let options = params
636 .to_mount_options(fds)
637 .map_err(|e| oci::OciError::InternalError {
638 message: format!("{e:?}"),
639 })?;
640 let mount_fd = repo
641 .mount_with_options(&erofs_id.to_hex(), &options)
642 .map_err(|e| oci::OciError::InternalError {
643 message: format!("{e:#}"),
644 })?;
645
646 Ok((MountReply { fd_index: 0 }, vec![mount_fd]))
647}
648
649fn run_init_repository(
656 path: &Path,
657 algorithm: Algorithm,
658 insecure: bool,
659) -> std::result::Result<InitRepositoryReply, RepositoryError> {
660 if let Some(parent) = path.parent() {
662 std::fs::create_dir_all(parent).map_err(|e| RepositoryError::InternalError {
663 message: format!("creating parent directories for {}: {e:#}", path.display()),
664 })?;
665 }
666
667 let created = match algorithm {
668 Algorithm::Sha256 { .. } => {
669 let config = if insecure {
670 RepositoryConfig::new(algorithm).set_insecure()
671 } else {
672 RepositoryConfig::new(algorithm)
673 };
674 Repository::<Sha256HashValue>::init_path(CWD, path, config)
675 .map_err(|e| RepositoryError::InternalError {
676 message: format!("{e:#}"),
677 })?
678 .1
679 }
680 Algorithm::Sha512 { .. } => {
681 let config = if insecure {
682 RepositoryConfig::new(algorithm).set_insecure()
683 } else {
684 RepositoryConfig::new(algorithm)
685 };
686 Repository::<Sha512HashValue>::init_path(CWD, path, config)
687 .map_err(|e| RepositoryError::InternalError {
688 message: format!("{e:#}"),
689 })?
690 .1
691 }
692 };
693 Ok(InitRepositoryReply { created })
694}
695
696#[cfg(feature = "oci")]
699async fn run_list_images<ObjectID: FsVerityHashValue>(
700 repo: &Repository<ObjectID>,
701 filter: Option<String>,
702) -> std::result::Result<Vec<oci::ImageEntry>, oci::OciError> {
703 composefs_oci::oci_image::list_images(repo)
704 .map(|imgs| {
705 imgs.iter()
706 .filter(|img| match &filter {
707 Some(needle) => img.name.contains(needle.as_str()),
708 None => true,
709 })
710 .map(oci::ImageEntry::from)
711 .collect()
712 })
713 .map_err(|e| oci::OciError::InternalError {
714 message: format!("{e:#}"),
715 })
716}
717
718#[cfg(feature = "oci")]
723async fn run_oci_fsck<ObjectID: FsVerityHashValue>(
724 repo: &Repository<ObjectID>,
725 image: Option<String>,
726) -> std::result::Result<oci::OciFsckReply, oci::OciError> {
727 let result = match image {
728 Some(name) => composefs_oci::oci_fsck_image(repo, &name).await,
729 None => composefs_oci::oci_fsck(repo).await,
730 }
731 .map_err(|e| oci::OciError::InternalError {
732 message: format!("{e:#}"),
733 })?;
734 Ok(oci::OciFsckReply::from(&result))
735}
736
737#[cfg(feature = "oci")]
739async fn run_inspect<ObjectID: FsVerityHashValue>(
740 repo: &Repository<ObjectID>,
741 image: String,
742) -> std::result::Result<oci::OciInspectReply, oci::OciError> {
743 let reference: crate::OciReference =
744 image.parse().map_err(|e| oci::OciError::InternalError {
745 message: format!("invalid image reference: {e:#}"),
746 })?;
747 let img = crate::resolve_oci_image(repo, &reference).map_err(|e| {
748 if let Some(nf) = e.downcast_ref::<composefs_oci::OciRefNotFound>() {
749 oci::OciError::NoSuchImage {
750 image: nf.name.clone(),
751 }
752 } else if let Some(nf) = e.downcast_ref::<composefs_oci::OciImageNotFound>() {
753 oci::OciError::NoSuchImage {
754 image: nf.digest.clone(),
755 }
756 } else {
757 oci::OciError::InternalError {
758 message: format!("{e:#}"),
759 }
760 }
761 })?;
762
763 oci::OciInspectReply::from_image(repo, &img).map_err(|e| oci::OciError::InternalError {
764 message: format!("{e:#}"),
765 })
766}
767
768#[cfg(feature = "oci")]
770async fn run_tag<ObjectID: FsVerityHashValue>(
771 repo: &Repository<ObjectID>,
772 manifest_digest: String,
773 name: String,
774) -> std::result::Result<(), oci::OciError> {
775 let digest: composefs_oci::OciDigest =
776 manifest_digest
777 .parse()
778 .map_err(|e| oci::OciError::InternalError {
779 message: format!("invalid digest: {e}"),
780 })?;
781 composefs_oci::oci_image::tag_image(repo, &digest, &name).map_err(|e| {
782 oci::OciError::InternalError {
783 message: format!("{e:#}"),
784 }
785 })
786}
787
788#[cfg(feature = "oci")]
790async fn run_untag<ObjectID: FsVerityHashValue>(
791 repo: &Repository<ObjectID>,
792 name: String,
793) -> std::result::Result<(), oci::OciError> {
794 composefs_oci::oci_image::untag_image(repo, &name).map_err(|e| oci::OciError::InternalError {
795 message: format!("{e:#}"),
796 })
797}
798
799#[cfg(feature = "oci")]
805async fn run_compute_id<ObjectID: FsVerityHashValue>(
806 repo: &Repository<ObjectID>,
807 image: String,
808 verity: Option<String>,
809 bootable: bool,
810) -> std::result::Result<oci::OciComputeIdReply, oci::OciError> {
811 let reference: crate::OciReference =
812 image.parse().map_err(|e| oci::OciError::InternalError {
813 message: format!("invalid image reference: {e:#}"),
814 })?;
815 let verity_override =
816 crate::verity_opt::<ObjectID>(&verity).map_err(|e| oci::OciError::InternalError {
817 message: format!("invalid verity: {e:#}"),
818 })?;
819 let (config_digest, config_verity) =
820 crate::resolve_oci_config(repo, &reference, verity_override).map_err(|e| {
821 oci::OciError::InternalError {
822 message: format!("{e:#}"),
823 }
824 })?;
825
826 let mut fs =
827 composefs_oci::image::create_filesystem(repo, &config_digest, config_verity.as_ref())
828 .map_err(|e| oci::OciError::InternalError {
829 message: format!("{e:#}"),
830 })?;
831 if bootable {
832 use composefs_boot::BootOps as _;
833 fs.transform_for_boot(repo)
834 .map_err(|e| oci::OciError::InternalError {
835 message: format!("{e:#}"),
836 })?;
837 }
838 let id = fs.compute_image_id(repo.erofs_version());
839 Ok(oci::OciComputeIdReply {
840 image_id: id.to_hex(),
841 })
842}
843
844#[cfg(not(feature = "oci"))]
862mod service_impl {
863 #![allow(missing_docs)]
864
865 use super::{
866 CfsctlService, FsckReply, GcReply, ImageObjectsReply, InitRepositoryReply,
867 ListImageRefsReply, MountParams, MountReply, OpenRepo, OpenRepositoryReply,
868 RepositoryError, run_fsck, run_gc, run_image_objects, run_init_repository,
869 run_list_image_refs, run_mount,
870 };
871 use composefs::fsverity::{Algorithm, Sha256HashValue, Sha512HashValue};
872
873 #[zlink::service(
874 interface = "org.composefs.Repository",
875 vendor = "org.composefs",
876 product = "cfsctl",
877 version = env!("CARGO_PKG_VERSION"),
878 url = "https://github.com/composefs/composefs-rs"
879 )]
880 impl<Sock> CfsctlService {
881 async fn init_repository(
891 &mut self,
892 path: String,
893 algorithm: Option<String>,
894 insecure: Option<bool>,
895 ) -> std::result::Result<InitRepositoryReply, RepositoryError> {
896 let algorithm: Algorithm = algorithm
897 .as_deref()
898 .unwrap_or("fsverity-sha512-12")
899 .parse()
900 .map_err(|e| RepositoryError::InvalidSpec {
901 message: format!("invalid algorithm: {e}"),
902 })?;
903 let insecure = insecure.unwrap_or(self.open_opts.insecure);
904 run_init_repository(std::path::Path::new(&path), algorithm, insecure)
905 }
906
907 async fn open_repository(
911 &mut self,
912 path: Option<String>,
913 user: Option<bool>,
914 system: Option<bool>,
915 #[zlink(connection)] conn: &mut zlink::Connection<Sock>,
916 ) -> std::result::Result<OpenRepositoryReply, RepositoryError> {
917 let selected = Self::resolve_selector(path, user, system)?;
918 self.do_open(&selected, Some(conn.id()))
919 }
920
921 async fn close_repository(
923 &mut self,
924 handle: u64,
925 ) -> std::result::Result<(), RepositoryError> {
926 self.repos
927 .remove(&handle)
928 .map(|_| ())
929 .ok_or(RepositoryError::InvalidHandle { handle })
930 }
931
932 async fn fsck(
938 &self,
939 handle: u64,
940 metadata_only: Option<bool>,
941 ) -> std::result::Result<FsckReply, RepositoryError> {
942 let metadata_only = metadata_only.unwrap_or(false);
943 let result = match self.lookup_repo(handle)? {
944 OpenRepo::Sha256(ref r) => run_fsck::<Sha256HashValue>(r, metadata_only).await,
945 OpenRepo::Sha512(ref r) => run_fsck::<Sha512HashValue>(r, metadata_only).await,
946 }?;
947 Ok(FsckReply::from(&result))
948 }
949
950 async fn gc(
952 &self,
953 handle: u64,
954 dry_run: bool,
955 roots: Vec<String>,
956 ) -> std::result::Result<GcReply, RepositoryError> {
957 match self.lookup_repo(handle)? {
958 OpenRepo::Sha256(ref r) => run_gc::<Sha256HashValue>(r, dry_run, roots).await,
959 OpenRepo::Sha512(ref r) => run_gc::<Sha512HashValue>(r, dry_run, roots).await,
960 }
961 }
962
963 async fn image_objects(
965 &self,
966 handle: u64,
967 name: String,
968 ) -> std::result::Result<ImageObjectsReply, RepositoryError> {
969 match self.lookup_repo(handle)? {
970 OpenRepo::Sha256(ref r) => run_image_objects::<Sha256HashValue>(r, name).await,
971 OpenRepo::Sha512(ref r) => run_image_objects::<Sha512HashValue>(r, name).await,
972 }
973 }
974
975 async fn list_image_refs(
977 &self,
978 handle: u64,
979 ) -> std::result::Result<ListImageRefsReply, RepositoryError> {
980 match self.lookup_repo(handle)? {
981 OpenRepo::Sha256(ref r) => run_list_image_refs::<Sha256HashValue>(r),
982 OpenRepo::Sha512(ref r) => run_list_image_refs::<Sha512HashValue>(r),
983 }
984 }
985
986 #[zlink(return_fds)]
992 async fn mount(
993 &self,
994 handle: u64,
995 name: String,
996 options: MountParams,
997 #[zlink(fds)] fds: Vec<std::os::fd::OwnedFd>,
998 ) -> (
999 std::result::Result<MountReply, RepositoryError>,
1000 Vec<std::os::fd::OwnedFd>,
1001 ) {
1002 let result = match self.lookup_repo(handle) {
1003 Ok(OpenRepo::Sha256(ref r)) => {
1004 run_mount::<Sha256HashValue>(r, &name, &options, fds)
1005 }
1006 Ok(OpenRepo::Sha512(ref r)) => {
1007 run_mount::<Sha512HashValue>(r, &name, &options, fds)
1008 }
1009 Err(e) => Err(e),
1010 };
1011 match result {
1012 Ok((reply, fds)) => (Ok(reply), fds),
1013 Err(e) => (Err(e), vec![]),
1014 }
1015 }
1016 }
1017}
1018
1019#[cfg(feature = "oci")]
1024mod service_impl {
1025 #![allow(missing_docs)]
1026
1027 use super::layer_sync::{
1028 FinalizeImageReply, GetInfoReply, GetLayerReply, HasLayerReply, LayerRef, PutLayerReply,
1029 };
1030 use super::oci::{
1031 ListImagesReply, OciComputeIdReply, OciError, OciFsckReply, OciInspectReply, PullProgress,
1032 parse_local_fetch, pull_stream,
1033 };
1034 use super::{
1035 CfsctlService, FsckReply, GcReply, ImageObjectsReply, InitRepositoryReply,
1036 ListImageRefsReply, MountParams, MountReply, OpenRepo, OpenRepositoryReply,
1037 RepositoryError, run_compute_id, run_fsck, run_gc, run_image_objects, run_init_repository,
1038 run_inspect, run_list_image_refs, run_list_images, run_mount, run_oci_fsck, run_oci_mount,
1039 run_tag, run_untag,
1040 };
1041 use composefs::fsverity::{Algorithm, FsVerityHashValue, Sha256HashValue, Sha512HashValue};
1042 use composefs_oci::layer_transport::{RepoLayerSource, serve_get_layer};
1043 use composefs_oci::varlink_types::GetLayerParams;
1044 use composefs_splitdirfdstream::seed_from_id;
1045
1046 #[zlink::service(
1047 interface = "org.composefs.Repository",
1048 vendor = "org.composefs",
1049 product = "cfsctl",
1050 version = env!("CARGO_PKG_VERSION"),
1051 url = "https://github.com/composefs/composefs-rs"
1052 )]
1053 impl<Sock> CfsctlService {
1054 async fn init_repository(
1066 &mut self,
1067 path: String,
1068 algorithm: Option<String>,
1069 insecure: Option<bool>,
1070 ) -> std::result::Result<InitRepositoryReply, RepositoryError> {
1071 let algorithm: Algorithm = algorithm
1072 .as_deref()
1073 .unwrap_or("fsverity-sha512-12")
1074 .parse()
1075 .map_err(|e| RepositoryError::InvalidSpec {
1076 message: format!("invalid algorithm: {e}"),
1077 })?;
1078 let insecure = insecure.unwrap_or(self.open_opts.insecure);
1079 run_init_repository(std::path::Path::new(&path), algorithm, insecure)
1080 }
1081
1082 async fn open_repository(
1086 &mut self,
1087 path: Option<String>,
1088 user: Option<bool>,
1089 system: Option<bool>,
1090 #[zlink(connection)] conn: &mut zlink::Connection<Sock>,
1091 ) -> std::result::Result<OpenRepositoryReply, RepositoryError> {
1092 let selected = Self::resolve_selector(path, user, system)?;
1093 self.do_open(&selected, Some(conn.id()))
1094 }
1095
1096 async fn close_repository(
1098 &mut self,
1099 handle: u64,
1100 ) -> std::result::Result<(), RepositoryError> {
1101 self.repos
1102 .remove(&handle)
1103 .map(|_| ())
1104 .ok_or(RepositoryError::InvalidHandle { handle })
1105 }
1106
1107 async fn fsck(
1113 &self,
1114 handle: u64,
1115 metadata_only: Option<bool>,
1116 ) -> std::result::Result<FsckReply, RepositoryError> {
1117 let metadata_only = metadata_only.unwrap_or(false);
1118 let result = match self.lookup_repo(handle)? {
1119 OpenRepo::Sha256(ref r) => run_fsck::<Sha256HashValue>(r, metadata_only).await,
1120 OpenRepo::Sha512(ref r) => run_fsck::<Sha512HashValue>(r, metadata_only).await,
1121 }?;
1122 Ok(FsckReply::from(&result))
1123 }
1124
1125 async fn gc(
1127 &self,
1128 handle: u64,
1129 dry_run: bool,
1130 roots: Vec<String>,
1131 ) -> std::result::Result<GcReply, RepositoryError> {
1132 match self.lookup_repo(handle)? {
1133 OpenRepo::Sha256(ref r) => run_gc::<Sha256HashValue>(r, dry_run, roots).await,
1134 OpenRepo::Sha512(ref r) => run_gc::<Sha512HashValue>(r, dry_run, roots).await,
1135 }
1136 }
1137
1138 async fn image_objects(
1140 &self,
1141 handle: u64,
1142 name: String,
1143 ) -> std::result::Result<ImageObjectsReply, RepositoryError> {
1144 match self.lookup_repo(handle)? {
1145 OpenRepo::Sha256(ref r) => run_image_objects::<Sha256HashValue>(r, name).await,
1146 OpenRepo::Sha512(ref r) => run_image_objects::<Sha512HashValue>(r, name).await,
1147 }
1148 }
1149
1150 async fn list_image_refs(
1152 &self,
1153 handle: u64,
1154 ) -> std::result::Result<ListImageRefsReply, RepositoryError> {
1155 match self.lookup_repo(handle)? {
1156 OpenRepo::Sha256(ref r) => run_list_image_refs::<Sha256HashValue>(r),
1157 OpenRepo::Sha512(ref r) => run_list_image_refs::<Sha512HashValue>(r),
1158 }
1159 }
1160
1161 #[zlink(return_fds)]
1167 async fn mount(
1168 &self,
1169 handle: u64,
1170 name: String,
1171 options: MountParams,
1172 #[zlink(fds)] fds: Vec<std::os::fd::OwnedFd>,
1173 ) -> (
1174 std::result::Result<MountReply, RepositoryError>,
1175 Vec<std::os::fd::OwnedFd>,
1176 ) {
1177 let result = match self.lookup_repo(handle) {
1178 Ok(OpenRepo::Sha256(ref r)) => {
1179 run_mount::<Sha256HashValue>(r, &name, &options, fds)
1180 }
1181 Ok(OpenRepo::Sha512(ref r)) => {
1182 run_mount::<Sha512HashValue>(r, &name, &options, fds)
1183 }
1184 Err(e) => Err(e),
1185 };
1186 match result {
1187 Ok((reply, fds)) => (Ok(reply), fds),
1188 Err(e) => (Err(e), vec![]),
1189 }
1190 }
1191
1192 #[zlink(interface = "org.composefs.Oci")]
1203 async fn list_images(
1204 &self,
1205 handle: u64,
1206 filter: Option<String>,
1207 ) -> std::result::Result<ListImagesReply, OciError> {
1208 let images = match self.lookup_oci(handle)? {
1209 OpenRepo::Sha256(ref r) => run_list_images::<Sha256HashValue>(r, filter).await,
1210 OpenRepo::Sha512(ref r) => run_list_images::<Sha512HashValue>(r, filter).await,
1211 }?;
1212 Ok(ListImagesReply { images })
1213 }
1214
1215 #[zlink(interface = "org.composefs.Oci", rename = "Check")]
1221 async fn oci_fsck(
1222 &self,
1223 handle: u64,
1224 image: Option<String>,
1225 ) -> std::result::Result<OciFsckReply, OciError> {
1226 match self.lookup_oci(handle)? {
1227 OpenRepo::Sha256(ref r) => run_oci_fsck::<Sha256HashValue>(r, image).await,
1228 OpenRepo::Sha512(ref r) => run_oci_fsck::<Sha512HashValue>(r, image).await,
1229 }
1230 }
1231
1232 #[zlink(interface = "org.composefs.Oci")]
1234 async fn inspect(
1235 &self,
1236 handle: u64,
1237 image: String,
1238 ) -> std::result::Result<OciInspectReply, OciError> {
1239 match self.lookup_oci(handle)? {
1240 OpenRepo::Sha256(ref r) => run_inspect::<Sha256HashValue>(r, image).await,
1241 OpenRepo::Sha512(ref r) => run_inspect::<Sha512HashValue>(r, image).await,
1242 }
1243 }
1244
1245 #[zlink(interface = "org.composefs.Oci")]
1247 async fn tag(
1248 &self,
1249 handle: u64,
1250 manifest_digest: String,
1251 name: String,
1252 ) -> std::result::Result<(), OciError> {
1253 match self.lookup_oci(handle)? {
1254 OpenRepo::Sha256(ref r) => {
1255 run_tag::<Sha256HashValue>(r, manifest_digest, name).await
1256 }
1257 OpenRepo::Sha512(ref r) => {
1258 run_tag::<Sha512HashValue>(r, manifest_digest, name).await
1259 }
1260 }
1261 }
1262
1263 #[zlink(interface = "org.composefs.Oci")]
1265 async fn untag(&self, handle: u64, name: String) -> std::result::Result<(), OciError> {
1266 match self.lookup_oci(handle)? {
1267 OpenRepo::Sha256(ref r) => run_untag::<Sha256HashValue>(r, name).await,
1268 OpenRepo::Sha512(ref r) => run_untag::<Sha512HashValue>(r, name).await,
1269 }
1270 }
1271
1272 #[zlink(interface = "org.composefs.Oci")]
1274 async fn compute_id(
1275 &self,
1276 handle: u64,
1277 image: String,
1278 verity: Option<String>,
1279 bootable: bool,
1280 ) -> std::result::Result<OciComputeIdReply, OciError> {
1281 match self.lookup_oci(handle)? {
1282 OpenRepo::Sha256(ref r) => {
1283 run_compute_id::<Sha256HashValue>(r, image, verity, bootable).await
1284 }
1285 OpenRepo::Sha512(ref r) => {
1286 run_compute_id::<Sha512HashValue>(r, image, verity, bootable).await
1287 }
1288 }
1289 }
1290
1291 #[zlink(interface = "org.composefs.Oci", more)]
1297 #[allow(clippy::too_many_arguments)]
1298 async fn pull(
1299 &self,
1300 more: bool,
1301 handle: u64,
1302 image: String,
1303 name: Option<String>,
1304 local_fetch: String,
1305 storage_root: Option<String>,
1306 bootable: bool,
1307 ) -> impl zlink::futures_util::Stream<
1308 Item = std::result::Result<zlink::Reply<PullProgress>, OciError>,
1309 > {
1310 let lf = parse_local_fetch(&local_fetch);
1311 let sr = storage_root.map(std::path::PathBuf::from);
1312 match self.repos.get(&handle).map(|entry| &entry.repo) {
1317 Some(OpenRepo::Sha256(r)) => {
1318 pull_stream::<Sha256HashValue>(r.clone(), image, name, lf, sr, bootable, more)
1319 }
1320 Some(OpenRepo::Sha512(r)) => {
1321 pull_stream::<Sha512HashValue>(r.clone(), image, name, lf, sr, bootable, more)
1322 }
1323 None => {
1324 use zlink::futures_util::stream;
1325 Box::pin(stream::once(async move {
1326 Err(OciError::InvalidHandle { handle })
1327 }))
1328 }
1329 }
1330 }
1331
1332 #[zlink(interface = "org.composefs.Oci", return_fds)]
1339 async fn oci_mount(
1340 &self,
1341 handle: u64,
1342 image: String,
1343 bootable: bool,
1344 options: MountParams,
1345 #[zlink(fds)] fds: Vec<std::os::fd::OwnedFd>,
1346 ) -> (
1347 std::result::Result<MountReply, OciError>,
1348 Vec<std::os::fd::OwnedFd>,
1349 ) {
1350 let result = match self.lookup_oci(handle) {
1351 Ok(OpenRepo::Sha256(ref r)) => {
1352 run_oci_mount::<Sha256HashValue>(r, &image, bootable, &options, fds)
1353 }
1354 Ok(OpenRepo::Sha512(ref r)) => {
1355 run_oci_mount::<Sha512HashValue>(r, &image, bootable, &options, fds)
1356 }
1357 Err(e) => Err(e),
1358 };
1359 match result {
1360 Ok((reply, fds)) => (Ok(reply), fds),
1361 Err(e) => (Err(e), vec![]),
1362 }
1363 }
1364
1365 #[zlink(interface = "org.composefs.Oci")]
1375 async fn get_info(&self) -> std::result::Result<GetInfoReply, OciError> {
1376 Ok(GetInfoReply {
1377 features: vec!["splitdirfdstream-v0".into()],
1378 })
1379 }
1380
1381 #[zlink(interface = "org.composefs.Oci")]
1386 async fn has_layer(
1387 &self,
1388 handle: u64,
1389 diff_id: String,
1390 ) -> std::result::Result<HasLayerReply, OciError> {
1391 let diff_id_parsed: composefs_oci::OciDigest =
1392 diff_id.parse().map_err(|e| OciError::InvalidDigest {
1393 message: format!("{e}"),
1394 })?;
1395 let content_id = composefs_oci::layer_content_id(&diff_id_parsed);
1396
1397 fn check<ObjectID: FsVerityHashValue>(
1398 repo: &composefs::repository::Repository<ObjectID>,
1399 content_id: &str,
1400 ) -> std::result::Result<HasLayerReply, OciError> {
1401 match repo
1402 .has_stream(content_id)
1403 .map_err(|e| OciError::InternalError {
1404 message: format!("{e:#}"),
1405 })? {
1406 Some(verity) => Ok(HasLayerReply {
1407 present: true,
1408 layer_verity: Some(verity.to_hex()),
1409 }),
1410 None => Ok(HasLayerReply {
1411 present: false,
1412 layer_verity: None,
1413 }),
1414 }
1415 }
1416
1417 match self.lookup_oci(handle)? {
1418 OpenRepo::Sha256(ref r) => check::<Sha256HashValue>(r, &content_id),
1419 OpenRepo::Sha512(ref r) => check::<Sha512HashValue>(r, &content_id),
1420 }
1421 }
1422
1423 #[zlink(interface = "org.composefs.Oci", more, return_fds)]
1445 async fn get_layer(
1446 &self,
1447 more: bool,
1448 handle: u64,
1449 params: GetLayerParams,
1450 #[zlink(fds)] _fds: Vec<std::os::fd::OwnedFd>,
1451 ) -> impl zlink::futures_util::Stream<
1452 Item = (
1453 std::result::Result<zlink::Reply<GetLayerReply>, OciError>,
1454 Vec<std::os::fd::OwnedFd>,
1455 ),
1456 > + Unpin {
1457 use zlink::futures_util::stream::{self, StreamExt as _};
1458
1459 type StreamItem = (
1460 std::result::Result<zlink::Reply<GetLayerReply>, OciError>,
1461 Vec<std::os::fd::OwnedFd>,
1462 );
1463
1464 macro_rules! err_stream {
1465 ($e:expr) => {
1466 return stream::iter(std::iter::once::<StreamItem>((Err($e), vec![])))
1467 .left_stream()
1468 };
1469 }
1470
1471 let diff_id = match params.diff_id {
1473 Some(d) => d,
1474 None => err_stream!(OciError::InvalidRequest {
1475 message: "GetLayer: diff_id is required for the repo service".into(),
1476 }),
1477 };
1478
1479 let diff_id_parsed: composefs_oci::OciDigest = match diff_id.parse() {
1481 Ok(d) => d,
1482 Err(e) => err_stream!(OciError::InvalidDigest {
1483 message: format!("{e}"),
1484 }),
1485 };
1486 let content_id = composefs_oci::layer_content_id(&diff_id_parsed);
1487
1488 fn do_serve_get_layer<ObjectID: FsVerityHashValue>(
1490 repo: &std::sync::Arc<composefs::repository::Repository<ObjectID>>,
1491 content_id: &str,
1492 diff_id_str: &str,
1493 more: bool,
1494 ) -> std::result::Result<composefs_oci::layer_transport::GetLayerFrames, OciError>
1495 {
1496 let verity = repo
1497 .has_stream(content_id)
1498 .map_err(|e| OciError::InternalError {
1499 message: format!("{e:#}"),
1500 })?
1501 .ok_or_else(|| OciError::NoSuchLayer {
1502 diff_id: diff_id_str.to_string(),
1503 })?;
1504
1505 let seed = seed_from_id(content_id);
1506 let source = RepoLayerSource {
1507 repo: repo.clone(),
1508 layer_verity: verity,
1509 };
1510
1511 serve_get_layer(source, seed, more).map_err(|e| match e {
1512 composefs_oci::layer_transport::ServeGetLayerError::FdLimitExceeded(e) => {
1513 OciError::FdLimitExceeded {
1514 fd_count: e.fd_count as u64,
1515 max_per_frame: e.max_per_frame as u64,
1516 }
1517 }
1518 composefs_oci::layer_transport::ServeGetLayerError::Other(e) => {
1519 OciError::InternalError {
1520 message: format!("{e:#}"),
1521 }
1522 }
1523 })
1524 }
1525
1526 let frames = match self.lookup_oci(handle) {
1527 Ok(OpenRepo::Sha256(ref r)) => {
1528 do_serve_get_layer::<Sha256HashValue>(r, &content_id, &diff_id, more)
1529 }
1530 Ok(OpenRepo::Sha512(ref r)) => {
1531 do_serve_get_layer::<Sha512HashValue>(r, &content_id, &diff_id, more)
1532 }
1533 Err(e) => Err(e),
1534 };
1535
1536 let frames = match frames {
1537 Ok(f) => f,
1538 Err(e) => err_stream!(e),
1539 };
1540
1541 let dir_count = frames.dir_count;
1542 let batches = frames.batches;
1543 let n_frames = batches.len();
1544 let reply = GetLayerReply { dir_count };
1545
1546 stream::iter(batches.into_iter().enumerate().map(move |(i, batch)| {
1547 let is_last = i == n_frames - 1;
1548 (
1549 Ok(zlink::Reply::new(Some(reply.clone())).set_continues(Some(!is_last))),
1550 batch,
1551 )
1552 }))
1553 .right_stream()
1554 }
1555
1556 #[zlink(interface = "org.composefs.Oci")]
1573 async fn put_layer(
1574 &self,
1575 handle: u64,
1576 diff_id: String,
1577 zerocopy: bool,
1578 #[zlink(fds)] fds: Vec<std::os::fd::OwnedFd>,
1579 ) -> std::result::Result<PutLayerReply, OciError> {
1580 if fds.len() < 2 {
1582 return Err(OciError::InvalidRequest {
1583 message: format!(
1584 "expected at least 2 fds (1 pipe + >=1 dir fd), got {}",
1585 fds.len()
1586 ),
1587 });
1588 }
1589
1590 let diff_id_parsed: composefs_oci::OciDigest =
1591 diff_id.parse().map_err(|e| OciError::InvalidDigest {
1592 message: format!("{e}"),
1593 })?;
1594
1595 let content_id = composefs_oci::layer_content_id(&diff_id_parsed);
1596
1597 let already_present = match self.lookup_oci(handle)? {
1601 OpenRepo::Sha256(ref r) => r
1602 .has_stream(&content_id)
1603 .map_err(|e| OciError::InternalError {
1604 message: format!("{e:#}"),
1605 })?
1606 .is_some(),
1607 OpenRepo::Sha512(ref r) => r
1608 .has_stream(&content_id)
1609 .map_err(|e| OciError::InternalError {
1610 message: format!("{e:#}"),
1611 })?
1612 .is_some(),
1613 };
1614
1615 let mut fds = fds;
1617 let pipe_read = fds.remove(0);
1618 let dir_fds = fds; async fn run_put_layer<ObjectID: FsVerityHashValue>(
1621 repo: std::sync::Arc<composefs::repository::Repository<ObjectID>>,
1622 pipe_read: std::os::fd::OwnedFd,
1623 dir_fds: Vec<std::os::fd::OwnedFd>,
1624 diff_id: composefs_oci::OciDigest,
1625 zerocopy: bool,
1626 already_present: bool,
1627 ) -> std::result::Result<PutLayerReply, OciError> {
1628 tokio::task::spawn_blocking(move || {
1629 composefs_oci::layer_sync::drain_splitdirfdstream_verified(
1630 repo,
1631 pipe_read,
1632 dir_fds,
1633 &diff_id,
1634 zerocopy,
1635 composefs::repository::ImportContext::default(),
1636 )
1637 })
1638 .await
1639 .map_err(|e| OciError::InternalError {
1640 message: format!("spawn_blocking panic: {e}"),
1641 })?
1642 .map(|(verity, stats, _ctx)| PutLayerReply {
1643 layer_verity: verity.to_hex(),
1644 already_present,
1645 objects_reflinked: stats.objects_reflinked,
1646 objects_hardlinked: stats.objects_hardlinked,
1647 objects_copied: stats.objects_copied,
1648 objects_already_present: stats.objects_already_present,
1649 })
1650 .map_err(|e| match e {
1651 composefs_oci::layer_sync::VerifiedDrainError::DiffIdMismatch {
1652 expected,
1653 actual,
1654 } => OciError::DiffIdMismatch { expected, actual },
1655 composefs_oci::layer_sync::VerifiedDrainError::Other(err) => {
1656 OciError::InternalError {
1657 message: format!("{err:#}"),
1658 }
1659 }
1660 })
1661 }
1662
1663 match self.lookup_oci(handle)? {
1664 OpenRepo::Sha256(ref r) => {
1665 run_put_layer::<Sha256HashValue>(
1666 r.clone(),
1667 pipe_read,
1668 dir_fds,
1669 diff_id_parsed,
1670 zerocopy,
1671 already_present,
1672 )
1673 .await
1674 }
1675 OpenRepo::Sha512(ref r) => {
1676 run_put_layer::<Sha512HashValue>(
1677 r.clone(),
1678 pipe_read,
1679 dir_fds,
1680 diff_id_parsed,
1681 zerocopy,
1682 already_present,
1683 )
1684 .await
1685 }
1686 }
1687 }
1688
1689 #[zlink(interface = "org.composefs.Oci")]
1699 async fn finalize_image(
1700 &self,
1701 handle: u64,
1702 manifest_json: String,
1703 config_json: String,
1704 layers: Vec<LayerRef>,
1705 name: Option<String>,
1706 ) -> std::result::Result<FinalizeImageReply, OciError> {
1707 async fn run_finalize<ObjectID: FsVerityHashValue>(
1708 repo: std::sync::Arc<composefs::repository::Repository<ObjectID>>,
1709 manifest_json: String,
1710 config_json: String,
1711 layers: Vec<LayerRef>,
1712 name: Option<String>,
1713 ) -> std::result::Result<FinalizeImageReply, OciError> {
1714 let mut layer_refs: Vec<(composefs_oci::OciDigest, ObjectID)> =
1716 Vec::with_capacity(layers.len());
1717 for lr in &layers {
1718 let diff_id: composefs_oci::OciDigest =
1719 lr.diff_id.parse().map_err(|e| OciError::InvalidDigest {
1720 message: format!("diff_id {:?}: {e}", lr.diff_id),
1721 })?;
1722 let verity = ObjectID::from_hex(&lr.layer_verity).map_err(|e| {
1723 OciError::InvalidDigest {
1724 message: format!("layer_verity {:?}: {e}", lr.layer_verity),
1725 }
1726 })?;
1727 layer_refs.push((diff_id, verity));
1728 }
1729
1730 tokio::task::spawn_blocking(move || {
1731 composefs_oci::layer_sync::finalize_oci_image(
1732 &repo,
1733 manifest_json.as_bytes(),
1734 config_json.as_bytes(),
1735 &layer_refs,
1736 name.as_deref(),
1737 )
1738 })
1739 .await
1740 .map_err(|e| OciError::InternalError {
1741 message: format!("spawn_blocking panic: {e}"),
1742 })?
1743 .map(
1744 |((manifest_digest, manifest_verity), (config_digest, config_verity))| {
1745 FinalizeImageReply {
1746 manifest_digest: manifest_digest.to_string(),
1747 manifest_verity: manifest_verity.to_hex(),
1748 config_digest: config_digest.to_string(),
1749 config_verity: config_verity.to_hex(),
1750 }
1751 },
1752 )
1753 .map_err(|e| OciError::InternalError {
1754 message: format!("{e:#}"),
1755 })
1756 }
1757
1758 match self.lookup_oci(handle)? {
1759 OpenRepo::Sha256(ref r) => {
1760 run_finalize::<Sha256HashValue>(
1761 r.clone(),
1762 manifest_json,
1763 config_json,
1764 layers,
1765 name,
1766 )
1767 .await
1768 }
1769 OpenRepo::Sha512(ref r) => {
1770 run_finalize::<Sha512HashValue>(
1771 r.clone(),
1772 manifest_json,
1773 config_json,
1774 layers,
1775 name,
1776 )
1777 .await
1778 }
1779 }
1780 }
1781 }
1782}
1783
1784#[derive(Debug)]
1791pub(crate) struct ActivatedListener {
1792 conn: Option<zlink::Connection<zlink::unix::Stream>>,
1794}
1795
1796impl zlink::Listener for ActivatedListener {
1797 type Socket = zlink::unix::Stream;
1798
1799 async fn accept(&mut self) -> zlink::Result<Option<zlink::Connection<Self::Socket>>> {
1800 match self.conn.take() {
1801 Some(conn) => Ok(Some(conn)),
1802 None => std::future::pending().await,
1803 }
1804 }
1805}
1806
1807pub(crate) enum ActivatedSocket {
1809 Connected(ActivatedListener),
1812 Listening(zlink::unix::Listener),
1815}
1816
1817#[allow(unsafe_code)]
1830pub(crate) fn try_activated_listener() -> Result<Option<ActivatedSocket>> {
1831 use std::os::fd::{FromRawFd as _, IntoRawFd as _, OwnedFd};
1832
1833 let fds = libsystemd::activation::receive_descriptors(true)
1834 .map_err(|e| anyhow::anyhow!("Failed to receive activation fds: {e}"))?;
1835
1836 let fd = match fds.into_iter().next() {
1837 Some(fd) => fd,
1838 None => return Ok(None),
1839 };
1840
1841 let owned: OwnedFd = unsafe { OwnedFd::from_raw_fd(fd.into_raw_fd()) };
1845
1846 let is_listening = rustix::net::sockopt::socket_acceptconn(&owned)
1849 .context("querying SO_ACCEPTCONN on activation fd")?;
1850
1851 if is_listening {
1852 let listener = zlink::unix::Listener::try_from(owned)
1855 .context("converting listening activation fd to zlink Listener")?;
1856 Ok(Some(ActivatedSocket::Listening(listener)))
1857 } else {
1858 let std_stream = std::os::unix::net::UnixStream::from(owned);
1861 std_stream
1862 .set_nonblocking(true)
1863 .context("setting systemd socket to non-blocking")?;
1864 let tokio_stream = tokio::net::UnixStream::from_std(std_stream)
1865 .context("converting systemd UnixStream to tokio")?;
1866 let zlink_stream =
1867 zlink::unix::Stream::try_from(tokio_stream).map_err(|e| anyhow::anyhow!(e))?;
1868 let conn = zlink::Connection::new(zlink_stream);
1869 Ok(Some(ActivatedSocket::Connected(ActivatedListener {
1870 conn: Some(conn),
1871 })))
1872 }
1873}
1874
1875pub(crate) async fn serve_activated<S>(service: S, listener: ActivatedListener) -> Result<()>
1885where
1886 S: zlink::Service<zlink::unix::Stream>,
1887{
1888 log::info!("Listening on systemd-activated socket");
1889 let server = zlink::Server::new(listener, service);
1890 tokio::task::LocalSet::new()
1891 .run_until(server.run())
1892 .await
1893 .context("running varlink server (activated)")
1894}
1895
1896pub(crate) async fn serve_on_listener<S>(service: S, listener: zlink::unix::Listener) -> Result<()>
1902where
1903 S: zlink::Service<zlink::unix::Stream>,
1904{
1905 let server = zlink::Server::new(listener, service);
1906 tokio::task::LocalSet::new()
1907 .run_until(server.run())
1908 .await
1909 .context("running varlink server")
1910}
1911
1912pub(crate) async fn serve<S>(service: S, address: Option<&Path>) -> Result<()>
1920where
1921 S: zlink::Service<zlink::unix::Stream>,
1922{
1923 match try_activated_listener()? {
1924 Some(ActivatedSocket::Connected(l)) => return serve_activated(service, l).await,
1925 Some(ActivatedSocket::Listening(listener)) => {
1926 log::info!("Listening on systemd-activated socket");
1927 return serve_on_listener(service, listener).await;
1928 }
1929 None => {}
1930 }
1931 let address = address.context("no --address given and not socket-activated")?;
1932 let listener = zlink::unix::bind(address)
1933 .with_context(|| format!("binding varlink socket at {}", address.display()))?;
1934 log::info!("Listening on {}", address.display());
1935 serve_on_listener(service, listener).await
1936}
1937
1938#[cfg(feature = "oci")]
1943pub mod oci {
1944 use super::*;
1945
1946 #[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
1948 pub struct ImageEntry {
1949 pub name: String,
1951 pub manifest_digest: String,
1953 pub is_container: bool,
1955 pub architecture: String,
1957 pub os: String,
1959 pub created: Option<String>,
1961 pub layer_count: u64,
1963 pub referrer_count: u64,
1965 }
1966
1967 impl From<&composefs_oci::oci_image::ImageInfo> for ImageEntry {
1968 fn from(info: &composefs_oci::oci_image::ImageInfo) -> Self {
1969 Self {
1970 name: info.name.clone(),
1971 manifest_digest: info.manifest_digest.to_string(),
1972 is_container: info.is_container,
1973 architecture: info.architecture.clone(),
1974 os: info.os.clone(),
1975 created: info.created.clone(),
1976 layer_count: info.layer_count as u64,
1977 referrer_count: info.referrer_count as u64,
1978 }
1979 }
1980 }
1981
1982 #[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
1984 pub struct ListImagesReply {
1985 pub images: Vec<ImageEntry>,
1987 }
1988
1989 #[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
1994 pub struct OciFsckReply {
1995 pub ok: bool,
1997 pub images_checked: u64,
1999 pub images_corrupted: u64,
2001 pub errors: Vec<String>,
2003 pub repo: FsckReply,
2005 }
2006
2007 impl From<&composefs_oci::OciFsckResult> for OciFsckReply {
2008 fn from(result: &composefs_oci::OciFsckResult) -> Self {
2009 Self {
2010 ok: result.is_ok(),
2011 images_checked: result.images_checked(),
2012 images_corrupted: result.images_corrupted(),
2013 errors: result.errors().iter().map(|e| e.to_string()).collect(),
2014 repo: FsckReply::from(result.repo_result()),
2015 }
2016 }
2017 }
2018
2019 #[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
2021 pub struct OciInspectReply {
2022 pub manifest: String,
2024 pub config: String,
2026 pub referrers: Vec<String>,
2028 pub composefs_erofs: Option<String>,
2030 pub composefs_boot_erofs: Option<String>,
2036 }
2037
2038 impl OciInspectReply {
2039 pub fn from_image<ObjectID: FsVerityHashValue>(
2042 repo: &Repository<ObjectID>,
2043 img: &composefs_oci::oci_image::OciImage<ObjectID>,
2044 ) -> anyhow::Result<Self> {
2045 let manifest = String::from_utf8(img.read_manifest_json(repo)?)
2046 .context("manifest is not valid UTF-8")?;
2047 let config = String::from_utf8(img.read_config_json(repo)?)
2048 .context("config is not valid UTF-8")?;
2049 let referrers = composefs_oci::oci_image::list_referrers(repo, img.manifest_digest())?
2050 .iter()
2051 .map(|(digest, _verity)| digest.to_string())
2052 .collect();
2053 Ok(Self {
2054 manifest,
2055 config,
2056 referrers,
2057 composefs_erofs: img.image_ref(repo.erofs_version()).map(|id| id.to_hex()),
2058 composefs_boot_erofs: img
2059 .boot_image_ref(repo.erofs_version())
2060 .map(|id| id.to_hex()),
2061 })
2062 }
2063 }
2064
2065 #[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
2067 pub struct OciComputeIdReply {
2068 pub image_id: String,
2070 }
2071
2072 #[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
2085 pub struct PullProgress {
2086 #[serde(skip_serializing_if = "Option::is_none", default)]
2088 pub started: Option<Started>,
2089 #[serde(skip_serializing_if = "Option::is_none", default)]
2091 pub progress: Option<Progress>,
2092 #[serde(skip_serializing_if = "Option::is_none", default)]
2094 pub skipped: Option<Skipped>,
2095 #[serde(skip_serializing_if = "Option::is_none", default)]
2097 pub done: Option<Done>,
2098 #[serde(skip_serializing_if = "Option::is_none", default)]
2100 pub message: Option<String>,
2101 #[serde(skip_serializing_if = "Option::is_none", default)]
2104 pub completed: Option<Completed>,
2105 }
2106
2107 #[derive(Debug, Clone, Copy, Serialize, Deserialize, zlink::introspect::Type)]
2109 pub enum ProgressUnit {
2110 Bytes,
2112 Items,
2114 }
2115
2116 impl From<composefs::progress::ProgressUnit> for ProgressUnit {
2117 fn from(unit: composefs::progress::ProgressUnit) -> Self {
2118 use composefs::progress::ProgressUnit as U;
2119 match unit {
2120 U::Bytes => ProgressUnit::Bytes,
2121 U::Items => ProgressUnit::Items,
2122 _ => ProgressUnit::Items,
2124 }
2125 }
2126 }
2127
2128 #[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
2130 pub struct Started {
2131 pub id: String,
2133 pub total: Option<u64>,
2135 pub unit: ProgressUnit,
2137 }
2138
2139 #[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
2141 pub struct Progress {
2142 pub id: String,
2144 pub fetched: u64,
2146 pub total: Option<u64>,
2148 }
2149
2150 #[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
2152 pub struct Skipped {
2153 pub id: String,
2155 }
2156
2157 #[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
2159 pub struct Done {
2160 pub id: String,
2162 pub transferred: u64,
2164 }
2165
2166 #[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
2168 pub struct Completed {
2169 pub manifest_digest: String,
2171 pub config_digest: String,
2173 pub manifest_verity: String,
2175 pub config_verity: String,
2177 pub stats: String,
2179 pub boot_image: Option<String>,
2182 }
2183
2184 impl PullProgress {
2185 fn empty() -> Self {
2188 PullProgress {
2189 started: None,
2190 progress: None,
2191 skipped: None,
2192 done: None,
2193 message: None,
2194 completed: None,
2195 }
2196 }
2197 }
2198
2199 impl From<composefs::progress::ProgressEvent> for PullProgress {
2200 fn from(event: composefs::progress::ProgressEvent) -> Self {
2204 use composefs::progress::ProgressEvent;
2205
2206 let mut p = PullProgress::empty();
2207 match event {
2208 ProgressEvent::Started { id, total, unit } => {
2209 p.started = Some(Started {
2210 id: id.into_inner(),
2211 total,
2212 unit: unit.into(),
2213 });
2214 }
2215 ProgressEvent::Progress { id, fetched, total } => {
2216 p.progress = Some(Progress {
2217 id: id.into_inner(),
2218 fetched,
2219 total,
2220 });
2221 }
2222 ProgressEvent::Skipped { id } => {
2223 p.skipped = Some(Skipped {
2224 id: id.into_inner(),
2225 });
2226 }
2227 ProgressEvent::Done { id, transferred } => {
2228 p.done = Some(Done {
2229 id: id.into_inner(),
2230 transferred,
2231 });
2232 }
2233 ProgressEvent::Message(s) => {
2234 p.message = Some(s);
2235 }
2236 other => {
2239 p.message = Some(format!("{other:?}"));
2240 }
2241 }
2242 p
2243 }
2244 }
2245
2246 struct ChannelReporter {
2249 tx: tokio::sync::mpsc::UnboundedSender<PullProgress>,
2250 }
2251
2252 impl std::fmt::Debug for ChannelReporter {
2253 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2254 f.debug_struct("ChannelReporter").finish_non_exhaustive()
2255 }
2256 }
2257
2258 impl composefs::progress::ProgressReporter for ChannelReporter {
2259 fn report(&self, event: composefs::progress::ProgressEvent) {
2260 let _ = self.tx.send(PullProgress::from(event));
2263 }
2264 }
2265
2266 struct AbortOnDrop {
2272 handle: Option<tokio::task::JoinHandle<std::result::Result<(), OciError>>>,
2273 }
2274
2275 impl AbortOnDrop {
2276 fn take(&mut self) -> Option<tokio::task::JoinHandle<std::result::Result<(), OciError>>> {
2278 self.handle.take()
2279 }
2280 }
2281
2282 impl std::fmt::Debug for AbortOnDrop {
2283 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2284 f.debug_struct("AbortOnDrop").finish_non_exhaustive()
2285 }
2286 }
2287
2288 impl Drop for AbortOnDrop {
2289 fn drop(&mut self) {
2290 if let Some(handle) = &self.handle {
2291 handle.abort();
2292 }
2293 }
2294 }
2295
2296 pub(crate) fn parse_local_fetch(value: &str) -> composefs_oci::LocalFetchOpt {
2300 use composefs_oci::LocalFetchOpt;
2301 match value {
2302 "auto" | "if-possible" => LocalFetchOpt::IfPossible,
2303 "zerocopy" | "zero-copy" => LocalFetchOpt::ZeroCopy,
2304 _ => LocalFetchOpt::Disabled,
2305 }
2306 }
2307
2308 #[allow(clippy::too_many_arguments)]
2324 pub(crate) fn pull_stream<ObjectID: FsVerityHashValue>(
2325 repo: Arc<Repository<ObjectID>>,
2326 image: String,
2327 name: Option<String>,
2328 local_fetch: composefs_oci::LocalFetchOpt,
2329 storage_root: Option<PathBuf>,
2330 bootable: bool,
2331 more: bool,
2332 ) -> std::pin::Pin<
2333 Box<
2334 dyn zlink::futures_util::Stream<
2335 Item = std::result::Result<zlink::Reply<PullProgress>, OciError>,
2336 >,
2337 >,
2338 > {
2339 use zlink::futures_util::stream;
2340
2341 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<PullProgress>();
2342 let reporter: Option<composefs::progress::SharedReporter> = if more {
2344 Some(std::sync::Arc::new(ChannelReporter { tx: tx.clone() }))
2345 } else {
2346 None
2347 };
2348
2349 let task_tx = tx.clone();
2354 let handle = tokio::task::spawn_local(async move {
2355 let opts = composefs_oci::PullOptions {
2356 local_fetch,
2357 storage_root: storage_root.as_deref(),
2358 progress: reporter,
2359 ..Default::default()
2360 };
2361 let result = composefs_oci::pull(&repo, &image, name.as_deref(), opts)
2362 .await
2363 .map_err(|e| OciError::InternalError {
2364 message: format!("{e:#}"),
2365 })?;
2366
2367 let boot_image = if bootable {
2368 let id = composefs_oci::generate_boot_image(&repo, &result.manifest_digest)
2369 .map_err(|e| OciError::InternalError {
2370 message: format!("{e:#}"),
2371 })?;
2372 Some(id.to_hex())
2373 } else {
2374 None
2375 };
2376
2377 let completed = PullProgress {
2378 completed: Some(Completed {
2379 manifest_digest: result.manifest_digest.to_string(),
2380 config_digest: result.config_digest.to_string(),
2381 manifest_verity: result.manifest_verity.to_hex(),
2382 config_verity: result.config_verity.to_hex(),
2383 stats: result.stats.to_string(),
2384 boot_image,
2385 }),
2386 ..PullProgress::empty()
2387 };
2388 let _ = task_tx.send(completed);
2390 Ok(())
2391 });
2392
2393 drop(tx);
2396
2397 struct State {
2398 rx: tokio::sync::mpsc::UnboundedReceiver<PullProgress>,
2399 handle: Option<AbortOnDrop>,
2400 done: bool,
2401 }
2402
2403 let state = State {
2404 rx,
2405 handle: Some(AbortOnDrop {
2406 handle: Some(handle),
2407 }),
2408 done: false,
2409 };
2410
2411 let stream = stream::unfold(state, |mut state| async move {
2412 if state.done {
2413 return None;
2414 }
2415 match state.rx.recv().await {
2416 Some(frame) => {
2417 let is_completed = frame.completed.is_some();
2418 if is_completed {
2419 state.done = true;
2420 if let Some(guard) = state.handle.as_mut() {
2423 let _ = guard.take();
2424 }
2425 }
2426 let reply = zlink::Reply::new(Some(frame)).set_continues(Some(!is_completed));
2427 Some((Ok(reply), state))
2428 }
2429 None => {
2430 state.done = true;
2433 let join = state.handle.as_mut().and_then(AbortOnDrop::take);
2436 let err = match join {
2437 Some(join) => match join.await {
2438 Ok(Ok(())) => OciError::InternalError {
2439 message: "pull completed without a result frame".to_string(),
2440 },
2441 Ok(Err(e)) => e,
2442 Err(_) => OciError::InternalError {
2443 message: "pull task panicked".to_string(),
2444 },
2445 },
2446 None => OciError::InternalError {
2447 message: "pull task panicked".to_string(),
2448 },
2449 };
2450 Some((Err(err), state))
2451 }
2452 }
2453 });
2454
2455 Box::pin(stream)
2456 }
2457
2458 #[derive(Debug, zlink::ReplyError, zlink::introspect::ReplyError)]
2460 #[zlink(interface = "org.composefs.Oci")]
2461 pub enum OciError {
2462 RepoNotFound {
2464 message: String,
2466 },
2467 InvalidHandle {
2469 handle: u64,
2471 },
2472 NoSuchImage {
2474 image: String,
2476 },
2477 InternalError {
2479 message: String,
2481 },
2482 NoSuchLayer {
2484 diff_id: String,
2486 },
2487 InvalidDigest {
2489 message: String,
2491 },
2492 DiffIdMismatch {
2496 expected: String,
2498 actual: String,
2500 },
2501 InvalidRequest {
2503 message: String,
2505 },
2506 FdLimitExceeded {
2510 fd_count: u64,
2512 max_per_frame: u64,
2514 },
2515 }
2516}
2517
2518#[cfg(feature = "oci")]
2525pub mod layer_sync {
2526 use super::*;
2527
2528 #[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
2530 pub struct GetInfoReply {
2531 pub features: Vec<String>,
2535 }
2536
2537 #[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
2539 pub struct HasLayerReply {
2540 pub present: bool,
2542 pub layer_verity: Option<String>,
2544 }
2545
2546 #[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
2577 pub struct GetLayerReply {
2578 pub dir_count: u32,
2581 }
2582
2583 #[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
2590 pub struct PutLayerReply {
2591 pub layer_verity: String,
2593 pub already_present: bool,
2598
2599 #[serde(default)]
2602 pub objects_reflinked: u64,
2603 #[serde(default)]
2605 pub objects_hardlinked: u64,
2606 #[serde(default)]
2608 pub objects_copied: u64,
2609 #[serde(default)]
2611 pub objects_already_present: u64,
2612 }
2613
2614 #[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
2620 pub struct LayerRef {
2621 pub diff_id: String,
2623 pub layer_verity: String,
2626 }
2627
2628 #[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
2631 pub struct FinalizeImageReply {
2632 pub manifest_digest: String,
2634 pub manifest_verity: String,
2636 pub config_digest: String,
2638 pub config_verity: String,
2640 }
2641}
2642
2643pub mod proxy {
2649 #![allow(missing_docs)]
2650
2651 #[cfg(feature = "oci")]
2652 use super::layer_sync::{
2653 FinalizeImageReply, GetInfoReply, GetLayerReply, HasLayerReply, LayerRef, PutLayerReply,
2654 };
2655 #[cfg(feature = "oci")]
2656 use super::oci::{
2657 ListImagesReply, OciComputeIdReply, OciError, OciFsckReply, OciInspectReply, PullProgress,
2658 };
2659 use super::{
2660 FsckReply, GcReply, ImageObjectsReply, InitRepositoryReply, OpenRepositoryReply,
2661 RepositoryError,
2662 };
2663 #[cfg(feature = "oci")]
2664 pub use composefs_oci::varlink_types::GetLayerParams;
2665 #[cfg(feature = "oci")]
2666 use zlink::futures_util::Stream;
2667
2668 #[zlink::proxy(interface = "org.composefs.Repository")]
2670 pub trait RepositoryProxy {
2671 async fn init_repository(
2673 &mut self,
2674 path: &str,
2675 algorithm: Option<&str>,
2676 insecure: Option<bool>,
2677 ) -> zlink::Result<Result<InitRepositoryReply, RepositoryError>>;
2678
2679 async fn open_repository(
2681 &mut self,
2682 path: Option<&str>,
2683 user: Option<bool>,
2684 system: Option<bool>,
2685 ) -> zlink::Result<Result<OpenRepositoryReply, RepositoryError>>;
2686
2687 async fn close_repository(
2689 &mut self,
2690 handle: u64,
2691 ) -> zlink::Result<Result<(), RepositoryError>>;
2692
2693 async fn fsck(
2695 &mut self,
2696 handle: u64,
2697 metadata_only: Option<bool>,
2698 ) -> zlink::Result<Result<FsckReply, RepositoryError>>;
2699
2700 async fn gc(
2702 &mut self,
2703 handle: u64,
2704 dry_run: bool,
2705 roots: Vec<String>,
2706 ) -> zlink::Result<Result<GcReply, RepositoryError>>;
2707
2708 async fn image_objects(
2710 &mut self,
2711 handle: u64,
2712 name: &str,
2713 ) -> zlink::Result<Result<ImageObjectsReply, RepositoryError>>;
2714 }
2715
2716 #[cfg(feature = "oci")]
2718 #[zlink::proxy(interface = "org.composefs.Oci")]
2719 pub trait OciProxy {
2720 async fn list_images(
2722 &mut self,
2723 handle: u64,
2724 filter: Option<&str>,
2725 ) -> zlink::Result<Result<ListImagesReply, OciError>>;
2726
2727 #[zlink(rename = "Check")]
2729 async fn oci_fsck(
2730 &mut self,
2731 handle: u64,
2732 image: Option<&str>,
2733 ) -> zlink::Result<Result<OciFsckReply, OciError>>;
2734
2735 async fn inspect(
2737 &mut self,
2738 handle: u64,
2739 image: &str,
2740 ) -> zlink::Result<Result<OciInspectReply, OciError>>;
2741
2742 async fn tag(
2744 &mut self,
2745 handle: u64,
2746 manifest_digest: &str,
2747 name: &str,
2748 ) -> zlink::Result<Result<(), OciError>>;
2749
2750 async fn untag(&mut self, handle: u64, name: &str) -> zlink::Result<Result<(), OciError>>;
2752
2753 async fn compute_id(
2755 &mut self,
2756 handle: u64,
2757 image: &str,
2758 verity: Option<&str>,
2759 bootable: bool,
2760 ) -> zlink::Result<Result<OciComputeIdReply, OciError>>;
2761
2762 #[zlink(more, rename = "Pull")]
2764 async fn pull(
2765 &mut self,
2766 handle: u64,
2767 image: &str,
2768 name: Option<&str>,
2769 local_fetch: &str,
2770 storage_root: Option<&str>,
2771 bootable: bool,
2772 ) -> zlink::Result<impl Stream<Item = zlink::Result<Result<PullProgress, OciError>>>>;
2773
2774 async fn get_info(&mut self) -> zlink::Result<Result<GetInfoReply, OciError>>;
2776
2777 async fn has_layer(
2779 &mut self,
2780 handle: u64,
2781 diff_id: &str,
2782 ) -> zlink::Result<Result<HasLayerReply, OciError>>;
2783
2784 #[zlink(more, return_fds)]
2791 async fn get_layer(
2792 &mut self,
2793 handle: u64,
2794 params: GetLayerParams,
2795 ) -> zlink::Result<
2796 impl zlink::futures_util::Stream<
2797 Item = zlink::Result<(Result<GetLayerReply, OciError>, Vec<std::os::fd::OwnedFd>)>,
2798 >,
2799 >;
2800
2801 async fn put_layer(
2806 &mut self,
2807 handle: u64,
2808 diff_id: &str,
2809 zerocopy: bool,
2810 #[zlink(fds)] fds: Vec<std::os::fd::OwnedFd>,
2811 ) -> zlink::Result<Result<PutLayerReply, OciError>>;
2812
2813 async fn finalize_image(
2819 &mut self,
2820 handle: u64,
2821 manifest_json: &str,
2822 config_json: &str,
2823 layers: Vec<LayerRef>,
2824 name: Option<&str>,
2825 ) -> zlink::Result<Result<FinalizeImageReply, OciError>>;
2826 }
2827}
2828
2829#[cfg(feature = "oci")]
2830pub(crate) use oci::*;
2831
2832#[cfg(feature = "oci")]
2843pub(crate) fn spawn_in_process(
2844 service: CfsctlService,
2845) -> std::io::Result<(zlink::unix::Connection, std::thread::JoinHandle<()>)> {
2846 let (client_std, server_std) = std::os::unix::net::UnixStream::pair()?;
2847 client_std.set_nonblocking(true)?;
2848 server_std.set_nonblocking(true)?;
2849
2850 let client_stream = tokio::net::UnixStream::from_std(client_std)?;
2851 let client_zlink =
2852 zlink::unix::Stream::try_from(client_stream).map_err(std::io::Error::other)?;
2853 let client_conn = zlink::Connection::new(client_zlink);
2854
2855 let handle = std::thread::Builder::new()
2856 .name("cfsctl-service-server".into())
2857 .spawn(move || {
2858 let rt = match tokio::runtime::Builder::new_current_thread()
2859 .enable_all()
2860 .build()
2861 {
2862 Ok(rt) => rt,
2863 Err(e) => {
2864 log::error!("CfsctlService server runtime build failed: {e:#?}");
2865 return;
2866 }
2867 };
2868 let local = tokio::task::LocalSet::new();
2869 local.block_on(&rt, async move {
2870 let server_stream = match tokio::net::UnixStream::from_std(server_std) {
2871 Ok(s) => s,
2872 Err(e) => {
2873 log::error!("CfsctlService server stream conversion failed: {e:#?}");
2874 return;
2875 }
2876 };
2877 let server_zlink = match zlink::unix::Stream::try_from(server_stream) {
2878 Ok(s) => s,
2879 Err(e) => {
2880 log::error!("CfsctlService server zlink stream conversion failed: {e:#?}");
2881 return;
2882 }
2883 };
2884 let listener = zlink::ReadyListener::new(server_zlink);
2885 let server = zlink::Server::new(listener, service);
2886 if let Err(e) = server.run().await {
2887 log::warn!("CfsctlService in-process server error: {e:#?}");
2888 }
2889 });
2890 })?;
2891
2892 Ok((client_conn, handle))
2893}
2894
2895#[cfg(all(test, feature = "oci"))]
2896mod layer_sync_tests {
2897 use std::io::Read as _;
2904 use std::os::fd::AsFd as _;
2905 use std::sync::Arc;
2906
2907 use composefs::fsverity::{FsVerityHashValue as _, Sha256HashValue};
2908 use composefs::repository::{Repository, RepositoryConfig};
2909 use composefs_splitdirfdstream::reconstruct;
2910
2911 use super::layer_sync::GetLayerReply;
2912 use super::oci::OciError;
2913 use super::proxy::{OciProxy, RepositoryProxy as _};
2914 use super::{CfsctlService, spawn_in_process};
2915 use composefs_oci::varlink_types::GetLayerParams;
2916
2917 async fn collect_get_layer<C>(
2925 client: &mut C,
2926 handle: u64,
2927 diff_id: &str,
2928 ) -> Result<(GetLayerReply, Vec<std::os::fd::OwnedFd>), OciError>
2929 where
2930 C: OciProxy,
2931 {
2932 use zlink::futures_util::StreamExt as _;
2933
2934 let params = GetLayerParams {
2935 diff_id: Some(diff_id.to_owned()),
2936 storage: None,
2937 };
2938 let mut stream = std::pin::pin!(
2939 client
2940 .get_layer(handle, params)
2941 .await
2942 .expect("get_layer transport error")
2943 );
2944
2945 let mut all_fds: Vec<std::os::fd::OwnedFd> = Vec::new();
2946 let mut last_reply: Option<GetLayerReply> = None;
2947
2948 while let Some(item) = stream.next().await {
2949 let (result, fds) = item.expect("get_layer stream error");
2950 match result {
2951 Ok(reply) => {
2952 last_reply = Some(reply);
2953 }
2954 Err(e) => return Err(e),
2955 }
2956 all_fds.extend(fds);
2957 }
2958
2959 Ok((last_reply.expect("get_layer stream was empty"), all_fds))
2960 }
2961
2962 async fn collect_get_layer_split<C>(
2968 client: &mut C,
2969 handle: u64,
2970 diff_id: &str,
2971 ) -> (
2972 GetLayerReply,
2973 Vec<std::os::fd::OwnedFd>,
2974 Vec<std::os::fd::OwnedFd>,
2975 )
2976 where
2977 C: OciProxy,
2978 {
2979 let (reply, mut all_fds) = collect_get_layer(client, handle, diff_id)
2980 .await
2981 .expect("get_layer failed");
2982 let dir_count = reply.dir_count as usize;
2983 let pipe_and_dirfds_len = 1 + dir_count;
2985 assert!(
2986 all_fds.len() >= pipe_and_dirfds_len,
2987 "expected at least {pipe_and_dirfds_len} fds, got {}",
2988 all_fds.len()
2989 );
2990 let lifetime_fds = all_fds.split_off(pipe_and_dirfds_len);
2991 (reply, all_fds, lifetime_fds)
2992 }
2993
2994 fn build_tar_layer(file_size: usize) -> Vec<u8> {
2997 let content: Vec<u8> = (0..file_size).map(|i| (i % 251) as u8).collect();
2998 let mut builder = ::tar::Builder::new(vec![]);
2999 let mut header = ::tar::Header::new_ustar();
3000 header.set_uid(0);
3001 header.set_gid(0);
3002 header.set_mode(0o644);
3003 header.set_entry_type(::tar::EntryType::Regular);
3004 header.set_size(file_size as u64);
3005 builder
3006 .append_data(&mut header, format!("file_{file_size}"), &content[..])
3007 .unwrap();
3008 builder.into_inner().unwrap()
3009 }
3010
3011 fn create_test_repo() -> (Arc<Repository<Sha256HashValue>>, tempfile::TempDir) {
3013 let tempdir = tempfile::TempDir::new().unwrap();
3014 let (repo, _) = Repository::init_path(
3015 rustix::fs::CWD,
3016 tempdir.path().join("repo"),
3017 RepositoryConfig::default().set_insecure(),
3018 )
3019 .unwrap();
3020 (Arc::new(repo), tempdir)
3021 }
3022
3023 #[tokio::test(flavor = "multi_thread")]
3024 async fn test_layer_sync_in_process() {
3025 let (repo, _tempdir) = create_test_repo();
3027
3028 let tar_bytes = build_tar_layer(128 * 1024); let diff_id = composefs_oci::sha256_content_digest(&tar_bytes);
3031 let (verity, _stats) =
3032 composefs_oci::import_layer(&repo, &diff_id, None, tar_bytes.as_slice())
3033 .await
3034 .expect("import_layer");
3035
3036 let mut expected = Vec::<u8>::new();
3038 {
3039 let mut reader = repo
3040 .open_stream("", Some(&verity), Some(composefs_oci::LAYER_CONTENT_TYPE))
3041 .expect("open_stream for cat");
3042 reader.cat(&repo, &mut expected).expect("cat");
3043 }
3044
3045 let repo_path = _tempdir.path().join("repo").to_str().unwrap().to_string();
3046
3047 let service = CfsctlService::insecure_for_test();
3049 let (mut client, _server_handle) = spawn_in_process(service).unwrap();
3050
3051 let open_reply = client
3053 .open_repository(Some(&repo_path), None, None)
3054 .await
3055 .unwrap()
3056 .expect("open_repository");
3057 let handle = open_reply.handle;
3058
3059 assert_eq!(
3061 open_reply.hash_algorithm.as_deref(),
3062 Some("sha256"),
3063 "hash_algorithm must be sha256 for a Sha256HashValue repo"
3064 );
3065 assert!(
3066 open_reply.objects_device_id.is_some(),
3067 "objects_device_id must be reported"
3068 );
3069
3070 let info = client.get_info().await.unwrap().expect("get_info");
3072 assert!(
3073 info.features.contains(&"splitdirfdstream-v0".to_string()),
3074 "expected splitdirfdstream-v0 in features"
3075 );
3076
3077 let has = client
3079 .has_layer(handle, diff_id.as_ref())
3080 .await
3081 .unwrap()
3082 .expect("has_layer");
3083 assert!(has.present, "layer must be present");
3084 assert_eq!(
3085 has.layer_verity.as_deref(),
3086 Some(verity.to_hex().as_str()),
3087 "verity mismatch"
3088 );
3089
3090 let fake_digest = "sha256:0000000000000000000000000000000000000000000000000000000000000000";
3092 let has_absent = client
3093 .has_layer(handle, fake_digest)
3094 .await
3095 .unwrap()
3096 .expect("has_layer absent");
3097 assert!(!has_absent.present, "absent layer must not be present");
3098 assert!(has_absent.layer_verity.is_none());
3099
3100 let (get_reply, pipe_and_dirfds, lifetime_fds) =
3104 collect_get_layer_split(&mut client, handle, diff_id.as_ref()).await;
3105 let dir_count = get_reply.dir_count as usize;
3106
3107 let _lifetime_fds = lifetime_fds;
3109
3110 let pipe_fd = pipe_and_dirfds[0].as_fd();
3112 let dir_fds: Vec<_> = pipe_and_dirfds[1..=dir_count]
3113 .iter()
3114 .map(|f| f.as_fd())
3115 .collect();
3116
3117 let pipe_owned = rustix::io::dup(pipe_fd).expect("dup pipe read");
3119 let mut pipe_file = std::fs::File::from(pipe_owned);
3120 let mut stream_bytes = Vec::new();
3121 pipe_file.read_to_end(&mut stream_bytes).unwrap();
3122 assert!(!stream_bytes.is_empty(), "stream must be non-empty");
3123
3124 let mut actual = Vec::new();
3126 reconstruct(stream_bytes.as_slice(), &dir_fds, &mut actual)
3127 .expect("reconstruct splitdirfdstream");
3128
3129 similar_asserts::assert_eq!(
3130 actual,
3131 expected,
3132 "reconstructed layer must equal cat() output"
3133 );
3134
3135 let err = collect_get_layer(&mut client, handle, fake_digest).await;
3137 match err {
3138 Err(super::oci::OciError::NoSuchLayer { .. }) => {}
3139 other => panic!("expected NoSuchLayer, got {other:?}"),
3140 }
3141 }
3142
3143 #[tokio::test(flavor = "multi_thread")]
3152 async fn test_put_layer_relay() {
3153 let (repo_a, _td_a) = create_test_repo();
3155 let tar_bytes = build_tar_layer(128 * 1024); let diff_id = composefs_oci::sha256_content_digest(&tar_bytes);
3157 let (verity_a, _) =
3158 composefs_oci::import_layer(&repo_a, &diff_id, None, tar_bytes.as_slice())
3159 .await
3160 .expect("import_layer into repo_a");
3161
3162 let mut expected = Vec::<u8>::new();
3164 {
3165 let mut reader = repo_a
3166 .open_stream("", Some(&verity_a), Some(composefs_oci::LAYER_CONTENT_TYPE))
3167 .expect("open_stream for cat");
3168 reader.cat(&repo_a, &mut expected).expect("cat");
3169 }
3170 let repo_a_path = _td_a.path().join("repo").to_str().unwrap().to_string();
3171
3172 let (repo_b, _td_b) = create_test_repo();
3174 let repo_b_path = _td_b.path().join("repo").to_str().unwrap().to_string();
3175
3176 let service_a = CfsctlService::insecure_for_test();
3178 let (mut client_a, _srv_a) = spawn_in_process(service_a).unwrap();
3179
3180 let service_b = CfsctlService::insecure_for_test();
3181 let (mut client_b, _srv_b) = spawn_in_process(service_b).unwrap();
3182
3183 let handle_a = client_a
3185 .open_repository(Some(&repo_a_path), None, None)
3186 .await
3187 .unwrap()
3188 .expect("open_repository A")
3189 .handle;
3190 let handle_b = client_b
3191 .open_repository(Some(&repo_b_path), None, None)
3192 .await
3193 .unwrap()
3194 .expect("open_repository B")
3195 .handle;
3196
3197 let (get_reply, pipe_and_dirfds, lifetime_fds) =
3200 collect_get_layer_split(&mut client_a, handle_a, diff_id.as_ref()).await;
3201 let dir_count = get_reply.dir_count as usize;
3202
3203 let put_fds = pipe_and_dirfds; let put_reply = client_b
3208 .put_layer(handle_b, diff_id.as_ref(), false, put_fds)
3209 .await
3210 .unwrap()
3211 .expect("put_layer");
3212 drop(lifetime_fds);
3214
3215 assert!(
3216 !put_reply.already_present,
3217 "first put_layer must report already_present = false"
3218 );
3219 assert!(
3220 dir_count > 0,
3221 "dir_count must be > 0 (dirfds region has at least one slot)"
3222 );
3223
3224 let total_stored =
3228 put_reply.objects_reflinked + put_reply.objects_hardlinked + put_reply.objects_copied;
3229 assert!(
3230 total_stored + put_reply.objects_already_present > 0,
3231 "put_layer must report at least one object stored, got {put_reply:?}"
3232 );
3233
3234 let content_id = composefs_oci::layer_content_id(&diff_id);
3236 assert!(
3237 repo_b
3238 .has_stream(&content_id)
3239 .expect("has_stream B")
3240 .is_some(),
3241 "repo B must have the layer after put_layer"
3242 );
3243
3244 let verity_b: Sha256HashValue =
3246 Sha256HashValue::from_hex(&put_reply.layer_verity).expect("parse layer_verity hex");
3247 let mut actual = Vec::<u8>::new();
3248 {
3249 let mut reader = repo_b
3250 .open_stream("", Some(&verity_b), Some(composefs_oci::LAYER_CONTENT_TYPE))
3251 .expect("open_stream B for cat");
3252 reader.cat(&repo_b, &mut actual).expect("cat B");
3253 }
3254 similar_asserts::assert_eq!(actual, expected, "repo B cat must equal repo A cat");
3255
3256 let (get_reply2, pipe_and_dirfds2, lifetime_fds2) =
3258 collect_get_layer_split(&mut client_a, handle_a, diff_id.as_ref()).await;
3259 let _ = get_reply2;
3260
3261 let put_reply2 = client_b
3262 .put_layer(handle_b, diff_id.as_ref(), false, pipe_and_dirfds2)
3263 .await
3264 .unwrap()
3265 .expect("put_layer 2nd");
3266 drop(lifetime_fds2);
3267
3268 assert!(
3269 put_reply2.already_present,
3270 "second put_layer must report already_present = true"
3271 );
3272 }
3273
3274 #[tokio::test(flavor = "multi_thread")]
3277 async fn test_put_layer_wrong_diff_id() {
3278 let (repo_a, _td_a) = create_test_repo();
3279 let tar_bytes = build_tar_layer(128 * 1024);
3280 let correct_diff_id = composefs_oci::sha256_content_digest(&tar_bytes);
3281 let (_verity_a, _) =
3282 composefs_oci::import_layer(&repo_a, &correct_diff_id, None, tar_bytes.as_slice())
3283 .await
3284 .expect("import_layer");
3285 let repo_a_path = _td_a.path().join("repo").to_str().unwrap().to_string();
3286
3287 let (_repo_b, _td_b) = create_test_repo();
3288 let repo_b_path = _td_b.path().join("repo").to_str().unwrap().to_string();
3289
3290 let service_a = CfsctlService::insecure_for_test();
3291 let (mut client_a, _srv_a) = spawn_in_process(service_a).unwrap();
3292 let service_b = CfsctlService::insecure_for_test();
3293 let (mut client_b, _srv_b) = spawn_in_process(service_b).unwrap();
3294
3295 let handle_a = client_a
3296 .open_repository(Some(&repo_a_path), None, None)
3297 .await
3298 .unwrap()
3299 .expect("open_repository A")
3300 .handle;
3301 let handle_b = client_b
3302 .open_repository(Some(&repo_b_path), None, None)
3303 .await
3304 .unwrap()
3305 .expect("open_repository B")
3306 .handle;
3307
3308 let (_get_reply, pipe_and_dirfds, lifetime_fds) =
3310 collect_get_layer_split(&mut client_a, handle_a, correct_diff_id.as_ref()).await;
3311
3312 let wrong_diff_id =
3314 "sha256:0000000000000000000000000000000000000000000000000000000000000000";
3315 let put_err = client_b
3316 .put_layer(handle_b, wrong_diff_id, false, pipe_and_dirfds)
3317 .await
3318 .unwrap();
3319 drop(lifetime_fds);
3320
3321 match put_err {
3322 Err(super::oci::OciError::DiffIdMismatch { expected, actual }) => {
3323 assert_eq!(expected, wrong_diff_id);
3324 assert_eq!(actual, correct_diff_id.to_string());
3325 }
3326 other => panic!("expected DiffIdMismatch, got {other:?}"),
3327 }
3328
3329 let wrong_content_id = composefs_oci::layer_content_id(
3331 &wrong_diff_id.parse::<composefs_oci::OciDigest>().unwrap(),
3332 );
3333 assert!(
3334 _repo_b
3335 .has_stream(&wrong_content_id)
3336 .expect("has_stream B")
3337 .is_none(),
3338 "repo B must NOT have a stream for the wrong diff_id"
3339 );
3340 }
3341
3342 fn build_oci_tar_layer(payload_size: usize) -> Vec<u8> {
3351 let mut builder = ::tar::Builder::new(vec![]);
3352
3353 for (path, is_dir) in &[("./", true), ("./usr/", true), ("./usr/share/", true)] {
3354 let mut hdr = ::tar::Header::new_ustar();
3355 hdr.set_entry_type(::tar::EntryType::Directory);
3356 hdr.set_uid(0);
3357 hdr.set_gid(0);
3358 hdr.set_mode(0o755);
3359 hdr.set_size(0);
3360 let _ = is_dir; builder
3362 .append_data(&mut hdr, path, std::io::empty())
3363 .unwrap();
3364 }
3365
3366 let content: Vec<u8> = (0..payload_size).map(|i| (i % 251) as u8).collect();
3367 let mut file_hdr = ::tar::Header::new_ustar();
3368 file_hdr.set_entry_type(::tar::EntryType::Regular);
3369 file_hdr.set_uid(0);
3370 file_hdr.set_gid(0);
3371 file_hdr.set_mode(0o644);
3372 file_hdr.set_size(payload_size as u64);
3373 builder
3374 .append_data(
3375 &mut file_hdr,
3376 format!("./usr/share/data_{payload_size}"),
3377 content.as_slice(),
3378 )
3379 .unwrap();
3380
3381 builder.into_inner().unwrap()
3382 }
3383
3384 fn make_config_json(diff_ids: &[String]) -> String {
3389 let ids: Vec<String> = diff_ids.iter().map(|d| format!("\"{d}\"")).collect();
3390 format!(
3391 r#"{{"architecture":"amd64","os":"linux","rootfs":{{"type":"layers","diff_ids":[{}]}},"config":{{}}}}"#,
3392 ids.join(",")
3393 )
3394 }
3395
3396 fn make_manifest_json(
3398 config_json: &str,
3399 config_digest_str: &str,
3400 diff_ids: &[String],
3401 ) -> String {
3402 let layer_entries: Vec<String> = diff_ids
3403 .iter()
3404 .map(|d| {
3405 format!(
3406 r#"{{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"{d}","size":1}}"#
3407 )
3408 })
3409 .collect();
3410 format!(
3411 r#"{{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","config":{{"mediaType":"application/vnd.oci.image.config.v1+json","digest":"{config_digest_str}","size":{}}},"layers":[{}]}}"#,
3412 config_json.len(),
3413 layer_entries.join(",")
3414 )
3415 }
3416
3417 #[tokio::test(flavor = "multi_thread")]
3425 async fn test_finalize_image_roundtrip() {
3426 use composefs_oci::OciDigest;
3427
3428 let (repo_b, _td_b) = create_test_repo();
3429 let repo_b_path = _td_b.path().join("repo").to_str().unwrap().to_string();
3430
3431 let tar_bytes = build_oci_tar_layer(128 * 1024);
3433 let diff_id = composefs_oci::sha256_content_digest(&tar_bytes);
3434 let (layer_verity, _) =
3435 composefs_oci::import_layer(&repo_b, &diff_id, None, tar_bytes.as_slice())
3436 .await
3437 .expect("import_layer into repo_b");
3438
3439 let diff_ids = vec![diff_id.to_string()];
3441 let config_json = make_config_json(&diff_ids);
3442 let config_digest = composefs_oci::sha256_content_digest(config_json.as_bytes());
3443 let manifest_json = make_manifest_json(&config_json, config_digest.as_ref(), &diff_ids);
3444
3445 let service_b = CfsctlService::insecure_for_test();
3447 let (mut client_b, _srv_b) = spawn_in_process(service_b).unwrap();
3448
3449 let handle_b = client_b
3450 .open_repository(Some(&repo_b_path), None, None)
3451 .await
3452 .unwrap()
3453 .expect("open_repository B")
3454 .handle;
3455
3456 let layers = vec![super::layer_sync::LayerRef {
3458 diff_id: diff_id.to_string(),
3459 layer_verity: layer_verity.to_hex(),
3460 }];
3461
3462 let reply = client_b
3464 .finalize_image(
3465 handle_b,
3466 &manifest_json,
3467 &config_json,
3468 layers,
3469 Some("finalize-test:v1"),
3470 )
3471 .await
3472 .unwrap()
3473 .expect("finalize_image");
3474
3475 assert!(
3477 !reply.manifest_digest.is_empty(),
3478 "manifest_digest must be non-empty"
3479 );
3480 assert!(
3481 !reply.manifest_verity.is_empty(),
3482 "manifest_verity must be non-empty"
3483 );
3484 assert!(
3485 !reply.config_digest.is_empty(),
3486 "config_digest must be non-empty"
3487 );
3488 assert!(
3489 !reply.config_verity.is_empty(),
3490 "config_verity must be non-empty"
3491 );
3492
3493 let manifest_digest: OciDigest = reply.manifest_digest.parse().unwrap();
3495 let config_digest2: OciDigest = reply.config_digest.parse().unwrap();
3496
3497 let manifest_id = composefs_oci::oci_image::manifest_identifier(&manifest_digest);
3498 assert!(
3499 repo_b
3500 .has_stream(&manifest_id)
3501 .expect("has_stream manifest")
3502 .is_some(),
3503 "manifest splitstream must exist in repo_b"
3504 );
3505
3506 let config_id2 = format!("oci-config-{config_digest2}");
3508 assert!(
3509 repo_b
3510 .has_stream(&config_id2)
3511 .expect("has_stream config")
3512 .is_some(),
3513 "config splitstream must exist in repo_b"
3514 );
3515
3516 let manifest_verity =
3518 Sha256HashValue::from_hex(&reply.manifest_verity).expect("parse manifest_verity");
3519 let erofs = composefs_oci::composefs_erofs_for_manifest(
3520 &repo_b,
3521 &manifest_digest,
3522 Some(&manifest_verity),
3523 repo_b.erofs_version(),
3524 )
3525 .expect("composefs_erofs_for_manifest");
3526 assert!(
3527 erofs.is_some(),
3528 "EROFS image must exist after finalize_image"
3529 );
3530 }
3531}