Skip to main content

composefs_ctl/
varlink.rs

1//! Varlink RPC service for `cfsctl`.
2//!
3//! Exposes a subset of repository operations over a Unix-socket varlink
4//! interface (`org.composefs.Repository`) so that integration tests and
5//! external callers can consume structured replies instead of scraping the
6//! human-oriented CLI output.
7//!
8//! Repositories are accessed through opaque `u64` handles: a client calls
9//! `OpenRepository` to obtain a handle, passes it to every subsequent method,
10//! and frees it with `CloseRepository`. No repository is opened at startup, so
11//! every call must carry a handle. Each handle stores an already-opened
12//! `Repository<ObjectID>` monomorphized over the digest algorithm detected at
13//! open time, wrapped in an `Arc` so the streaming `Pull` method can move an
14//! owned clone into its `'static` reply stream.
15//!
16//! The zlink server serializes `Service::handle` calls (a single task holds
17//! one `&mut self` borrow at a time), so the handle table is a plain
18//! `HashMap` with no interior locking.
19
20use 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/// Result of a repository consistency check, mirrored for the varlink wire
33/// format.
34///
35/// This is a flattened, snake_case projection of
36/// [`composefs::repository::FsckResult`]; field names follow the varlink
37/// convention rather than the camelCase used by the JSON CLI output.
38#[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
39pub struct FsckReply {
40    /// Whether the repository passed the integrity check with no errors.
41    pub ok: bool,
42    /// Whether the repository has a `meta.json` metadata file.
43    pub has_metadata: bool,
44    /// Number of objects whose fs-verity digests were verified.
45    pub objects_checked: u64,
46    /// Number of objects found to have a bad fs-verity digest.
47    pub objects_corrupted: u64,
48    /// Number of splitstreams verified.
49    pub streams_checked: u64,
50    /// Number of splitstreams with issues (bad header, missing refs, etc.).
51    pub streams_corrupted: u64,
52    /// Number of images verified.
53    pub images_checked: u64,
54    /// Number of images with issues.
55    pub images_corrupted: u64,
56    /// Number of broken symlinks found.
57    pub broken_links: u64,
58    /// Number of missing objects referenced by streams.
59    pub missing_objects: u64,
60    /// Human-readable descriptions of each error found.
61    ///
62    /// These are the `Display` rendering of the library's structured
63    /// `FsckError` variants; they carry stable `fsck: <kind>:` prefixes.
64    // TODO: expose the structured `FsckError` variants over the wire once a
65    // varlink-friendly representation (e.g. a tagged struct) is settled on,
66    // so clients can match on error kind instead of parsing strings.
67    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/// Result of a garbage-collection run for the varlink wire format.
89///
90/// Wraps the canonical [`composefs::repository::GcResult`] and adds the
91/// `dry_run` flag (which the library type does not carry).
92#[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
93pub struct GcReply {
94    /// What was (or would be) removed.
95    pub result: composefs::repository::GcResult,
96    /// Whether this was a dry run (no files actually deleted).
97    pub dry_run: bool,
98}
99
100/// Reply listing the objects referenced by a single image.
101#[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
102pub struct ImageObjectsReply {
103    /// The fs-verity object IDs referenced by the image, sorted for
104    /// deterministic output.
105    pub object_ids: Vec<String>,
106}
107
108/// Errors that may be returned by the `org.composefs.Repository` interface.
109#[derive(Debug, zlink::ReplyError, zlink::introspect::ReplyError)]
110#[zlink(interface = "org.composefs.Repository")]
111pub enum RepositoryError {
112    /// The repository could not be found or opened at the configured path.
113    RepoNotFound {
114        /// Description of the failure.
115        message: String,
116    },
117    /// The given handle does not refer to an open repository.
118    InvalidHandle {
119        /// The handle that was not found.
120        handle: u64,
121    },
122    /// The request did not specify a valid repository selector.
123    InvalidSpec {
124        /// Description of the problem with the selector.
125        message: String,
126    },
127    /// The named image/ref does not exist in the repository.
128    NoSuchRef {
129        /// The ref name that was not found.
130        reference: String,
131    },
132    /// An unexpected internal error occurred while servicing the request.
133    InternalError {
134        /// Description of the failure.
135        message: String,
136    },
137}
138
139/// Reply carrying an opaque repository handle and basic repository metadata.
140///
141/// The `hash_algorithm` and `objects_device_id` fields let a client making a
142/// cross-repository copy decide whether zero-copy (reflink / hardlink)
143/// transfer is viable for a given source–destination pair:
144///
145/// * **`hash_algorithm`** — `"sha256"` or `"sha512"`. Hardlink (zero-copy)
146///   requires both repositories to use the same algorithm, because fs-verity
147///   is enabled on the *shared* inode. Reflink and regular copy work across
148///   algorithms (each produces a fresh inode re-digested under the
149///   destination's algorithm).
150///
151/// * **`objects_device_id`** — the `st_dev` of the repository's objects
152///   directory. Both reflink (`FICLONE`) and hardlink (`linkat`) require
153///   source and destination to reside on the same filesystem; comparing
154///   `objects_device_id` from both sides lets the client detect this up front.
155///   Note: `st_dev` is only meaningful when both servers share a mount
156///   namespace (the typical same-host deployment). If they do not, the
157///   worst case is a failed `PutLayer` (EXDEV), not silent data corruption.
158#[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
159pub struct OpenRepositoryReply {
160    /// The opaque handle to pass to subsequent repository methods.
161    pub handle: u64,
162
163    /// The fs-verity hash algorithm used by this repository (`"sha256"` or
164    /// `"sha512"`).
165    ///
166    /// `None` on old servers that do not report this field (serde default).
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub hash_algorithm: Option<String>,
169
170    /// The `st_dev` of the repository's objects directory, as a decimal u64.
171    ///
172    /// Clients comparing two repositories should treat matching values as
173    /// "likely same filesystem" (and thus eligible for reflink/hardlink).
174    /// `None` on old servers.
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub objects_device_id: Option<u64>,
177}
178
179/// Reply from initializing a repository.
180#[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
181pub struct InitRepositoryReply {
182    /// `true` if a new repository was created; `false` if one already existed
183    /// at the requested path with the same algorithm (idempotent).
184    pub created: bool,
185}
186
187/// Reply from ensuring a repository exists.
188#[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
189pub struct EnsureRepositoryReply {
190    /// What happened when ensuring the repository.
191    pub status: composefs::repository::EnsureStatus,
192}
193
194/// An opened repository, monomorphized over its detected hash algorithm.
195///
196/// Stored as an [`Arc`] so streaming methods can clone an owned handle into a
197/// `'static` reply stream without borrowing the service.
198#[derive(Debug, Clone)]
199pub(crate) enum OpenRepo {
200    /// A repository using the SHA-256 digest algorithm.
201    Sha256(Arc<Repository<Sha256HashValue>>),
202    /// A repository using the SHA-512 digest algorithm.
203    Sha512(Arc<Repository<Sha512HashValue>>),
204}
205
206impl OpenRepo {
207    /// The fs-verity hash algorithm name for this repository.
208    fn hash_algorithm(&self) -> &'static str {
209        match self {
210            OpenRepo::Sha256(_) => "sha256",
211            OpenRepo::Sha512(_) => "sha512",
212        }
213    }
214
215    /// The `st_dev` of the repository's objects directory, if available.
216    fn objects_device_id(&self) -> Option<u64> {
217        let stat_it = |fd: &std::os::fd::OwnedFd| -> Option<u64> {
218            rustix::fs::fstat(fd).ok().map(|s| s.st_dev)
219        };
220        match self {
221            OpenRepo::Sha256(r) => r.objects_dir().ok().and_then(stat_it),
222            OpenRepo::Sha512(r) => r.objects_dir().ok().and_then(stat_it),
223        }
224    }
225}
226
227/// A single entry in the service's open-repository table.
228#[derive(Debug)]
229struct HandleEntry {
230    /// The opened repository.
231    repo: OpenRepo,
232    /// Owning connection id, recorded for a future per-connection disconnect
233    /// hook that will reclaim handles left open by a vanished client. `Option`
234    /// to leave room for handles not tied to a specific connection.
235    #[allow(dead_code)]
236    owner: Option<usize>,
237}
238
239/// Process-wide repository open options, fixed at startup.
240#[derive(Debug, Clone)]
241struct OpenOptions {
242    /// Open the repository in insecure (no verity required) mode.
243    insecure: bool,
244    /// Require fs-verity to be enabled on the repository.
245    require_verity: bool,
246    /// Skip auto-upgrading old-format repositories.
247    no_upgrade: bool,
248}
249
250impl OpenOptions {
251    /// Derive the open options from parsed CLI arguments.
252    fn from_app(args: &App) -> Self {
253        Self {
254            insecure: args.insecure,
255            require_verity: args.require_verity,
256            no_upgrade: args.no_upgrade,
257        }
258    }
259}
260
261impl Default for OpenOptions {
262    /// The uid-default open options used by the socket-activated entry point,
263    /// which serves before CLI parsing and so has no `App` to consult.
264    fn default() -> Self {
265        Self {
266            insecure: false,
267            require_verity: false,
268            no_upgrade: false,
269        }
270    }
271}
272
273/// Varlink service implementation backing the `org.composefs.Repository` (and,
274/// with the `oci` feature, `org.composefs.Oci`) interfaces.
275///
276/// Holds a table of opened repositories keyed by opaque handle. The zlink
277/// server serializes calls to a single service, so the table is a plain
278/// `HashMap` with no interior locking.
279#[derive(Debug)]
280pub(crate) struct CfsctlService {
281    /// Open repositories keyed by opaque handle.
282    repos: HashMap<u64, HandleEntry>,
283    /// Monotonically increasing handle counter; `0` is reserved as "none".
284    next_handle: u64,
285    /// Repository open options fixed at startup.
286    open_opts: OpenOptions,
287}
288
289impl Default for CfsctlService {
290    fn default() -> Self {
291        Self::new()
292    }
293}
294
295impl CfsctlService {
296    /// Construct an empty service with the given repository open options.
297    ///
298    /// No repository is opened at startup: a client must explicitly select one
299    /// with `OpenRepository` and pass the returned handle to every subsequent
300    /// call.
301    fn with_open_opts(open_opts: OpenOptions) -> Self {
302        Self {
303            repos: HashMap::new(),
304            next_handle: 0,
305            open_opts,
306        }
307    }
308
309    /// Construct a service from parsed CLI arguments.
310    ///
311    /// The open flags (`--insecure`/`--require-verity`/`--no-upgrade`) carry
312    /// into repositories opened later via `OpenRepository`; the repository
313    /// selection flags (`--repo`/`--user`/`--system`) do not apply, since the
314    /// varlink service opens repositories on demand rather than at startup.
315    pub(crate) fn from_app(args: &App) -> Self {
316        Self::with_open_opts(OpenOptions::from_app(args))
317    }
318
319    /// Construct a service for the socket-activated entry point, which serves
320    /// before CLI parsing and so has no `App` to consult. Uses default open
321    /// options; the client supplies repository paths via `OpenRepository`.
322    pub(crate) fn activated() -> Self {
323        Self::with_open_opts(OpenOptions::default())
324    }
325
326    /// Construct a service with default open options.
327    pub(crate) fn new() -> Self {
328        Self::with_open_opts(OpenOptions::default())
329    }
330
331    /// Construct an insecure service for in-process tests.
332    ///
333    /// The `insecure` flag disables fs-verity requirements so that tests can
334    /// use repositories created on tmpfs or without verity support.
335    #[cfg(test)]
336    pub(crate) fn insecure_for_test() -> Self {
337        Self::with_open_opts(OpenOptions {
338            insecure: true,
339            require_verity: false,
340            no_upgrade: false,
341        })
342    }
343
344    /// Allocate a fresh, never-reused handle. Starts at `1` (`0` is "none").
345    fn next_handle(&mut self) -> u64 {
346        self.next_handle += 1;
347        self.next_handle
348    }
349
350    /// Look up an open repository by handle for the Repository interface.
351    ///
352    /// Returns an owned [`OpenRepo`] (a cheap `Arc` clone) so callers do not
353    /// hold a borrow of `self` across the subsequent `.await`.
354    fn lookup_repo(&self, handle: u64) -> std::result::Result<OpenRepo, RepositoryError> {
355        self.repos
356            .get(&handle)
357            .map(|entry| entry.repo.clone())
358            .ok_or(RepositoryError::InvalidHandle { handle })
359    }
360
361    /// Look up an open repository by handle for the OCI interface.
362    ///
363    /// Like [`Self::lookup_repo`] but reports the OCI-interface error so the
364    /// wire error name is `org.composefs.Oci.InvalidHandle`.
365    #[cfg(feature = "oci")]
366    fn lookup_oci(&self, handle: u64) -> std::result::Result<OpenRepo, oci::OciError> {
367        self.repos
368            .get(&handle)
369            .map(|entry| entry.repo.clone())
370            .ok_or(oci::OciError::InvalidHandle { handle })
371    }
372
373    /// Resolve, open and register a repository at `path`, returning the reply
374    /// with the handle and repository metadata.
375    ///
376    /// The digest algorithm is detected from the repository metadata; both
377    /// resolution and open failures are reported as
378    /// [`RepositoryError::RepoNotFound`].
379    fn do_open(
380        &mut self,
381        path: &Path,
382        owner: Option<usize>,
383    ) -> std::result::Result<OpenRepositoryReply, RepositoryError> {
384        let hash_type = resolve_hash_type(path, None, !self.open_opts.no_upgrade).map_err(|e| {
385            RepositoryError::RepoNotFound {
386                message: format!("{e:#}"),
387            }
388        })?;
389        let repo = match hash_type {
390            HashType::Sha256 => OpenRepo::Sha256(Arc::new(
391                open_repo_at::<Sha256HashValue>(
392                    path,
393                    self.open_opts.insecure,
394                    self.open_opts.require_verity,
395                    self.open_opts.no_upgrade,
396                )
397                .map_err(|e| RepositoryError::RepoNotFound {
398                    message: format!("{e:#}"),
399                })?,
400            )),
401            HashType::Sha512 => OpenRepo::Sha512(Arc::new(
402                open_repo_at::<Sha512HashValue>(
403                    path,
404                    self.open_opts.insecure,
405                    self.open_opts.require_verity,
406                    self.open_opts.no_upgrade,
407                )
408                .map_err(|e| RepositoryError::RepoNotFound {
409                    message: format!("{e:#}"),
410                })?,
411            )),
412        };
413        let handle = self.next_handle();
414        let hash_algorithm = Some(repo.hash_algorithm().to_string());
415        let objects_device_id = repo.objects_device_id();
416        self.repos.insert(handle, HandleEntry { repo, owner });
417        Ok(OpenRepositoryReply {
418            handle,
419            hash_algorithm,
420            objects_device_id,
421        })
422    }
423
424    /// Resolve a repository selector (`path`/`user`/`system`) to a path.
425    ///
426    /// Exactly one of the three must be set; otherwise
427    /// [`RepositoryError::InvalidSpec`] is returned.
428    fn resolve_selector(
429        path: Option<String>,
430        user: Option<bool>,
431        system: Option<bool>,
432    ) -> std::result::Result<PathBuf, RepositoryError> {
433        let user = user.unwrap_or(false);
434        let system = system.unwrap_or(false);
435        match (path, user, system) {
436            (Some(p), false, false) => Ok(PathBuf::from(p)),
437            (None, true, false) => user_path().map_err(|e| RepositoryError::InvalidSpec {
438                message: format!("{e:#}"),
439            }),
440            (None, false, true) => Ok(system_path()),
441            _ => Err(RepositoryError::InvalidSpec {
442                message: "exactly one of `path`, `user`, `system` must be set".into(),
443            }),
444        }
445    }
446}
447
448/// Open the repository and run an fsck.
449async fn run_fsck<ObjectID: FsVerityHashValue>(
450    repo: &Repository<ObjectID>,
451    metadata_only: bool,
452) -> std::result::Result<FsckResult, RepositoryError> {
453    let result = if metadata_only {
454        repo.fsck_metadata_only().await
455    } else {
456        repo.fsck().await
457    };
458    result.map_err(|e| RepositoryError::InternalError {
459        message: format!("{e:#}"),
460    })
461}
462
463/// Run garbage collection (or a dry run) on a repository.
464async fn run_gc<ObjectID: FsVerityHashValue>(
465    repo: &Repository<ObjectID>,
466    dry_run: bool,
467    roots: Vec<String>,
468) -> std::result::Result<GcReply, RepositoryError> {
469    let root_refs: Vec<&str> = roots.iter().map(String::as_str).collect();
470    let result = if dry_run {
471        repo.gc_dry_run(&root_refs)
472    } else {
473        repo.gc(&root_refs)
474    }
475    .map_err(|e| RepositoryError::InternalError {
476        message: format!("{e:#}"),
477    })?;
478    Ok(GcReply { result, dry_run })
479}
480
481/// Collect the objects referenced by an image.
482async fn run_image_objects<ObjectID: FsVerityHashValue>(
483    repo: &Repository<ObjectID>,
484    name: String,
485) -> std::result::Result<ImageObjectsReply, RepositoryError> {
486    let objects = repo.objects_for_image(&name).map_err(|e| {
487        if let Some(nf) = e.downcast_ref::<composefs::ImageNotFound>() {
488            RepositoryError::NoSuchRef {
489                reference: nf.name.clone(),
490            }
491        } else {
492            RepositoryError::InternalError {
493                message: format!("{e:#}"),
494            }
495        }
496    })?;
497    let mut object_ids: Vec<String> = objects.iter().map(|id| id.to_id()).collect();
498    object_ids.sort();
499    Ok(ImageObjectsReply { object_ids })
500}
501
502/// A single image reference entry.
503#[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
504pub struct ImageRefEntry {
505    /// The reference name.
506    pub name: String,
507    /// The fs-verity digest the reference points to.
508    pub digest: String,
509}
510
511/// Reply listing all named image references in the repository.
512#[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
513pub struct ListImageRefsReply {
514    /// The image references.
515    pub images: Vec<ImageRefEntry>,
516}
517
518/// Collect all named image references from the repository.
519pub fn run_list_image_refs<ObjectID: FsVerityHashValue>(
520    repo: &Repository<ObjectID>,
521) -> std::result::Result<ListImageRefsReply, RepositoryError> {
522    let refs = repo
523        .list_image_refs("")
524        .map_err(|e| RepositoryError::InternalError {
525            message: format!("{e:#}"),
526        })?;
527    let images = refs
528        .into_iter()
529        .map(|(name, target)| {
530            let digest = target.rsplit('/').next().unwrap_or(&target).to_string();
531            ImageRefEntry { name, digest }
532        })
533        .collect();
534    Ok(ListImageRefsReply { images })
535}
536
537/// Options for a `Mount` call. All fields are optional for forward
538/// compatibility — new mount options can be added without breaking the
539/// wire format.
540#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, zlink::introspect::Type)]
541pub struct MountParams {
542    /// Whether to set up an overlayfs upper layer.
543    /// When true, the fd array must contain two fds: upperdir and workdir.
544    pub overlay: Option<bool>,
545    /// Whether to mount read-write (only meaningful with overlay).
546    pub read_write: Option<bool>,
547}
548
549impl MountParams {
550    /// Build [`MountOptions`] from these params, consuming the expected fds.
551    fn to_mount_options(
552        &self,
553        fds: Vec<std::os::fd::OwnedFd>,
554    ) -> std::result::Result<composefs::mount::MountOptions, RepositoryError> {
555        let overlay = self.overlay.unwrap_or(false);
556
557        let mut expected_fds = 0;
558        if overlay {
559            expected_fds += 2;
560        }
561
562        if fds.len() != expected_fds {
563            return Err(RepositoryError::InvalidSpec {
564                message: format!(
565                    "Mount expects {expected_fds} fds for the requested options, got {}",
566                    fds.len()
567                ),
568            });
569        }
570
571        let mut options = composefs::mount::MountOptions::default();
572        let mut fd_iter = fds.into_iter();
573        if overlay {
574            let upperdir = fd_iter.next().unwrap();
575            let workdir = fd_iter.next().unwrap();
576            options.set_overlay(upperdir, workdir);
577        }
578        options.set_read_write(self.read_write.unwrap_or(false));
579
580        Ok(options)
581    }
582}
583
584/// Reply for a `Mount` call — just an fd_index referencing the mount fd.
585#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, zlink::introspect::Type)]
586pub struct MountReply {
587    /// Index into the fd vector of the detached mount file descriptor.
588    pub fd_index: u32,
589}
590
591fn run_mount<ObjectID: FsVerityHashValue>(
592    repo: &Repository<ObjectID>,
593    name: &str,
594    params: &MountParams,
595    fds: Vec<std::os::fd::OwnedFd>,
596) -> std::result::Result<(MountReply, Vec<std::os::fd::OwnedFd>), RepositoryError> {
597    let options = params.to_mount_options(fds)?;
598
599    let mount_fd =
600        repo.mount_with_options(name, &options)
601            .map_err(|e| RepositoryError::InternalError {
602                message: format!("{e:#}"),
603            })?;
604
605    Ok((MountReply { fd_index: 0 }, vec![mount_fd]))
606}
607
608#[cfg(feature = "oci")]
609fn run_oci_mount<ObjectID: composefs::fsverity::FsVerityHashValue>(
610    repo: &Repository<ObjectID>,
611    image: &str,
612    bootable: bool,
613    params: &MountParams,
614    fds: Vec<std::os::fd::OwnedFd>,
615) -> std::result::Result<(MountReply, Vec<std::os::fd::OwnedFd>), oci::OciError> {
616    let img = if image.starts_with("sha256:") {
617        let digest: composefs_oci::OciDigest =
618            image.parse().map_err(|e| oci::OciError::InternalError {
619                message: format!("Invalid manifest digest: {e}"),
620            })?;
621        composefs_oci::OciImage::open(repo, &digest, None)
622    } else {
623        composefs_oci::OciImage::open_ref(repo, image)
624    }
625    .map_err(|e| oci::OciError::NoSuchImage {
626        image: format!("{image}: {e:#}"),
627    })?;
628
629    let erofs_id = if bootable {
630        img.boot_image_ref(repo.erofs_version())
631    } else {
632        img.image_ref(repo.erofs_version())
633    }
634    .ok_or_else(|| oci::OciError::InternalError {
635        message: if bootable {
636            "No boot EROFS image linked".into()
637        } else {
638            "No composefs EROFS image linked".into()
639        },
640    })?;
641
642    let options = params
643        .to_mount_options(fds)
644        .map_err(|e| oci::OciError::InternalError {
645            message: format!("{e:?}"),
646        })?;
647    let mount_fd = repo
648        .mount_with_options(&erofs_id.to_hex(), &options)
649        .map_err(|e| oci::OciError::InternalError {
650            message: format!("{e:#}"),
651        })?;
652
653    Ok((MountReply { fd_index: 0 }, vec![mount_fd]))
654}
655
656/// Parse a wire-format algorithm string (e.g. `"fsverity-sha512-12"`),
657/// falling back to the service default ([`Algorithm::SHA512`]) when omitted.
658fn parse_algorithm(algorithm: Option<&str>) -> std::result::Result<Algorithm, RepositoryError> {
659    match algorithm {
660        Some(s) => s.parse().map_err(|e| RepositoryError::InvalidSpec {
661            message: format!("invalid algorithm: {e}"),
662        }),
663        None => Ok(Algorithm::SHA512),
664    }
665}
666
667/// Initialize (or verify) a repository at `path` with the given algorithm.
668///
669/// Creates parent directories if needed, then delegates to
670/// [`Repository::init_path`]. Returns `true` when a new repository was
671/// created and `false` when an identical one already existed (idempotent).
672/// A conflicting existing repository (different algorithm) is an error.
673fn run_init_repository(
674    path: &Path,
675    algorithm: Algorithm,
676    insecure: bool,
677) -> std::result::Result<InitRepositoryReply, RepositoryError> {
678    // Ensure parent directories exist (init_path only creates the final dir).
679    if let Some(parent) = path.parent() {
680        std::fs::create_dir_all(parent).map_err(|e| RepositoryError::InternalError {
681            message: format!("creating parent directories for {}: {e:#}", path.display()),
682        })?;
683    }
684
685    let created = match algorithm {
686        Algorithm::Sha256 { .. } => {
687            let config = if insecure {
688                RepositoryConfig::new(algorithm).set_insecure()
689            } else {
690                RepositoryConfig::new(algorithm)
691            };
692            Repository::<Sha256HashValue>::init_path(CWD, path, config)
693                .map_err(|e| RepositoryError::InternalError {
694                    message: format!("{e:#}"),
695                })?
696                .1
697        }
698        Algorithm::Sha512 { .. } => {
699            let config = if insecure {
700                RepositoryConfig::new(algorithm).set_insecure()
701            } else {
702                RepositoryConfig::new(algorithm)
703            };
704            Repository::<Sha512HashValue>::init_path(CWD, path, config)
705                .map_err(|e| RepositoryError::InternalError {
706                    message: format!("{e:#}"),
707                })?
708                .1
709        }
710    };
711    Ok(InitRepositoryReply { created })
712}
713
714/// Ensure a repository exists at `path`: create parent directories, then
715/// delegate to [`Repository::ensure_path`]. Unlike [`run_init_repository`],
716/// this never bails when a repository already exists with a different
717/// on-disk configuration (e.g. EROFS format version) — the existing
718/// repository is simply opened as-is. `erofs_formats`, if given, is only
719/// applied when a brand-new repository is created; it has no effect when an
720/// existing repository is opened or an old-format one is upgraded.
721pub(crate) fn run_ensure_repository(
722    path: &Path,
723    algorithm: Algorithm,
724    insecure: bool,
725    erofs_formats: Option<composefs::erofs::format::FormatConfig>,
726) -> anyhow::Result<composefs::repository::EnsureStatus> {
727    if let Some(parent) = path.parent() {
728        std::fs::create_dir_all(parent)
729            .with_context(|| format!("creating parent directories for {}", path.display()))?;
730    }
731
732    let mut config = RepositoryConfig::new(algorithm);
733    if insecure {
734        config = config.set_insecure();
735    }
736    if let Some(formats) = erofs_formats {
737        config.erofs_formats = formats;
738    }
739
740    let status = match algorithm {
741        Algorithm::Sha256 { .. } => {
742            Repository::<Sha256HashValue>::ensure_path(CWD, path, config)?.1
743        }
744        Algorithm::Sha512 { .. } => {
745            Repository::<Sha512HashValue>::ensure_path(CWD, path, config)?.1
746        }
747    };
748    Ok(status)
749}
750
751/// OCI helper functions backing the `org.composefs.Oci` interface, gated behind
752/// the `oci` feature.
753#[cfg(feature = "oci")]
754async fn run_list_images<ObjectID: FsVerityHashValue>(
755    repo: &Repository<ObjectID>,
756    filter: Option<String>,
757) -> std::result::Result<Vec<oci::ImageEntry>, oci::OciError> {
758    composefs_oci::oci_image::list_images(repo)
759        .map(|imgs| {
760            imgs.iter()
761                .filter(|img| match &filter {
762                    Some(needle) => img.name.contains(needle.as_str()),
763                    None => true,
764                })
765                .map(oci::ImageEntry::from)
766                .collect()
767        })
768        .map_err(|e| oci::OciError::InternalError {
769            message: format!("{e:#}"),
770        })
771}
772
773/// Run an OCI-aware consistency check on a repository.
774///
775/// When `image` is `Some`, only that tagged image is checked; otherwise all
776/// tagged images are checked.
777#[cfg(feature = "oci")]
778async fn run_oci_fsck<ObjectID: FsVerityHashValue>(
779    repo: &Repository<ObjectID>,
780    image: Option<String>,
781) -> std::result::Result<oci::OciFsckReply, oci::OciError> {
782    let result = match image {
783        Some(name) => composefs_oci::oci_fsck_image(repo, &name).await,
784        None => composefs_oci::oci_fsck(repo).await,
785    }
786    .map_err(|e| oci::OciError::InternalError {
787        message: format!("{e:#}"),
788    })?;
789    Ok(oci::OciFsckReply::from(&result))
790}
791
792/// Inspect a single OCI image.
793#[cfg(feature = "oci")]
794async fn run_inspect<ObjectID: FsVerityHashValue>(
795    repo: &Repository<ObjectID>,
796    image: String,
797) -> std::result::Result<oci::OciInspectReply, oci::OciError> {
798    let reference: crate::OciReference =
799        image.parse().map_err(|e| oci::OciError::InternalError {
800            message: format!("invalid image reference: {e:#}"),
801        })?;
802    let img = crate::resolve_oci_image(repo, &reference).map_err(|e| {
803        if let Some(nf) = e.downcast_ref::<composefs_oci::OciRefNotFound>() {
804            oci::OciError::NoSuchImage {
805                image: nf.name.clone(),
806            }
807        } else if let Some(nf) = e.downcast_ref::<composefs_oci::OciImageNotFound>() {
808            oci::OciError::NoSuchImage {
809                image: nf.digest.clone(),
810            }
811        } else {
812            oci::OciError::InternalError {
813                message: format!("{e:#}"),
814            }
815        }
816    })?;
817
818    oci::OciInspectReply::from_image(repo, &img).map_err(|e| oci::OciError::InternalError {
819        message: format!("{e:#}"),
820    })
821}
822
823/// Tag a manifest digest with a name.
824#[cfg(feature = "oci")]
825async fn run_tag<ObjectID: FsVerityHashValue>(
826    repo: &Repository<ObjectID>,
827    manifest_digest: String,
828    name: String,
829) -> std::result::Result<(), oci::OciError> {
830    let digest: composefs_oci::OciDigest =
831        manifest_digest
832            .parse()
833            .map_err(|e| oci::OciError::InternalError {
834                message: format!("invalid digest: {e}"),
835            })?;
836    composefs_oci::oci_image::tag_image(repo, &digest, &name).map_err(|e| {
837        oci::OciError::InternalError {
838            message: format!("{e:#}"),
839        }
840    })
841}
842
843/// Remove a tag.
844#[cfg(feature = "oci")]
845async fn run_untag<ObjectID: FsVerityHashValue>(
846    repo: &Repository<ObjectID>,
847    name: String,
848) -> std::result::Result<(), oci::OciError> {
849    composefs_oci::oci_image::untag_image(repo, &name).map_err(|e| oci::OciError::InternalError {
850        message: format!("{e:#}"),
851    })
852}
853
854/// Compute the composefs image ID for an OCI image.
855///
856/// Mirrors the CLI `compute-id` path: digest references (`@sha256:…`) use the
857/// supplied `verity` override, while named refs derive both the config digest
858/// and verity from the stored image metadata (ignoring `verity`).
859#[cfg(feature = "oci")]
860async fn run_compute_id<ObjectID: FsVerityHashValue>(
861    repo: &Repository<ObjectID>,
862    image: String,
863    verity: Option<String>,
864    bootable: bool,
865    xattrs: Option<composefs_oci::XattrFiltering>,
866) -> std::result::Result<oci::OciComputeIdReply, oci::OciError> {
867    let reference: crate::OciReference =
868        image.parse().map_err(|e| oci::OciError::InternalError {
869            message: format!("invalid image reference: {e:#}"),
870        })?;
871    let verity_override =
872        crate::verity_opt::<ObjectID>(&verity).map_err(|e| oci::OciError::InternalError {
873            message: format!("invalid verity: {e:#}"),
874        })?;
875    let (config_digest, config_verity) =
876        crate::resolve_oci_config(repo, &reference, verity_override).map_err(|e| {
877            oci::OciError::InternalError {
878                message: format!("{e:#}"),
879            }
880        })?;
881
882    let transform_opts = composefs_oci::OciTransformOptions {
883        xattrs: xattrs.unwrap_or_default(),
884    };
885    let mut fs = composefs_oci::image::create_filesystem(
886        repo,
887        &config_digest,
888        config_verity.as_ref(),
889        &transform_opts,
890    )
891    .map_err(|e| oci::OciError::InternalError {
892        message: format!("{e:#}"),
893    })?;
894    if bootable {
895        use composefs_boot::BootOps as _;
896        fs.transform_for_boot(repo)
897            .map_err(|e| oci::OciError::InternalError {
898                message: format!("{e:#}"),
899            })?;
900    }
901    let id = fs.compute_image_id(repo.erofs_version());
902    Ok(oci::OciComputeIdReply {
903        image_id: id.to_hex(),
904    })
905}
906
907// The `zlink::service` macro emits several `pub` helper enums (method dispatch,
908// reply params, etc.) as siblings of the impl block. Those cannot be annotated
909// individually, so the macro invocation lives in a dedicated private submodule
910// where `missing_docs` is relaxed. The generated `Service` trait impl applies
911// to `CfsctlService` regardless of the module it is written in.
912//
913// There are two variants of this module selected at compile time. The macro
914// cannot cfg-gate individual methods (it doesn't propagate `#[cfg]`), and the
915// dispatch enum derives its variants from wire method names (so both
916// interfaces must live in ONE impl block). So when the `oci` feature is on we
917// emit a single impl that hosts BOTH `org.composefs.Repository` and
918// `org.composefs.Oci`; otherwise we emit a Repository-only impl.
919//
920// The interface attribute on each method is "sticky": once a method sets
921// `interface = "org.composefs.Oci"` the macro keeps using it for subsequent
922// methods until changed. The Repository methods come first and inherit the
923// seeded `org.composefs.Repository` interface.
924#[cfg(not(feature = "oci"))]
925mod service_impl {
926    #![allow(missing_docs)]
927
928    use super::{
929        CfsctlService, EnsureRepositoryReply, FsckReply, GcReply, ImageObjectsReply,
930        InitRepositoryReply, ListImageRefsReply, MountParams, MountReply, OpenRepo,
931        OpenRepositoryReply, RepositoryError, parse_algorithm, run_ensure_repository, run_fsck,
932        run_gc, run_image_objects, run_init_repository, run_list_image_refs, run_mount,
933    };
934    use composefs::fsverity::{Sha256HashValue, Sha512HashValue};
935
936    #[zlink::service(
937        interface = "org.composefs.Repository",
938        vendor = "org.composefs",
939        product = "cfsctl",
940        version = env!("CARGO_PKG_VERSION"),
941        url = "https://github.com/composefs/composefs-rs"
942    )]
943    impl<Sock> CfsctlService {
944        /// Initialize a new repository at the given path, or verify that an
945        /// existing one matches the requested algorithm (idempotent).
946        ///
947        /// Creates the directory (and any parents) if they do not exist.
948        /// `algorithm` must be a valid fs-verity algorithm string such as
949        /// `"fsverity-sha512-12"` (the default) or `"fsverity-sha256-12"`.
950        /// When omitted the service default (`fsverity-sha512-12`) is used.
951        /// The `insecure` flag mirrors `cfsctl init --insecure`: when `true`,
952        /// fs-verity is not required on `meta.json`.
953        async fn init_repository(
954            &mut self,
955            path: String,
956            algorithm: Option<String>,
957            insecure: Option<bool>,
958        ) -> std::result::Result<InitRepositoryReply, RepositoryError> {
959            let algorithm = parse_algorithm(algorithm.as_deref())?;
960            let insecure = insecure.unwrap_or(self.open_opts.insecure);
961            run_init_repository(std::path::Path::new(&path), algorithm, insecure)
962        }
963
964        /// Ensure a repository exists at the given path: open it as-is if it
965        /// already exists (preserving its on-disk configuration even if it
966        /// differs from the crate's current defaults), initialize a fresh one
967        /// if none exists, or upgrade an old-format (pre-`meta.json`) repository
968        /// in place.
969        ///
970        /// Unlike [`init_repository`](Self::init_repository), this never fails
971        /// due to a configuration mismatch with an existing repository — it is
972        /// the appropriate method for idempotent "make sure this repo exists"
973        /// callers (e.g. a service ensuring its repository is available at
974        /// startup).
975        async fn ensure_repository(
976            &mut self,
977            path: String,
978            algorithm: Option<String>,
979            insecure: Option<bool>,
980        ) -> std::result::Result<EnsureRepositoryReply, RepositoryError> {
981            let algorithm = parse_algorithm(algorithm.as_deref())?;
982            let insecure = insecure.unwrap_or(self.open_opts.insecure);
983            let status =
984                run_ensure_repository(std::path::Path::new(&path), algorithm, insecure, None)
985                    .map_err(|e| RepositoryError::InternalError {
986                        message: format!("{e:#}"),
987                    })?;
988            Ok(EnsureRepositoryReply { status })
989        }
990
991        /// Open and validate a repository, returning an opaque handle.
992        ///
993        /// Exactly one of `path`, `user`, `system` must be set.
994        async fn open_repository(
995            &mut self,
996            path: Option<String>,
997            user: Option<bool>,
998            system: Option<bool>,
999            #[zlink(connection)] conn: &mut zlink::Connection<Sock>,
1000        ) -> std::result::Result<OpenRepositoryReply, RepositoryError> {
1001            let selected = Self::resolve_selector(path, user, system)?;
1002            self.do_open(&selected, Some(conn.id()))
1003        }
1004
1005        /// Close a previously opened repository handle.
1006        async fn close_repository(
1007            &mut self,
1008            handle: u64,
1009        ) -> std::result::Result<(), RepositoryError> {
1010            self.repos
1011                .remove(&handle)
1012                .map(|_| ())
1013                .ok_or(RepositoryError::InvalidHandle { handle })
1014        }
1015
1016        /// Check repository integrity and return the structured result.
1017        ///
1018        /// When `metadata_only` is true, the expensive per-object fs-verity
1019        /// verification is skipped; only metadata and symlink structure are
1020        /// checked.
1021        async fn fsck(
1022            &self,
1023            handle: u64,
1024            metadata_only: Option<bool>,
1025        ) -> std::result::Result<FsckReply, RepositoryError> {
1026            let metadata_only = metadata_only.unwrap_or(false);
1027            let result = match self.lookup_repo(handle)? {
1028                OpenRepo::Sha256(ref r) => run_fsck::<Sha256HashValue>(r, metadata_only).await,
1029                OpenRepo::Sha512(ref r) => run_fsck::<Sha512HashValue>(r, metadata_only).await,
1030            }?;
1031            Ok(FsckReply::from(&result))
1032        }
1033
1034        /// Run garbage collection (or a dry run) and return what was removed.
1035        async fn gc(
1036            &self,
1037            handle: u64,
1038            dry_run: bool,
1039            roots: Vec<String>,
1040        ) -> std::result::Result<GcReply, RepositoryError> {
1041            match self.lookup_repo(handle)? {
1042                OpenRepo::Sha256(ref r) => run_gc::<Sha256HashValue>(r, dry_run, roots).await,
1043                OpenRepo::Sha512(ref r) => run_gc::<Sha512HashValue>(r, dry_run, roots).await,
1044            }
1045        }
1046
1047        /// List the objects referenced by a single image.
1048        async fn image_objects(
1049            &self,
1050            handle: u64,
1051            name: String,
1052        ) -> std::result::Result<ImageObjectsReply, RepositoryError> {
1053            match self.lookup_repo(handle)? {
1054                OpenRepo::Sha256(ref r) => run_image_objects::<Sha256HashValue>(r, name).await,
1055                OpenRepo::Sha512(ref r) => run_image_objects::<Sha512HashValue>(r, name).await,
1056            }
1057        }
1058
1059        /// List all named image references in the repository.
1060        async fn list_image_refs(
1061            &self,
1062            handle: u64,
1063        ) -> std::result::Result<ListImageRefsReply, RepositoryError> {
1064            match self.lookup_repo(handle)? {
1065                OpenRepo::Sha256(ref r) => run_list_image_refs::<Sha256HashValue>(r),
1066                OpenRepo::Sha512(ref r) => run_list_image_refs::<Sha512HashValue>(r),
1067            }
1068        }
1069
1070        /// Create a detached mount of an image and return the mount fd.
1071        ///
1072        /// If overlay upper/work directories are needed, pass them as two fds
1073        /// (upperdir, workdir) via SCM_RIGHTS. The returned fd is a detached
1074        /// mount that the caller can attach with `move_mount()`.
1075        #[zlink(return_fds)]
1076        async fn mount(
1077            &self,
1078            handle: u64,
1079            name: String,
1080            options: MountParams,
1081            #[zlink(fds)] fds: Vec<std::os::fd::OwnedFd>,
1082        ) -> (
1083            std::result::Result<MountReply, RepositoryError>,
1084            Vec<std::os::fd::OwnedFd>,
1085        ) {
1086            let result = match self.lookup_repo(handle) {
1087                Ok(OpenRepo::Sha256(ref r)) => {
1088                    run_mount::<Sha256HashValue>(r, &name, &options, fds)
1089                }
1090                Ok(OpenRepo::Sha512(ref r)) => {
1091                    run_mount::<Sha512HashValue>(r, &name, &options, fds)
1092                }
1093                Err(e) => Err(e),
1094            };
1095            match result {
1096                Ok((reply, fds)) => (Ok(reply), fds),
1097                Err(e) => (Err(e), vec![]),
1098            }
1099        }
1100    }
1101}
1102
1103// Combined variant: hosts BOTH the `org.composefs.Repository` and
1104// `org.composefs.Oci` interfaces from a single impl block on `CfsctlService`,
1105// so one service answers both interfaces on one socket. See the comment above
1106// for why this can't be cfg-gated method-by-method.
1107#[cfg(feature = "oci")]
1108mod service_impl {
1109    #![allow(missing_docs)]
1110
1111    use super::layer_sync::{
1112        FinalizeImageReply, GetInfoReply, GetLayerReply, HasLayerReply, LayerRef, PutLayerReply,
1113    };
1114    use super::oci::{
1115        ListImagesReply, OciComputeIdReply, OciError, OciFsckReply, OciInspectReply, PullProgress,
1116        parse_local_fetch, pull_stream,
1117    };
1118    use super::{
1119        CfsctlService, EnsureRepositoryReply, FsckReply, GcReply, ImageObjectsReply,
1120        InitRepositoryReply, ListImageRefsReply, MountParams, MountReply, OpenRepo,
1121        OpenRepositoryReply, RepositoryError, parse_algorithm, run_compute_id,
1122        run_ensure_repository, run_fsck, run_gc, run_image_objects, run_init_repository,
1123        run_inspect, run_list_image_refs, run_list_images, run_mount, run_oci_fsck, run_oci_mount,
1124        run_tag, run_untag,
1125    };
1126    use composefs::fsverity::{FsVerityHashValue, Sha256HashValue, Sha512HashValue};
1127    use composefs_oci::layer_transport::{RepoLayerSource, serve_get_layer};
1128    use composefs_oci::varlink_types::GetLayerParams;
1129    use composefs_splitdirfdstream::seed_from_id;
1130
1131    #[zlink::service(
1132        interface = "org.composefs.Repository",
1133        vendor = "org.composefs",
1134        product = "cfsctl",
1135        version = env!("CARGO_PKG_VERSION"),
1136        url = "https://github.com/composefs/composefs-rs"
1137    )]
1138    impl<Sock> CfsctlService {
1139        // --- org.composefs.Repository (inherits the seeded interface) ---
1140
1141        /// Initialize a new repository at the given path, or verify that an
1142        /// existing one matches the requested algorithm (idempotent).
1143        ///
1144        /// Creates the directory (and any parents) if they do not exist.
1145        /// `algorithm` must be a valid fs-verity algorithm string such as
1146        /// `"fsverity-sha512-12"` (the default) or `"fsverity-sha256-12"`.
1147        /// When omitted the service default (`fsverity-sha512-12`) is used.
1148        /// The `insecure` flag mirrors `cfsctl init --insecure`: when `true`,
1149        /// fs-verity is not required on `meta.json`.
1150        async fn init_repository(
1151            &mut self,
1152            path: String,
1153            algorithm: Option<String>,
1154            insecure: Option<bool>,
1155        ) -> std::result::Result<InitRepositoryReply, RepositoryError> {
1156            let algorithm = parse_algorithm(algorithm.as_deref())?;
1157            let insecure = insecure.unwrap_or(self.open_opts.insecure);
1158            run_init_repository(std::path::Path::new(&path), algorithm, insecure)
1159        }
1160
1161        /// Ensure a repository exists at the given path: open it as-is if it
1162        /// already exists (preserving its on-disk configuration even if it
1163        /// differs from the crate's current defaults), initialize a fresh one
1164        /// if none exists, or upgrade an old-format (pre-`meta.json`) repository
1165        /// in place.
1166        ///
1167        /// Unlike [`init_repository`](Self::init_repository), this never fails
1168        /// due to a configuration mismatch with an existing repository — it is
1169        /// the appropriate method for idempotent "make sure this repo exists"
1170        /// callers (e.g. a service ensuring its repository is available at
1171        /// startup).
1172        async fn ensure_repository(
1173            &mut self,
1174            path: String,
1175            algorithm: Option<String>,
1176            insecure: Option<bool>,
1177        ) -> std::result::Result<EnsureRepositoryReply, RepositoryError> {
1178            let algorithm = parse_algorithm(algorithm.as_deref())?;
1179            let insecure = insecure.unwrap_or(self.open_opts.insecure);
1180            let status =
1181                run_ensure_repository(std::path::Path::new(&path), algorithm, insecure, None)
1182                    .map_err(|e| RepositoryError::InternalError {
1183                        message: format!("{e:#}"),
1184                    })?;
1185            Ok(EnsureRepositoryReply { status })
1186        }
1187
1188        /// Open and validate a repository, returning an opaque handle.
1189        ///
1190        /// Exactly one of `path`, `user`, `system` must be set.
1191        async fn open_repository(
1192            &mut self,
1193            path: Option<String>,
1194            user: Option<bool>,
1195            system: Option<bool>,
1196            #[zlink(connection)] conn: &mut zlink::Connection<Sock>,
1197        ) -> std::result::Result<OpenRepositoryReply, RepositoryError> {
1198            let selected = Self::resolve_selector(path, user, system)?;
1199            self.do_open(&selected, Some(conn.id()))
1200        }
1201
1202        /// Close a previously opened repository handle.
1203        async fn close_repository(
1204            &mut self,
1205            handle: u64,
1206        ) -> std::result::Result<(), RepositoryError> {
1207            self.repos
1208                .remove(&handle)
1209                .map(|_| ())
1210                .ok_or(RepositoryError::InvalidHandle { handle })
1211        }
1212
1213        /// Check repository integrity and return the structured result.
1214        ///
1215        /// When `metadata_only` is true, the expensive per-object fs-verity
1216        /// verification is skipped; only metadata and symlink structure are
1217        /// checked.
1218        async fn fsck(
1219            &self,
1220            handle: u64,
1221            metadata_only: Option<bool>,
1222        ) -> std::result::Result<FsckReply, RepositoryError> {
1223            let metadata_only = metadata_only.unwrap_or(false);
1224            let result = match self.lookup_repo(handle)? {
1225                OpenRepo::Sha256(ref r) => run_fsck::<Sha256HashValue>(r, metadata_only).await,
1226                OpenRepo::Sha512(ref r) => run_fsck::<Sha512HashValue>(r, metadata_only).await,
1227            }?;
1228            Ok(FsckReply::from(&result))
1229        }
1230
1231        /// Run garbage collection (or a dry run) and return what was removed.
1232        async fn gc(
1233            &self,
1234            handle: u64,
1235            dry_run: bool,
1236            roots: Vec<String>,
1237        ) -> std::result::Result<GcReply, RepositoryError> {
1238            match self.lookup_repo(handle)? {
1239                OpenRepo::Sha256(ref r) => run_gc::<Sha256HashValue>(r, dry_run, roots).await,
1240                OpenRepo::Sha512(ref r) => run_gc::<Sha512HashValue>(r, dry_run, roots).await,
1241            }
1242        }
1243
1244        /// List the objects referenced by a single image.
1245        async fn image_objects(
1246            &self,
1247            handle: u64,
1248            name: String,
1249        ) -> std::result::Result<ImageObjectsReply, RepositoryError> {
1250            match self.lookup_repo(handle)? {
1251                OpenRepo::Sha256(ref r) => run_image_objects::<Sha256HashValue>(r, name).await,
1252                OpenRepo::Sha512(ref r) => run_image_objects::<Sha512HashValue>(r, name).await,
1253            }
1254        }
1255
1256        /// List all named image references in the repository.
1257        async fn list_image_refs(
1258            &self,
1259            handle: u64,
1260        ) -> std::result::Result<ListImageRefsReply, RepositoryError> {
1261            match self.lookup_repo(handle)? {
1262                OpenRepo::Sha256(ref r) => run_list_image_refs::<Sha256HashValue>(r),
1263                OpenRepo::Sha512(ref r) => run_list_image_refs::<Sha512HashValue>(r),
1264            }
1265        }
1266
1267        /// Create a detached mount of an image and return the mount fd.
1268        ///
1269        /// If overlay upper/work directories are needed, pass them as two fds
1270        /// (upperdir, workdir) via SCM_RIGHTS. The returned fd is a detached
1271        /// mount that the caller can attach with `move_mount()`.
1272        #[zlink(return_fds)]
1273        async fn mount(
1274            &self,
1275            handle: u64,
1276            name: String,
1277            options: MountParams,
1278            #[zlink(fds)] fds: Vec<std::os::fd::OwnedFd>,
1279        ) -> (
1280            std::result::Result<MountReply, RepositoryError>,
1281            Vec<std::os::fd::OwnedFd>,
1282        ) {
1283            let result = match self.lookup_repo(handle) {
1284                Ok(OpenRepo::Sha256(ref r)) => {
1285                    run_mount::<Sha256HashValue>(r, &name, &options, fds)
1286                }
1287                Ok(OpenRepo::Sha512(ref r)) => {
1288                    run_mount::<Sha512HashValue>(r, &name, &options, fds)
1289                }
1290                Err(e) => Err(e),
1291            };
1292            match result {
1293                Ok((reply, fds)) => (Ok(reply), fds),
1294                Err(e) => (Err(e), vec![]),
1295            }
1296        }
1297
1298        // --- org.composefs.Oci ---
1299        //
1300        // The first OCI method sets `interface = "org.composefs.Oci"`; the
1301        // macro then keeps that interface sticky for subsequent methods. Each
1302        // OCI method is still annotated explicitly for clarity.
1303
1304        /// List tagged OCI images in the repository.
1305        ///
1306        /// When `filter` is given, only images whose name contains that
1307        /// substring are returned.
1308        #[zlink(interface = "org.composefs.Oci")]
1309        async fn list_images(
1310            &self,
1311            handle: u64,
1312            filter: Option<String>,
1313        ) -> std::result::Result<ListImagesReply, OciError> {
1314            let images = match self.lookup_oci(handle)? {
1315                OpenRepo::Sha256(ref r) => run_list_images::<Sha256HashValue>(r, filter).await,
1316                OpenRepo::Sha512(ref r) => run_list_images::<Sha512HashValue>(r, filter).await,
1317            }?;
1318            Ok(ListImagesReply { images })
1319        }
1320
1321        /// Run an OCI-aware consistency check on the repository.
1322        ///
1323        /// Renamed on the wire to `Check` so it does not collide with the
1324        /// repository-level `Fsck` method (the dispatch enum keys on the wire
1325        /// method name, which must be globally unique across both interfaces).
1326        #[zlink(interface = "org.composefs.Oci", rename = "Check")]
1327        async fn oci_fsck(
1328            &self,
1329            handle: u64,
1330            image: Option<String>,
1331        ) -> std::result::Result<OciFsckReply, OciError> {
1332            match self.lookup_oci(handle)? {
1333                OpenRepo::Sha256(ref r) => run_oci_fsck::<Sha256HashValue>(r, image).await,
1334                OpenRepo::Sha512(ref r) => run_oci_fsck::<Sha512HashValue>(r, image).await,
1335            }
1336        }
1337
1338        /// Inspect a single OCI image.
1339        #[zlink(interface = "org.composefs.Oci")]
1340        async fn inspect(
1341            &self,
1342            handle: u64,
1343            image: String,
1344        ) -> std::result::Result<OciInspectReply, OciError> {
1345            match self.lookup_oci(handle)? {
1346                OpenRepo::Sha256(ref r) => run_inspect::<Sha256HashValue>(r, image).await,
1347                OpenRepo::Sha512(ref r) => run_inspect::<Sha512HashValue>(r, image).await,
1348            }
1349        }
1350
1351        /// Tag a manifest digest with a name.
1352        #[zlink(interface = "org.composefs.Oci")]
1353        async fn tag(
1354            &self,
1355            handle: u64,
1356            manifest_digest: String,
1357            name: String,
1358        ) -> std::result::Result<(), OciError> {
1359            match self.lookup_oci(handle)? {
1360                OpenRepo::Sha256(ref r) => {
1361                    run_tag::<Sha256HashValue>(r, manifest_digest, name).await
1362                }
1363                OpenRepo::Sha512(ref r) => {
1364                    run_tag::<Sha512HashValue>(r, manifest_digest, name).await
1365                }
1366            }
1367        }
1368
1369        /// Remove a tag.
1370        #[zlink(interface = "org.composefs.Oci")]
1371        async fn untag(&self, handle: u64, name: String) -> std::result::Result<(), OciError> {
1372            match self.lookup_oci(handle)? {
1373                OpenRepo::Sha256(ref r) => run_untag::<Sha256HashValue>(r, name).await,
1374                OpenRepo::Sha512(ref r) => run_untag::<Sha512HashValue>(r, name).await,
1375            }
1376        }
1377
1378        /// Compute the composefs image ID for an OCI image.
1379        #[zlink(interface = "org.composefs.Oci")]
1380        async fn compute_id(
1381            &self,
1382            handle: u64,
1383            image: String,
1384            verity: Option<String>,
1385            bootable: bool,
1386            xattrs: Option<composefs_oci::XattrFiltering>,
1387        ) -> std::result::Result<OciComputeIdReply, OciError> {
1388            match self.lookup_oci(handle)? {
1389                OpenRepo::Sha256(ref r) => {
1390                    run_compute_id::<Sha256HashValue>(r, image, verity, bootable, xattrs).await
1391                }
1392                OpenRepo::Sha512(ref r) => {
1393                    run_compute_id::<Sha512HashValue>(r, image, verity, bootable, xattrs).await
1394                }
1395            }
1396        }
1397
1398        /// Pull an OCI image into the repository, streaming progress.
1399        ///
1400        /// Emits zero or more intermediate [`PullProgress`] frames describing
1401        /// fetch progress (only when `more` is true), followed by exactly one
1402        /// terminal frame whose `completed` field is set, carrying the pull result.
1403        ///
1404        /// `expected_digest`, if set, requires `bootable` and is incompatible
1405        /// with `xattrs`: instead of generating the boot image with a fixed
1406        /// xattr filtering mode, every (mode, EROFS format version)
1407        /// combination is searched until one produces this digest. See
1408        /// [`composefs_oci::find_matching_boot_image`] for why this is
1409        /// needed (e.g. a UKI embedding a digest produced by an older
1410        /// composefs-rs release with different defaults, against a
1411        /// repository whose format version is now fixed).
1412        #[zlink(interface = "org.composefs.Oci", more)]
1413        #[allow(clippy::too_many_arguments)]
1414        async fn pull(
1415            &self,
1416            more: bool,
1417            handle: u64,
1418            image: String,
1419            name: Option<String>,
1420            local_fetch: String,
1421            storage_root: Option<String>,
1422            bootable: bool,
1423            xattrs: Option<composefs_oci::XattrFiltering>,
1424            expected_digest: Option<String>,
1425        ) -> impl zlink::futures_util::Stream<
1426            Item = std::result::Result<zlink::Reply<PullProgress>, OciError>,
1427        > {
1428            let lf = parse_local_fetch(&local_fetch);
1429            let sr = storage_root.map(std::path::PathBuf::from);
1430            // Resolve the handle synchronously and clone an owned Arc out so the
1431            // returned stream owns everything it needs ('static). On a missing
1432            // handle, yield a one-shot error stream (`pull_stream` and the
1433            // error path share the same boxed-trait-object return type).
1434            match self.repos.get(&handle).map(|entry| &entry.repo) {
1435                Some(OpenRepo::Sha256(r)) => pull_stream::<Sha256HashValue>(
1436                    r.clone(),
1437                    image,
1438                    name,
1439                    lf,
1440                    sr,
1441                    bootable,
1442                    xattrs,
1443                    expected_digest,
1444                    more,
1445                ),
1446                Some(OpenRepo::Sha512(r)) => pull_stream::<Sha512HashValue>(
1447                    r.clone(),
1448                    image,
1449                    name,
1450                    lf,
1451                    sr,
1452                    bootable,
1453                    xattrs,
1454                    expected_digest,
1455                    more,
1456                ),
1457                None => {
1458                    use zlink::futures_util::stream;
1459                    Box::pin(stream::once(async move {
1460                        Err(OciError::InvalidHandle { handle })
1461                    }))
1462                }
1463            }
1464        }
1465
1466        /// Mount an OCI image and return the detached mount fd.
1467        ///
1468        /// Resolves the image by ref name or `sha256:` digest, finds its
1469        /// EROFS image (or boot variant if `bootable` is true), and creates
1470        /// a composefs mount. If `options.overlay` is true, the fd array
1471        /// must contain upperdir and workdir fds.
1472        #[zlink(interface = "org.composefs.Oci", return_fds)]
1473        async fn oci_mount(
1474            &self,
1475            handle: u64,
1476            image: String,
1477            bootable: bool,
1478            options: MountParams,
1479            #[zlink(fds)] fds: Vec<std::os::fd::OwnedFd>,
1480        ) -> (
1481            std::result::Result<MountReply, OciError>,
1482            Vec<std::os::fd::OwnedFd>,
1483        ) {
1484            let result = match self.lookup_oci(handle) {
1485                Ok(OpenRepo::Sha256(ref r)) => {
1486                    run_oci_mount::<Sha256HashValue>(r, &image, bootable, &options, fds)
1487                }
1488                Ok(OpenRepo::Sha512(ref r)) => {
1489                    run_oci_mount::<Sha512HashValue>(r, &image, bootable, &options, fds)
1490                }
1491                Err(e) => Err(e),
1492            };
1493            match result {
1494                Ok((reply, fds)) => (Ok(reply), fds),
1495                Err(e) => (Err(e), vec![]),
1496            }
1497        }
1498
1499        // --- org.composefs.Oci (layer-sync methods) ---
1500        //
1501        // These methods were previously under org.composefs.LayerSync but have
1502        // been folded into the Oci interface. Each carries an explicit `interface`
1503        // annotation so the wire names land under the correct interface namespace.
1504
1505        /// Return the capability tokens supported by this service.
1506        ///
1507        /// Currently advertises `"splitdirfdstream-v0"`.
1508        #[zlink(interface = "org.composefs.Oci")]
1509        async fn get_info(&self) -> std::result::Result<GetInfoReply, OciError> {
1510            Ok(GetInfoReply {
1511                features: vec!["splitdirfdstream-v0".into()],
1512            })
1513        }
1514
1515        /// Check whether the layer splitstream for `diff_id` is present.
1516        ///
1517        /// Returns `present = true` and the hex verity if found; `present =
1518        /// false` and `layer_verity = None` if not.
1519        #[zlink(interface = "org.composefs.Oci")]
1520        async fn has_layer(
1521            &self,
1522            handle: u64,
1523            diff_id: String,
1524        ) -> std::result::Result<HasLayerReply, OciError> {
1525            let diff_id_parsed: composefs_oci::OciDigest =
1526                diff_id.parse().map_err(|e| OciError::InvalidDigest {
1527                    message: format!("{e}"),
1528                })?;
1529            let content_id = composefs_oci::layer_content_id(&diff_id_parsed);
1530
1531            fn check<ObjectID: FsVerityHashValue>(
1532                repo: &composefs::repository::Repository<ObjectID>,
1533                content_id: &str,
1534            ) -> std::result::Result<HasLayerReply, OciError> {
1535                match repo
1536                    .has_stream(content_id)
1537                    .map_err(|e| OciError::InternalError {
1538                        message: format!("{e:#}"),
1539                    })? {
1540                    Some(verity) => Ok(HasLayerReply {
1541                        present: true,
1542                        layer_verity: Some(verity.to_hex()),
1543                    }),
1544                    None => Ok(HasLayerReply {
1545                        present: false,
1546                        layer_verity: None,
1547                    }),
1548                }
1549            }
1550
1551            match self.lookup_oci(handle)? {
1552                OpenRepo::Sha256(ref r) => check::<Sha256HashValue>(r, &content_id),
1553                OpenRepo::Sha512(ref r) => check::<Sha512HashValue>(r, &content_id),
1554            }
1555        }
1556
1557        /// Stream the layer as a `splitdirfdstream` over a pipe, with the full
1558        /// hardened streaming fd-transport contract.
1559        ///
1560        /// This is a **streaming** method (`more`): it yields multiple frames,
1561        /// each carrying a batch of FDs.  The client must concatenate FD batches
1562        /// from all frames to reconstruct the logical array:
1563        ///
1564        /// ```text
1565        /// [ pipe_read | <dirfds region: dir_count fds> | <lifetime fds: keepalive + extras> ]
1566        /// ```
1567        ///
1568        /// The dirfds region uses sparse placement (hash-determined slot assignment);
1569        /// lifetime fds are opaque tokens the client must hold until done reading.
1570        ///
1571        /// **Non-streaming** (`more=false`): all fds in a single frame; returns
1572        /// `FdLimitExceeded` if the total exceeds `MAX_FDS_PER_FRAME` (retry with
1573        /// `more=true`).
1574        ///
1575        /// The producer runs on `spawn_blocking` so the async task is never blocked.
1576        /// For the repo case there is no external lock to release, so `keepalive_read`
1577        /// is moved into the producer closure and dropped when the producer finishes.
1578        #[zlink(interface = "org.composefs.Oci", more, return_fds)]
1579        async fn get_layer(
1580            &self,
1581            more: bool,
1582            handle: u64,
1583            params: GetLayerParams,
1584            #[zlink(fds)] _fds: Vec<std::os::fd::OwnedFd>,
1585        ) -> impl zlink::futures_util::Stream<
1586            Item = (
1587                std::result::Result<zlink::Reply<GetLayerReply>, OciError>,
1588                Vec<std::os::fd::OwnedFd>,
1589            ),
1590        > + Unpin {
1591            use zlink::futures_util::stream::{self, StreamExt as _};
1592
1593            type StreamItem = (
1594                std::result::Result<zlink::Reply<GetLayerReply>, OciError>,
1595                Vec<std::os::fd::OwnedFd>,
1596            );
1597
1598            macro_rules! err_stream {
1599                ($e:expr) => {
1600                    return stream::iter(std::iter::once::<StreamItem>((Err($e), vec![])))
1601                        .left_stream()
1602                };
1603            }
1604
1605            // ── Extract diff_id from params (repo service requires it) ─────────
1606            let diff_id = match params.diff_id {
1607                Some(d) => d,
1608                None => err_stream!(OciError::InvalidRequest {
1609                    message: "GetLayer: diff_id is required for the repo service".into(),
1610                }),
1611            };
1612
1613            // ── Parse diff_id ─────────────────────────────────────────────────
1614            let diff_id_parsed: composefs_oci::OciDigest = match diff_id.parse() {
1615                Ok(d) => d,
1616                Err(e) => err_stream!(OciError::InvalidDigest {
1617                    message: format!("{e}"),
1618                }),
1619            };
1620            let content_id = composefs_oci::layer_content_id(&diff_id_parsed);
1621
1622            // ── Drive serve_get_layer via the LayerSource trait ───────────────
1623            fn do_serve_get_layer<ObjectID: FsVerityHashValue>(
1624                repo: &std::sync::Arc<composefs::repository::Repository<ObjectID>>,
1625                content_id: &str,
1626                diff_id_str: &str,
1627                more: bool,
1628            ) -> std::result::Result<composefs_oci::layer_transport::GetLayerFrames, OciError>
1629            {
1630                let verity = repo
1631                    .has_stream(content_id)
1632                    .map_err(|e| OciError::InternalError {
1633                        message: format!("{e:#}"),
1634                    })?
1635                    .ok_or_else(|| OciError::NoSuchLayer {
1636                        diff_id: diff_id_str.to_string(),
1637                    })?;
1638
1639                let seed = seed_from_id(content_id);
1640                let source = RepoLayerSource {
1641                    repo: repo.clone(),
1642                    layer_verity: verity,
1643                };
1644
1645                serve_get_layer(source, seed, more).map_err(|e| match e {
1646                    composefs_oci::layer_transport::ServeGetLayerError::FdLimitExceeded(e) => {
1647                        OciError::FdLimitExceeded {
1648                            fd_count: e.fd_count as u64,
1649                            max_per_frame: e.max_per_frame as u64,
1650                        }
1651                    }
1652                    composefs_oci::layer_transport::ServeGetLayerError::Other(e) => {
1653                        OciError::InternalError {
1654                            message: format!("{e:#}"),
1655                        }
1656                    }
1657                })
1658            }
1659
1660            let frames = match self.lookup_oci(handle) {
1661                Ok(OpenRepo::Sha256(ref r)) => {
1662                    do_serve_get_layer::<Sha256HashValue>(r, &content_id, &diff_id, more)
1663                }
1664                Ok(OpenRepo::Sha512(ref r)) => {
1665                    do_serve_get_layer::<Sha512HashValue>(r, &content_id, &diff_id, more)
1666                }
1667                Err(e) => Err(e),
1668            };
1669
1670            let frames = match frames {
1671                Ok(f) => f,
1672                Err(e) => err_stream!(e),
1673            };
1674
1675            let dir_count = frames.dir_count;
1676            let batches = frames.batches;
1677            let n_frames = batches.len();
1678            let reply = GetLayerReply { dir_count };
1679
1680            stream::iter(batches.into_iter().enumerate().map(move |(i, batch)| {
1681                let is_last = i == n_frames - 1;
1682                (
1683                    Ok(zlink::Reply::new(Some(reply.clone())).set_continues(Some(!is_last))),
1684                    batch,
1685                )
1686            }))
1687            .right_stream()
1688        }
1689
1690        /// Receive a layer as a `splitdirfdstream` from the client and import
1691        /// it into the server's repository, verifying content integrity.
1692        ///
1693        /// The client supplies:
1694        /// * `fds[0]` — read end of a pipe carrying the `splitdirfdstream` bytes.
1695        /// * `fds[1..]` — source object directories (the splitdirfdstream's
1696        ///   `dirfd_index` selects among them; objects dir is index 0).
1697        ///
1698        /// The server runs the verified drain on a `spawn_blocking` thread so the
1699        /// async task is not blocked while data flows through the pipe.  The layer
1700        /// content is only committed if its reconstructed sha256 matches `diff_id`;
1701        /// on mismatch [`OciError::DiffIdMismatch`] is returned and no stream
1702        /// is committed.
1703        ///
1704        /// The server always drains the pipe to avoid wedging the client's writer
1705        /// even if the layer is already present — the import is idempotent.
1706        #[zlink(interface = "org.composefs.Oci")]
1707        async fn put_layer(
1708            &self,
1709            handle: u64,
1710            diff_id: String,
1711            zerocopy: bool,
1712            #[zlink(fds)] fds: Vec<std::os::fd::OwnedFd>,
1713        ) -> std::result::Result<PutLayerReply, OciError> {
1714            // Validate the fd count: fds[0] = pipe read, fds[1..] = dir fds.
1715            if fds.len() < 2 {
1716                return Err(OciError::InvalidRequest {
1717                    message: format!(
1718                        "expected at least 2 fds (1 pipe + >=1 dir fd), got {}",
1719                        fds.len()
1720                    ),
1721                });
1722            }
1723
1724            let diff_id_parsed: composefs_oci::OciDigest =
1725                diff_id.parse().map_err(|e| OciError::InvalidDigest {
1726                    message: format!("{e}"),
1727                })?;
1728
1729            let content_id = composefs_oci::layer_content_id(&diff_id_parsed);
1730
1731            // Check whether the layer is already present (for the reply flag).
1732            // We still proceed with the drain regardless to avoid wedging the
1733            // client's writer if it is already producing.
1734            let already_present = match self.lookup_oci(handle)? {
1735                OpenRepo::Sha256(ref r) => r
1736                    .has_stream(&content_id)
1737                    .map_err(|e| OciError::InternalError {
1738                        message: format!("{e:#}"),
1739                    })?
1740                    .is_some(),
1741                OpenRepo::Sha512(ref r) => r
1742                    .has_stream(&content_id)
1743                    .map_err(|e| OciError::InternalError {
1744                        message: format!("{e:#}"),
1745                    })?
1746                    .is_some(),
1747            };
1748
1749            // Split fds: pipe_read + dir_fds.
1750            let mut fds = fds;
1751            let pipe_read = fds.remove(0);
1752            let dir_fds = fds; // remaining fds are the dir fds
1753
1754            async fn run_put_layer<ObjectID: FsVerityHashValue>(
1755                repo: std::sync::Arc<composefs::repository::Repository<ObjectID>>,
1756                pipe_read: std::os::fd::OwnedFd,
1757                dir_fds: Vec<std::os::fd::OwnedFd>,
1758                diff_id: composefs_oci::OciDigest,
1759                zerocopy: bool,
1760                already_present: bool,
1761            ) -> std::result::Result<PutLayerReply, OciError> {
1762                tokio::task::spawn_blocking(move || {
1763                    composefs_oci::layer_sync::drain_splitdirfdstream_verified(
1764                        repo,
1765                        pipe_read,
1766                        dir_fds,
1767                        &diff_id,
1768                        zerocopy,
1769                        composefs::repository::ImportContext::default(),
1770                    )
1771                })
1772                .await
1773                .map_err(|e| OciError::InternalError {
1774                    message: format!("spawn_blocking panic: {e}"),
1775                })?
1776                .map(|(verity, stats, _ctx)| PutLayerReply {
1777                    layer_verity: verity.to_hex(),
1778                    already_present,
1779                    objects_reflinked: stats.objects_reflinked,
1780                    objects_hardlinked: stats.objects_hardlinked,
1781                    objects_copied: stats.objects_copied,
1782                    objects_already_present: stats.objects_already_present,
1783                })
1784                .map_err(|e| match e {
1785                    composefs_oci::layer_sync::VerifiedDrainError::DiffIdMismatch {
1786                        expected,
1787                        actual,
1788                    } => OciError::DiffIdMismatch { expected, actual },
1789                    composefs_oci::layer_sync::VerifiedDrainError::Other(err) => {
1790                        OciError::InternalError {
1791                            message: format!("{err:#}"),
1792                        }
1793                    }
1794                })
1795            }
1796
1797            match self.lookup_oci(handle)? {
1798                OpenRepo::Sha256(ref r) => {
1799                    run_put_layer::<Sha256HashValue>(
1800                        r.clone(),
1801                        pipe_read,
1802                        dir_fds,
1803                        diff_id_parsed,
1804                        zerocopy,
1805                        already_present,
1806                    )
1807                    .await
1808                }
1809                OpenRepo::Sha512(ref r) => {
1810                    run_put_layer::<Sha512HashValue>(
1811                        r.clone(),
1812                        pipe_read,
1813                        dir_fds,
1814                        diff_id_parsed,
1815                        zerocopy,
1816                        already_present,
1817                    )
1818                    .await
1819                }
1820            }
1821        }
1822
1823        /// Finalize an OCI image after all layers have been imported.
1824        ///
1825        /// Given the raw manifest and config JSON bytes and the ordered list of
1826        /// `(diff_id, layer_verity)` pairs (as returned by `PutLayer`), this
1827        /// method writes the config and manifest splitstreams, generates the
1828        /// composefs EROFS image, and optionally tags the manifest. Idempotent.
1829        ///
1830        /// Returns the digest and verity strings for both the manifest and config
1831        /// splitstreams.
1832        #[zlink(interface = "org.composefs.Oci")]
1833        async fn finalize_image(
1834            &self,
1835            handle: u64,
1836            manifest_json: String,
1837            config_json: String,
1838            layers: Vec<LayerRef>,
1839            name: Option<String>,
1840        ) -> std::result::Result<FinalizeImageReply, OciError> {
1841            async fn run_finalize<ObjectID: FsVerityHashValue>(
1842                repo: std::sync::Arc<composefs::repository::Repository<ObjectID>>,
1843                manifest_json: String,
1844                config_json: String,
1845                layers: Vec<LayerRef>,
1846                name: Option<String>,
1847            ) -> std::result::Result<FinalizeImageReply, OciError> {
1848                // Parse each LayerRef into (OciDigest, ObjectID).
1849                let mut layer_refs: Vec<(composefs_oci::OciDigest, ObjectID)> =
1850                    Vec::with_capacity(layers.len());
1851                for lr in &layers {
1852                    let diff_id: composefs_oci::OciDigest =
1853                        lr.diff_id.parse().map_err(|e| OciError::InvalidDigest {
1854                            message: format!("diff_id {:?}: {e}", lr.diff_id),
1855                        })?;
1856                    let verity = ObjectID::from_hex(&lr.layer_verity).map_err(|e| {
1857                        OciError::InvalidDigest {
1858                            message: format!("layer_verity {:?}: {e}", lr.layer_verity),
1859                        }
1860                    })?;
1861                    layer_refs.push((diff_id, verity));
1862                }
1863
1864                tokio::task::spawn_blocking(move || {
1865                    composefs_oci::layer_sync::finalize_oci_image(
1866                        &repo,
1867                        manifest_json.as_bytes(),
1868                        config_json.as_bytes(),
1869                        &layer_refs,
1870                        name.as_deref(),
1871                    )
1872                })
1873                .await
1874                .map_err(|e| OciError::InternalError {
1875                    message: format!("spawn_blocking panic: {e}"),
1876                })?
1877                .map(
1878                    |((manifest_digest, manifest_verity), (config_digest, config_verity))| {
1879                        FinalizeImageReply {
1880                            manifest_digest: manifest_digest.to_string(),
1881                            manifest_verity: manifest_verity.to_hex(),
1882                            config_digest: config_digest.to_string(),
1883                            config_verity: config_verity.to_hex(),
1884                        }
1885                    },
1886                )
1887                .map_err(|e| OciError::InternalError {
1888                    message: format!("{e:#}"),
1889                })
1890            }
1891
1892            match self.lookup_oci(handle)? {
1893                OpenRepo::Sha256(ref r) => {
1894                    run_finalize::<Sha256HashValue>(
1895                        r.clone(),
1896                        manifest_json,
1897                        config_json,
1898                        layers,
1899                        name,
1900                    )
1901                    .await
1902                }
1903                OpenRepo::Sha512(ref r) => {
1904                    run_finalize::<Sha512HashValue>(
1905                        r.clone(),
1906                        manifest_json,
1907                        config_json,
1908                        layers,
1909                        name,
1910                    )
1911                    .await
1912                }
1913            }
1914        }
1915    }
1916}
1917
1918/// A `Listener` that yields a single pre-connected socket, then blocks forever.
1919///
1920/// Used for socket activation where a connected socket pair is
1921/// passed on fd 3. After the first `accept()` returns the connection, subsequent
1922/// calls pend indefinitely (the server will be killed by the parent process once
1923/// the connection closes).
1924#[derive(Debug)]
1925pub(crate) struct ActivatedListener {
1926    /// The connection to yield on the first accept(), consumed after use.
1927    conn: Option<zlink::Connection<zlink::tokio::unix::Stream>>,
1928}
1929
1930impl zlink::Listener for ActivatedListener {
1931    type Socket = zlink::tokio::unix::Stream;
1932
1933    async fn accept(&mut self) -> zlink::Result<Option<zlink::Connection<Self::Socket>>> {
1934        match self.conn.take() {
1935            Some(conn) => Ok(Some(conn)),
1936            None => std::future::pending().await,
1937        }
1938    }
1939}
1940
1941/// An inherited socket-activation fd, classified by its listening state.
1942pub(crate) enum ActivatedSocket {
1943    /// A pre-connected stream (`varlinkctl exec:` transport): one connection
1944    /// on fd 3. Served via [`ActivatedListener`].
1945    Connected(ActivatedListener),
1946    /// A listening socket (systemd `.socket` with `Accept=no`, or the test
1947    /// harness): served with a normal accept loop.
1948    Listening(zlink::tokio::unix::Listener),
1949}
1950
1951/// Try to classify a socket-activation fd inherited from the service manager.
1952///
1953/// Uses `libsystemd` to receive file descriptors (checks `LISTEN_FDS`/
1954/// `LISTEN_PID` and clears the env vars). Returns `None` when the process
1955/// was not socket-activated.
1956///
1957/// When a fd is present its socket type is inspected via `SO_ACCEPTCONN`:
1958/// - **Listening**: the fd is a bound, listening socket (e.g. passed by the
1959///   test harness or a systemd `.socket` unit with `Accept=no`) — wrapped as
1960///   [`ActivatedSocket::Listening`].
1961/// - **Connected**: the fd is an already-connected stream (e.g. `varlinkctl
1962///   exec:`) — wrapped as [`ActivatedSocket::Connected`].
1963#[allow(unsafe_code)]
1964pub(crate) fn try_activated_listener() -> Result<Option<ActivatedSocket>> {
1965    use std::os::fd::{FromRawFd as _, IntoRawFd as _, OwnedFd};
1966
1967    let fds = libsystemd::activation::receive_descriptors(true)
1968        .map_err(|e| anyhow::anyhow!("Failed to receive activation fds: {e}"))?;
1969
1970    let fd = match fds.into_iter().next() {
1971        Some(fd) => fd,
1972        None => return Ok(None),
1973    };
1974
1975    // SAFETY: `receive_descriptors` validated the fd and transferred ownership
1976    // via `IntoRawFd`.  We immediately re-wrap the raw integer as an `OwnedFd`
1977    // so that Rust's ownership rules track the fd from this point forward.
1978    let owned: OwnedFd = unsafe { OwnedFd::from_raw_fd(fd.into_raw_fd()) };
1979
1980    // Query SO_ACCEPTCONN to distinguish a pre-connected stream (varlinkctl
1981    // `exec:`) from a listening socket (systemd socket unit / test harness).
1982    let is_listening = rustix::net::sockopt::socket_acceptconn(&owned)
1983        .context("querying SO_ACCEPTCONN on activation fd")?;
1984
1985    if is_listening {
1986        // The fd is a bound, listening Unix socket.  Hand it to zlink's
1987        // Listener adapter, which calls set_nonblocking and wraps it in tokio.
1988        let listener = zlink::tokio::unix::Listener::try_from(owned)
1989            .context("converting listening activation fd to zlink Listener")?;
1990        Ok(Some(ActivatedSocket::Listening(listener)))
1991    } else {
1992        // The fd is an already-connected stream (e.g. varlinkctl exec:).
1993        // `From<OwnedFd>` for `UnixStream` is safe — ownership is transferred.
1994        let std_stream = std::os::unix::net::UnixStream::from(owned);
1995        std_stream
1996            .set_nonblocking(true)
1997            .context("setting systemd socket to non-blocking")?;
1998        let tokio_stream = tokio::net::UnixStream::from_std(std_stream)
1999            .context("converting systemd UnixStream to tokio")?;
2000        let zlink_stream =
2001            zlink::tokio::unix::Stream::try_from(tokio_stream).map_err(|e| anyhow::anyhow!(e))?;
2002        let conn = zlink::Connection::new(zlink_stream);
2003        Ok(Some(ActivatedSocket::Connected(ActivatedListener {
2004            conn: Some(conn),
2005        })))
2006    }
2007}
2008
2009/// Serve `service` on an already-obtained socket-activated connected listener.
2010///
2011/// Status is logged, never written to stdout: under socket activation (e.g.
2012/// varlinkctl's `exec:` transport) the parent may treat our stdout as part of
2013/// the protocol handshake, and any stray bytes there reset the connection.
2014///
2015/// The server loop runs inside a [`tokio::task::LocalSet`] so request handlers
2016/// can `spawn_local` `!Send` work (see [`pull_stream`]).  Both serve paths
2017/// wrap exactly one `LocalSet`.
2018pub(crate) async fn serve_activated<S>(service: S, listener: ActivatedListener) -> Result<()>
2019where
2020    S: zlink::Service<zlink::tokio::unix::Stream>,
2021{
2022    log::info!("Listening on systemd-activated socket");
2023    let server = zlink::Server::new(listener, service);
2024    tokio::task::LocalSet::new()
2025        .run_until(server.run())
2026        .await
2027        .context("running varlink server (activated)")
2028}
2029
2030/// Serve `service` on a listening [`zlink::tokio::unix::Listener`] inside a
2031/// [`tokio::task::LocalSet`].
2032///
2033/// Used for both the socket-activated listening fd path and the normal
2034/// `bind`-a-fresh-socket path (see [`serve`]).
2035pub(crate) async fn serve_on_listener<S>(
2036    service: S,
2037    listener: zlink::tokio::unix::Listener,
2038) -> Result<()>
2039where
2040    S: zlink::Service<zlink::tokio::unix::Stream>,
2041{
2042    let server = zlink::Server::new(listener, service);
2043    tokio::task::LocalSet::new()
2044        .run_until(server.run())
2045        .await
2046        .context("running varlink server")
2047}
2048
2049/// Serve `service` on the appropriate socket, auto-detecting the source.
2050///
2051/// Resolution order:
2052/// 1. A socket-activation fd inherited from the service manager:
2053///    - If listening (`SO_ACCEPTCONN`): serve with a normal accept loop.
2054///    - If connected (`varlinkctl exec:`): serve single-shot.
2055/// 2. A freshly bound socket at `address` (which must be `Some`).
2056pub(crate) async fn serve<S>(service: S, address: Option<&Path>) -> Result<()>
2057where
2058    S: zlink::Service<zlink::tokio::unix::Stream>,
2059{
2060    match try_activated_listener()? {
2061        Some(ActivatedSocket::Connected(l)) => return serve_activated(service, l).await,
2062        Some(ActivatedSocket::Listening(listener)) => {
2063            log::info!("Listening on systemd-activated socket");
2064            return serve_on_listener(service, listener).await;
2065        }
2066        None => {}
2067    }
2068    let address = address.context("no --address given and not socket-activated")?;
2069    let listener = zlink::tokio::unix::bind(address)
2070        .with_context(|| format!("binding varlink socket at {}", address.display()))?;
2071    log::info!("Listening on {}", address.display());
2072    serve_on_listener(service, listener).await
2073}
2074
2075/// Varlink support for the OCI interface (`org.composefs.Oci`).
2076///
2077/// Gated behind the `oci` feature; collected in one module so the feature
2078/// gate lives in a single place rather than on every item.
2079#[cfg(feature = "oci")]
2080pub mod oci {
2081    use super::*;
2082
2083    /// Summary of a stored OCI image for the varlink wire format.
2084    #[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
2085    pub struct ImageEntry {
2086        /// Tag/name of the image.
2087        pub name: String,
2088        /// Manifest digest, e.g. "sha256:...".
2089        pub manifest_digest: String,
2090        /// Whether this is a container image (vs an artifact).
2091        pub is_container: bool,
2092        /// Architecture (empty for artifacts).
2093        pub architecture: String,
2094        /// Operating system (empty for artifacts).
2095        pub os: String,
2096        /// Creation timestamp, if recorded.
2097        pub created: Option<String>,
2098        /// Number of layers/blobs.
2099        pub layer_count: u64,
2100        /// Number of OCI referrers (signatures, attestations, etc.).
2101        pub referrer_count: u64,
2102    }
2103
2104    impl From<&composefs_oci::oci_image::ImageInfo> for ImageEntry {
2105        fn from(info: &composefs_oci::oci_image::ImageInfo) -> Self {
2106            Self {
2107                name: info.name.clone(),
2108                manifest_digest: info.manifest_digest.to_string(),
2109                is_container: info.is_container,
2110                architecture: info.architecture.clone(),
2111                os: info.os.clone(),
2112                created: info.created.clone(),
2113                layer_count: info.layer_count as u64,
2114                referrer_count: info.referrer_count as u64,
2115            }
2116        }
2117    }
2118
2119    /// Reply format for listing OCI images.
2120    #[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
2121    pub struct ListImagesReply {
2122        /// The images found in the repository.
2123        pub images: Vec<ImageEntry>,
2124    }
2125
2126    /// Result of an OCI-level consistency check for the varlink wire format.
2127    ///
2128    /// Flattened projection of [`composefs_oci::oci_fsck`]'s `OciFsckResult`; the
2129    /// embedded [`FsckReply`] carries the underlying repository-level results.
2130    #[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
2131    pub struct OciFsckReply {
2132        /// Whether no corruption or errors were found at any level.
2133        pub ok: bool,
2134        /// Number of OCI images checked.
2135        pub images_checked: u64,
2136        /// Number of OCI images found to have issues.
2137        pub images_corrupted: u64,
2138        /// Human-readable descriptions of each OCI-level error found.
2139        pub errors: Vec<String>,
2140        /// The underlying repository-level fsck results.
2141        pub repo: FsckReply,
2142    }
2143
2144    impl From<&composefs_oci::OciFsckResult> for OciFsckReply {
2145        fn from(result: &composefs_oci::OciFsckResult) -> Self {
2146            Self {
2147                ok: result.is_ok(),
2148                images_checked: result.images_checked(),
2149                images_corrupted: result.images_corrupted(),
2150                errors: result.errors().iter().map(|e| e.to_string()).collect(),
2151                repo: FsckReply::from(result.repo_result()),
2152            }
2153        }
2154    }
2155
2156    /// Reply with the manifest, config and referrers of a single OCI image.
2157    #[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
2158    pub struct OciInspectReply {
2159        /// The raw manifest JSON as stored, as a UTF-8 string.
2160        pub manifest: String,
2161        /// The raw config JSON as stored, as a UTF-8 string.
2162        pub config: String,
2163        /// Digests of the OCI referrers (signatures, attestations, etc.).
2164        pub referrers: Vec<String>,
2165        /// Hex fs-verity ID of the linked composefs EROFS image, if any.
2166        pub composefs_erofs: Option<String>,
2167        /// Hex fs-verity ID of the linked bootable composefs EROFS image, if any.
2168        ///
2169        /// Present when the image was pulled with `bootable` support; bootc and
2170        /// other GC-aware callers use this to keep the derived boot EROFS object
2171        /// alive alongside the primary image.
2172        pub composefs_boot_erofs: Option<String>,
2173    }
2174
2175    impl OciInspectReply {
2176        /// Build an inspect reply from a resolved image, reading its manifest,
2177        /// config and referrers from the repository.
2178        pub fn from_image<ObjectID: FsVerityHashValue>(
2179            repo: &Repository<ObjectID>,
2180            img: &composefs_oci::oci_image::OciImage<ObjectID>,
2181        ) -> anyhow::Result<Self> {
2182            let manifest = String::from_utf8(img.read_manifest_json(repo)?)
2183                .context("manifest is not valid UTF-8")?;
2184            let config = String::from_utf8(img.read_config_json(repo)?)
2185                .context("config is not valid UTF-8")?;
2186            let referrers = composefs_oci::oci_image::list_referrers(repo, img.manifest_digest())?
2187                .iter()
2188                .map(|(digest, _verity)| digest.to_string())
2189                .collect();
2190            Ok(Self {
2191                manifest,
2192                config,
2193                referrers,
2194                composefs_erofs: img.image_ref(repo.erofs_version()).map(|id| id.to_hex()),
2195                composefs_boot_erofs: img
2196                    .boot_image_ref(repo.erofs_version())
2197                    .map(|id| id.to_hex()),
2198            })
2199        }
2200    }
2201
2202    /// Reply carrying the computed composefs image ID for an OCI image.
2203    #[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
2204    pub struct OciComputeIdReply {
2205        /// The hex-encoded composefs image ID.
2206        pub image_id: String,
2207    }
2208
2209    /// A single progress frame emitted by the streaming `Pull` method.
2210    ///
2211    /// varlink has no tagged/data-union type, so a sum-of-events is modelled as a
2212    /// struct with one optional field per event shape: exactly one field is set
2213    /// per frame, and its presence acts as the discriminant. (zlink does support
2214    /// nested struct fields, hence the dedicated [`Started`]/[`Progress`]/etc.
2215    /// payload types rather than a flat bag of always-empty columns.)
2216    ///
2217    /// The stream yields zero or more intermediate frames (with `continues=true`)
2218    /// describing fetch progress, followed by exactly one terminal frame whose
2219    /// [`completed`](PullProgress::completed) field is set (and `continues=false`)
2220    /// carrying the pull result.
2221    #[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
2222    pub struct PullProgress {
2223        /// A new component started downloading.
2224        #[serde(skip_serializing_if = "Option::is_none", default)]
2225        pub started: Option<Started>,
2226        /// Incremental transfer progress for a component.
2227        #[serde(skip_serializing_if = "Option::is_none", default)]
2228        pub progress: Option<Progress>,
2229        /// A component was skipped because it was already present.
2230        #[serde(skip_serializing_if = "Option::is_none", default)]
2231        pub skipped: Option<Skipped>,
2232        /// A component finished downloading.
2233        #[serde(skip_serializing_if = "Option::is_none", default)]
2234        pub done: Option<Done>,
2235        /// A human-readable status message.
2236        #[serde(skip_serializing_if = "Option::is_none", default)]
2237        pub message: Option<String>,
2238        /// The terminal frame carrying the pull result. Its presence marks the
2239        /// end of the stream (the reply also has `continues=false`).
2240        #[serde(skip_serializing_if = "Option::is_none", default)]
2241        pub completed: Option<Completed>,
2242    }
2243
2244    /// Unit of measurement for [`Started`]/[`Progress`] counters.
2245    #[derive(Debug, Clone, Copy, Serialize, Deserialize, zlink::introspect::Type)]
2246    pub enum ProgressUnit {
2247        /// Counters are byte counts.
2248        Bytes,
2249        /// Counters are discrete item counts.
2250        Items,
2251    }
2252
2253    impl From<composefs::progress::ProgressUnit> for ProgressUnit {
2254        fn from(unit: composefs::progress::ProgressUnit) -> Self {
2255            use composefs::progress::ProgressUnit as U;
2256            match unit {
2257                U::Bytes => ProgressUnit::Bytes,
2258                U::Items => ProgressUnit::Items,
2259                // `ProgressUnit` is `#[non_exhaustive]`; default to items.
2260                _ => ProgressUnit::Items,
2261            }
2262        }
2263    }
2264
2265    /// A new component (layer/object) started downloading.
2266    #[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
2267    pub struct Started {
2268        /// Component id (layer/object digest).
2269        pub id: String,
2270        /// Total bytes/items to transfer, if known.
2271        pub total: Option<u64>,
2272        /// Unit of `total` and subsequent [`Progress`] counters.
2273        pub unit: ProgressUnit,
2274    }
2275
2276    /// Incremental transfer progress for a component.
2277    #[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
2278    pub struct Progress {
2279        /// Component id (layer/object digest).
2280        pub id: String,
2281        /// Bytes/items transferred so far.
2282        pub fetched: u64,
2283        /// Total bytes/items to transfer, if known.
2284        pub total: Option<u64>,
2285    }
2286
2287    /// A component was skipped because it was already present.
2288    #[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
2289    pub struct Skipped {
2290        /// Component id (layer/object digest).
2291        pub id: String,
2292    }
2293
2294    /// A component finished downloading.
2295    #[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
2296    pub struct Done {
2297        /// Component id (layer/object digest).
2298        pub id: String,
2299        /// Total bytes/items actually transferred.
2300        pub transferred: u64,
2301    }
2302
2303    /// The result of a completed pull.
2304    #[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
2305    pub struct Completed {
2306        /// Manifest digest of the pulled image.
2307        pub manifest_digest: String,
2308        /// Config digest of the pulled image.
2309        pub config_digest: String,
2310        /// Hex fs-verity of the manifest splitstream.
2311        pub manifest_verity: String,
2312        /// Hex fs-verity of the config splitstream.
2313        pub config_verity: String,
2314        /// `Display` rendering of the import stats.
2315        pub stats: String,
2316        /// Hex fs-verity of the generated boot EROFS image, when a bootable pull
2317        /// was requested; `None` otherwise.
2318        pub boot_image: Option<String>,
2319        /// [`Display`](std::fmt::Display) rendering of the xattr filtering mode
2320        /// used to produce `boot_image`; `None` unless `boot_image` is set.
2321        pub boot_image_mode: Option<String>,
2322        /// `Debug` rendering of the EROFS format version used to produce
2323        /// `boot_image`; `None` unless `boot_image` is set.
2324        pub boot_image_format_version: Option<String>,
2325    }
2326
2327    impl PullProgress {
2328        /// An empty frame with every variant cleared. Construct a frame by
2329        /// setting exactly one field.
2330        fn empty() -> Self {
2331            PullProgress {
2332                started: None,
2333                progress: None,
2334                skipped: None,
2335                done: None,
2336                message: None,
2337                completed: None,
2338            }
2339        }
2340    }
2341
2342    impl From<composefs::progress::ProgressEvent> for PullProgress {
2343        /// Map a library [`composefs::progress::ProgressEvent`] to a wire frame,
2344        /// consuming the event so owned fields (e.g. a `Message` string) move
2345        /// rather than clone.
2346        fn from(event: composefs::progress::ProgressEvent) -> Self {
2347            use composefs::progress::ProgressEvent;
2348
2349            let mut p = PullProgress::empty();
2350            match event {
2351                ProgressEvent::Started { id, total, unit } => {
2352                    p.started = Some(Started {
2353                        id: id.into_inner(),
2354                        total,
2355                        unit: unit.into(),
2356                    });
2357                }
2358                ProgressEvent::Progress { id, fetched, total } => {
2359                    p.progress = Some(Progress {
2360                        id: id.into_inner(),
2361                        fetched,
2362                        total,
2363                    });
2364                }
2365                ProgressEvent::Skipped { id } => {
2366                    p.skipped = Some(Skipped {
2367                        id: id.into_inner(),
2368                    });
2369                }
2370                ProgressEvent::Done { id, transferred } => {
2371                    p.done = Some(Done {
2372                        id: id.into_inner(),
2373                        transferred,
2374                    });
2375                }
2376                ProgressEvent::Message(s) => {
2377                    p.message = Some(s);
2378                }
2379                // `ProgressEvent` is `#[non_exhaustive]`; map unknown variants to a
2380                // message frame so future additions remain forward-compatible.
2381                other => {
2382                    p.message = Some(format!("{other:?}"));
2383                }
2384            }
2385            p
2386        }
2387    }
2388
2389    /// A [`composefs::progress::ProgressReporter`] that forwards each event as a
2390    /// [`PullProgress`] frame over an unbounded channel to the streaming method.
2391    struct ChannelReporter {
2392        tx: tokio::sync::mpsc::UnboundedSender<PullProgress>,
2393    }
2394
2395    impl std::fmt::Debug for ChannelReporter {
2396        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2397            f.debug_struct("ChannelReporter").finish_non_exhaustive()
2398        }
2399    }
2400
2401    impl composefs::progress::ProgressReporter for ChannelReporter {
2402        fn report(&self, event: composefs::progress::ProgressEvent) {
2403            // The receiver may have been dropped (client cancelled the stream);
2404            // dropping the event is the right behaviour in that case.
2405            let _ = self.tx.send(PullProgress::from(event));
2406        }
2407    }
2408
2409    /// Aborts the wrapped pull task when dropped.
2410    ///
2411    /// If the client disconnects before the stream completes, dropping the
2412    /// returned stream drops this guard, which aborts the in-flight pull instead
2413    /// of leaking the task.
2414    struct AbortOnDrop {
2415        handle: Option<tokio::task::JoinHandle<std::result::Result<(), OciError>>>,
2416    }
2417
2418    impl AbortOnDrop {
2419        /// Take the join handle out, disarming the abort-on-drop behaviour.
2420        fn take(&mut self) -> Option<tokio::task::JoinHandle<std::result::Result<(), OciError>>> {
2421            self.handle.take()
2422        }
2423    }
2424
2425    impl std::fmt::Debug for AbortOnDrop {
2426        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2427            f.debug_struct("AbortOnDrop").finish_non_exhaustive()
2428        }
2429    }
2430
2431    impl Drop for AbortOnDrop {
2432        fn drop(&mut self) {
2433            if let Some(handle) = &self.handle {
2434                handle.abort();
2435            }
2436        }
2437    }
2438
2439    /// Parse the wire `local_fetch` string into a [`composefs_oci::LocalFetchOpt`].
2440    ///
2441    /// Unknown values fall back to [`LocalFetchOpt::Disabled`](composefs_oci::LocalFetchOpt::Disabled).
2442    pub(crate) fn parse_local_fetch(value: &str) -> composefs_oci::LocalFetchOpt {
2443        use composefs_oci::LocalFetchOpt;
2444        match value {
2445            "auto" | "if-possible" => LocalFetchOpt::IfPossible,
2446            "zerocopy" | "zero-copy" => LocalFetchOpt::ZeroCopy,
2447            _ => LocalFetchOpt::Disabled,
2448        }
2449    }
2450
2451    /// Run a streaming pull against an already-opened repository, returning a
2452    /// boxed stream of [`PullProgress`] frames.
2453    ///
2454    /// The return type is a concrete boxed trait object rather than `impl Stream`
2455    /// so that both monomorphisations (Sha256/Sha512) of this generic function
2456    /// produce the *same* type — letting the non-generic service `pull` method
2457    /// unify the two match arms under a single `impl Stream` return.
2458    ///
2459    /// When `more` is `false` the client asked for a single reply, so no progress
2460    /// reporter is attached and the stream yields only the terminal `completed`
2461    /// frame (or an error).
2462    ///
2463    /// The pull task uses [`tokio::task::spawn_local`], not [`tokio::spawn`]:
2464    /// `composefs_oci::pull` is `!Send` (the `get_layer` zlink proxy returns a
2465    /// `!Send` `ReplyStream`), and the server loop runs inside a `LocalSet`.
2466    ///
2467    /// `expected_digest` requires `bootable` and is mutually exclusive with
2468    /// `xattrs` (searching for an unknown mode and pinning one are
2469    /// contradictory requests); both are rejected up front with
2470    /// [`OciError::InvalidRequest`].
2471    #[allow(clippy::too_many_arguments)]
2472    pub(crate) fn pull_stream<ObjectID: FsVerityHashValue>(
2473        repo: Arc<Repository<ObjectID>>,
2474        image: String,
2475        name: Option<String>,
2476        local_fetch: composefs_oci::LocalFetchOpt,
2477        storage_root: Option<PathBuf>,
2478        bootable: bool,
2479        xattrs: Option<composefs_oci::XattrFiltering>,
2480        expected_digest: Option<String>,
2481        more: bool,
2482    ) -> std::pin::Pin<
2483        Box<
2484            dyn zlink::futures_util::Stream<
2485                    Item = std::result::Result<zlink::Reply<PullProgress>, OciError>,
2486                >,
2487        >,
2488    > {
2489        use zlink::futures_util::stream;
2490
2491        if expected_digest.is_some() && !bootable {
2492            return Box::pin(stream::once(async move {
2493                Err(OciError::InvalidRequest {
2494                    message: "expected_digest requires bootable".to_string(),
2495                })
2496            }));
2497        }
2498        if expected_digest.is_some() && xattrs.is_some() {
2499            return Box::pin(stream::once(async move {
2500                Err(OciError::InvalidRequest {
2501                    message: "expected_digest and xattrs are mutually exclusive: \
2502                              expected_digest searches every mode"
2503                        .to_string(),
2504                })
2505            }));
2506        }
2507        // Parse eagerly so a malformed digest is rejected before the (potentially
2508        // slow) pull runs, rather than surfacing only once it completes.
2509        let expected_digest = match expected_digest
2510            .map(|hex| ObjectID::from_hex(&hex).map(|id| (hex, id)))
2511            .transpose()
2512        {
2513            Ok(parsed) => parsed,
2514            Err(e) => {
2515                return Box::pin(stream::once(async move {
2516                    Err(OciError::InvalidDigest {
2517                        message: format!("invalid expected_digest: {e}"),
2518                    })
2519                }));
2520            }
2521        };
2522
2523        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<PullProgress>();
2524        // Only attach a progress reporter when the client wants streaming frames.
2525        let reporter: Option<composefs::progress::SharedReporter> = if more {
2526            Some(std::sync::Arc::new(ChannelReporter { tx: tx.clone() }))
2527        } else {
2528            None
2529        };
2530
2531        // The task owns everything it needs ('static): it runs the pull, builds the
2532        // terminal `completed` frame from the result (plus optional boot image),
2533        // and sends it through the channel before the sender drops.  Pull errors
2534        // are carried out via the task's `JoinHandle` return value.
2535        let task_tx = tx.clone();
2536        let handle = tokio::task::spawn_local(async move {
2537            let opts = composefs_oci::PullOptions {
2538                local_fetch,
2539                storage_root: storage_root.as_deref(),
2540                progress: reporter,
2541                ..Default::default()
2542            };
2543            let result = composefs_oci::pull(&repo, &image, name.as_deref(), opts)
2544                .await
2545                .map_err(|e| OciError::InternalError {
2546                    message: format!("{e:#}"),
2547                })?;
2548
2549            let (boot_image, boot_image_mode, boot_image_format_version) = if !bootable {
2550                (None, None, None)
2551            } else if let Some((expected_hex, expected)) = expected_digest {
2552                match composefs_oci::find_matching_boot_image(
2553                    &repo,
2554                    &result.manifest_digest,
2555                    &expected,
2556                )
2557                .map_err(|e| OciError::InternalError {
2558                    message: format!("{e:#}"),
2559                })? {
2560                    composefs_oci::BootImageMatch::Found {
2561                        mode,
2562                        version,
2563                        digest,
2564                    } => (
2565                        Some(digest.to_hex()),
2566                        Some(mode.to_string()),
2567                        Some(format!("{version:?}")),
2568                    ),
2569                    composefs_oci::BootImageMatch::NotFound(tried) => {
2570                        return Err(OciError::BootImageMismatch {
2571                            expected: expected_hex,
2572                            tried: tried as u64,
2573                        });
2574                    }
2575                }
2576            } else {
2577                let mode = xattrs.unwrap_or_default();
2578                let transform_opts = composefs_oci::OciTransformOptions { xattrs: mode };
2579                let id = composefs_oci::generate_boot_image(
2580                    &repo,
2581                    &result.manifest_digest,
2582                    &transform_opts,
2583                )
2584                .map_err(|e| OciError::InternalError {
2585                    message: format!("{e:#}"),
2586                })?;
2587                (
2588                    Some(id.to_hex()),
2589                    Some(mode.to_string()),
2590                    Some(format!("{:?}", repo.erofs_version())),
2591                )
2592            };
2593
2594            let completed = PullProgress {
2595                completed: Some(Completed {
2596                    manifest_digest: result.manifest_digest.to_string(),
2597                    config_digest: result.config_digest.to_string(),
2598                    manifest_verity: result.manifest_verity.to_hex(),
2599                    config_verity: result.config_verity.to_hex(),
2600                    stats: result.stats.to_string(),
2601                    boot_image,
2602                    boot_image_mode,
2603                    boot_image_format_version,
2604                }),
2605                ..PullProgress::empty()
2606            };
2607            // If the receiver is gone the client cancelled; that's fine.
2608            let _ = task_tx.send(completed);
2609            Ok(())
2610        });
2611
2612        // Drop our extra sender handle so the channel closes once the task's clone
2613        // is dropped (i.e. when the task finishes).
2614        drop(tx);
2615
2616        struct State {
2617            rx: tokio::sync::mpsc::UnboundedReceiver<PullProgress>,
2618            handle: Option<AbortOnDrop>,
2619            done: bool,
2620        }
2621
2622        let state = State {
2623            rx,
2624            handle: Some(AbortOnDrop {
2625                handle: Some(handle),
2626            }),
2627            done: false,
2628        };
2629
2630        let stream = stream::unfold(state, |mut state| async move {
2631            if state.done {
2632                return None;
2633            }
2634            match state.rx.recv().await {
2635                Some(frame) => {
2636                    let is_completed = frame.completed.is_some();
2637                    if is_completed {
2638                        state.done = true;
2639                        // Disarm the abort guard: the task has produced its result
2640                        // frame and is finished, so there is nothing left to abort.
2641                        if let Some(guard) = state.handle.as_mut() {
2642                            let _ = guard.take();
2643                        }
2644                    }
2645                    let reply = zlink::Reply::new(Some(frame)).set_continues(Some(!is_completed));
2646                    Some((Ok(reply), state))
2647                }
2648                None => {
2649                    // Channel closed without a terminal frame: the pull failed (or
2650                    // the task panicked). Await the join handle to recover the error.
2651                    state.done = true;
2652                    // Take the join handle out (disarming the abort guard, since
2653                    // the task has already finished) and recover the pull error.
2654                    let join = state.handle.as_mut().and_then(AbortOnDrop::take);
2655                    let err = match join {
2656                        Some(join) => match join.await {
2657                            Ok(Ok(())) => OciError::InternalError {
2658                                message: "pull completed without a result frame".to_string(),
2659                            },
2660                            Ok(Err(e)) => e,
2661                            Err(_) => OciError::InternalError {
2662                                message: "pull task panicked".to_string(),
2663                            },
2664                        },
2665                        None => OciError::InternalError {
2666                            message: "pull task panicked".to_string(),
2667                        },
2668                    };
2669                    Some((Err(err), state))
2670                }
2671            }
2672        });
2673
2674        Box::pin(stream)
2675    }
2676
2677    /// Errors that may be returned by the `org.composefs.Oci` interface.
2678    #[derive(Debug, zlink::ReplyError, zlink::introspect::ReplyError)]
2679    #[zlink(interface = "org.composefs.Oci")]
2680    pub enum OciError {
2681        /// The repository could not be found or opened at the configured path.
2682        RepoNotFound {
2683            /// Description of the failure.
2684            message: String,
2685        },
2686        /// The given handle does not refer to an open repository.
2687        InvalidHandle {
2688            /// The handle that was not found.
2689            handle: u64,
2690        },
2691        /// The named OCI image/reference does not exist.
2692        NoSuchImage {
2693            /// The image reference that was not found.
2694            image: String,
2695        },
2696        /// An unexpected internal error occurred while servicing the request.
2697        InternalError {
2698            /// Description of the failure.
2699            message: String,
2700        },
2701        /// The requested layer (by diff-id) is not present in the repository.
2702        NoSuchLayer {
2703            /// The diff-id that was not found.
2704            diff_id: String,
2705        },
2706        /// A supplied digest/diff-id string was malformed.
2707        InvalidDigest {
2708            /// Human-readable description of the parse failure.
2709            message: String,
2710        },
2711        /// Received layer content did not hash to the declared diff-id.
2712        ///
2713        /// The stream was NOT committed; the client must retry with correct data.
2714        DiffIdMismatch {
2715            /// The diff_id that was declared by the client.
2716            expected: String,
2717            /// The sha256 digest of the data that was actually received.
2718            actual: String,
2719        },
2720        /// The request was malformed (e.g. wrong fd count).
2721        InvalidRequest {
2722            /// Human-readable description of what was wrong.
2723            message: String,
2724        },
2725        /// The total fd count exceeds [`MAX_FDS_PER_FRAME`] for a `more=false` call.
2726        ///
2727        /// The client must retry with `more=true` (streaming mode).
2728        FdLimitExceeded {
2729            /// Total number of fds that would be sent.
2730            fd_count: u64,
2731            /// The per-frame cap that was exceeded.
2732            max_per_frame: u64,
2733        },
2734        /// No combination of xattr filtering mode and EROFS format version
2735        /// produced a boot image matching `expected` (see the `pull`
2736        /// method's `expected_digest` parameter and
2737        /// [`composefs_oci::find_matching_boot_image`]).
2738        BootImageMismatch {
2739            /// The expected digest that was searched for.
2740            expected: String,
2741            /// Number of (mode, version) combinations that were tried.
2742            tried: u64,
2743        },
2744    }
2745}
2746
2747/// Reply types for the layer-sync methods of the `org.composefs.Oci` interface,
2748/// gated behind the `oci` feature (they depend on [`composefs_oci::layer_sync`]).
2749///
2750/// The four layer-sync methods (`GetInfo`, `HasLayer`, `GetLayer`, `PutLayer`)
2751/// are part of `org.composefs.Oci`; this module merely collects their reply
2752/// structs to keep them separate from the rest of the OCI wire types.
2753#[cfg(feature = "oci")]
2754pub mod layer_sync {
2755    use super::*;
2756
2757    /// Reply from `GetInfo`: capability tokens supported by this service.
2758    #[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
2759    pub struct GetInfoReply {
2760        /// Capability tokens advertised by this service instance.
2761        ///
2762        /// Currently only `"splitdirfdstream-v0"` is defined.
2763        pub features: Vec<String>,
2764    }
2765
2766    /// Reply from `HasLayer`: whether the layer is present in the repository.
2767    #[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
2768    pub struct HasLayerReply {
2769        /// Whether the layer splitstream for the given diff-id is present.
2770        pub present: bool,
2771        /// Hex-encoded fs-verity hash of the layer splitstream, if present.
2772        pub layer_verity: Option<String>,
2773    }
2774
2775    /// Reply from `GetLayer`: the number of diff-directory slots in the logical FD array.
2776    ///
2777    /// `GetLayer` is a **streaming** method (`more`): it yields multiple frames,
2778    /// each carrying a batch of FDs.  The client MUST concatenate the FD batches
2779    /// from all frames (in arrival order) to reconstruct the full logical FD array:
2780    ///
2781    /// - `fds[0]` — data pipe read end (carries the `splitdirfdstream` bytes).
2782    /// - `fds[1..=dir_count]` — the dirfds region (`dir_count` slots total).  The
2783    ///   real objects-directory fd sits at a sparse, hash-determined index within
2784    ///   this region; the remaining (gap) slots hold inert dummy fds that
2785    ///   `reconstruct` never dereferences.  The sparse placement is encoded in each
2786    ///   `FileBackedData` chunk's `dirfd_index`; the client passes the whole region
2787    ///   to `drain_splitdirfdstream` / `reconstruct` unchanged and must NOT assume
2788    ///   the dir is at a fixed index.
2789    /// - `fds[dir_count+1..]` — opaque lifetime FDs.  The client MUST hold every
2790    ///   one of these open until it has finished reading and processing all dir fds,
2791    ///   then close them all to signal completion to the server.  The count of
2792    ///   trailing FDs is unspecified by contract; the client keeps open whatever it
2793    ///   does not otherwise recognise.  This lifetime-FD convention is part of the
2794    ///   `splitdirfdstream-v0` feature.
2795    ///
2796    /// Each transport frame carries at most `MAX_FDS_PER_FRAME` (240) fds, safely
2797    /// below the kernel `SCM_MAX_FD` (253) limit.  Every frame carries the same
2798    /// `dir_count`; the client should use the value from any frame (they are all
2799    /// identical).  The stream terminates when a frame with `continues=false` is
2800    /// received.
2801    ///
2802    /// A non-streaming (`more=false`) call delivers all fds in a single frame; if
2803    /// the layer requires more than `MAX_FDS_PER_FRAME` fds the call returns
2804    /// `FdLimitExceeded` and the client must retry with `more=true`.
2805    #[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
2806    pub struct GetLayerReply {
2807        /// Number of diff-directory file descriptors in the full logical FD array
2808        /// (i.e. `fds[1..=dir_count]` after concatenating all frames' batches).
2809        pub dir_count: u32,
2810    }
2811
2812    /// Reply from `PutLayer`: the verity hash of the imported layer, whether
2813    /// it was already present, and per-object transfer statistics.
2814    ///
2815    /// The object-count fields let the client verify that zero-copy transfer
2816    /// actually took place (e.g. assert `objects_reflinked > 0` in tests) and
2817    /// accumulate aggregate stats for user-facing output.
2818    #[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
2819    pub struct PutLayerReply {
2820        /// Hex-encoded fs-verity hash of the committed layer splitstream.
2821        pub layer_verity: String,
2822        /// `true` if the layer was already present before this call.
2823        ///
2824        /// The server always drains the pipe regardless (to avoid wedging
2825        /// the client's writer), so the stream is re-imported idempotently.
2826        pub already_present: bool,
2827
2828        /// Number of objects that were reflinked (FICLONE) into the
2829        /// destination. Non-zero only when source and dest share a filesystem.
2830        #[serde(default)]
2831        pub objects_reflinked: u64,
2832        /// Number of objects hardlinked into the destination (zerocopy mode).
2833        #[serde(default)]
2834        pub objects_hardlinked: u64,
2835        /// Number of objects byte-copied into the destination.
2836        #[serde(default)]
2837        pub objects_copied: u64,
2838        /// Number of objects already present in the destination (skipped).
2839        #[serde(default)]
2840        pub objects_already_present: u64,
2841    }
2842
2843    /// A single (diff_id, layer_verity) pair passed to `FinalizeImage`.
2844    ///
2845    /// The client builds this list from the `PutLayer` replies it received while
2846    /// copying layers to the destination repository.  The order must match the
2847    /// manifest layer order.
2848    #[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
2849    pub struct LayerRef {
2850        /// OCI diff-id of the layer (e.g. `"sha256:abcd..."`).
2851        pub diff_id: String,
2852        /// Hex-encoded fs-verity hash of the layer splitstream in the destination
2853        /// repository, as returned by `PutLayer`.
2854        pub layer_verity: String,
2855    }
2856
2857    /// Reply from `FinalizeImage`: digest and verity strings for the manifest
2858    /// and config splitstreams that were written (or already existed).
2859    #[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
2860    pub struct FinalizeImageReply {
2861        /// OCI digest of the manifest (e.g. `"sha256:abcd..."`).
2862        pub manifest_digest: String,
2863        /// Hex-encoded fs-verity hash of the manifest splitstream.
2864        pub manifest_verity: String,
2865        /// OCI digest of the config (e.g. `"sha256:abcd..."`).
2866        pub config_digest: String,
2867        /// Hex-encoded fs-verity hash of the config splitstream.
2868        pub config_verity: String,
2869    }
2870}
2871
2872/// Typed Rust client bindings (the native-API mirror of the on-the-wire
2873/// varlink interfaces). These let a Rust consumer — the integration tests
2874/// today, and a future cfsctl-as-client — call the service with generated,
2875/// type-checked proxy methods, in addition to the wire protocol exercised by
2876/// external clients such as `varlinkctl`.
2877pub mod proxy {
2878    #![allow(missing_docs)]
2879
2880    #[cfg(feature = "oci")]
2881    use super::layer_sync::{
2882        FinalizeImageReply, GetInfoReply, GetLayerReply, HasLayerReply, LayerRef, PutLayerReply,
2883    };
2884    #[cfg(feature = "oci")]
2885    use super::oci::{
2886        ListImagesReply, OciComputeIdReply, OciError, OciFsckReply, OciInspectReply, PullProgress,
2887    };
2888    use super::{
2889        EnsureRepositoryReply, FsckReply, GcReply, ImageObjectsReply, InitRepositoryReply,
2890        OpenRepositoryReply, RepositoryError,
2891    };
2892    #[cfg(feature = "oci")]
2893    pub use composefs_oci::varlink_types::GetLayerParams;
2894    #[cfg(feature = "oci")]
2895    use zlink::futures_util::Stream;
2896
2897    /// Typed client for the `org.composefs.Repository` interface.
2898    #[zlink::proxy(interface = "org.composefs.Repository")]
2899    pub trait RepositoryProxy {
2900        /// Initialize a new repository (or verify an existing one).
2901        async fn init_repository(
2902            &mut self,
2903            path: &str,
2904            algorithm: Option<&str>,
2905            insecure: Option<bool>,
2906        ) -> zlink::Result<Result<InitRepositoryReply, RepositoryError>>;
2907
2908        /// Ensure a repository exists (open as-is, initialize, or upgrade).
2909        async fn ensure_repository(
2910            &mut self,
2911            path: &str,
2912            algorithm: Option<&str>,
2913            insecure: Option<bool>,
2914        ) -> zlink::Result<Result<EnsureRepositoryReply, RepositoryError>>;
2915
2916        /// Open and validate a repository, returning an opaque handle.
2917        async fn open_repository(
2918            &mut self,
2919            path: Option<&str>,
2920            user: Option<bool>,
2921            system: Option<bool>,
2922        ) -> zlink::Result<Result<OpenRepositoryReply, RepositoryError>>;
2923
2924        /// Close a previously opened repository handle.
2925        async fn close_repository(
2926            &mut self,
2927            handle: u64,
2928        ) -> zlink::Result<Result<(), RepositoryError>>;
2929
2930        /// Check repository integrity.
2931        async fn fsck(
2932            &mut self,
2933            handle: u64,
2934            metadata_only: Option<bool>,
2935        ) -> zlink::Result<Result<FsckReply, RepositoryError>>;
2936
2937        /// Run garbage collection (or a dry run).
2938        async fn gc(
2939            &mut self,
2940            handle: u64,
2941            dry_run: bool,
2942            roots: Vec<String>,
2943        ) -> zlink::Result<Result<GcReply, RepositoryError>>;
2944
2945        /// List the objects referenced by a single image.
2946        async fn image_objects(
2947            &mut self,
2948            handle: u64,
2949            name: &str,
2950        ) -> zlink::Result<Result<ImageObjectsReply, RepositoryError>>;
2951    }
2952
2953    /// Typed client for the `org.composefs.Oci` interface.
2954    #[cfg(feature = "oci")]
2955    #[zlink::proxy(interface = "org.composefs.Oci")]
2956    #[allow(clippy::too_many_arguments)]
2957    pub trait OciProxy {
2958        /// List tagged OCI images.
2959        async fn list_images(
2960            &mut self,
2961            handle: u64,
2962            filter: Option<&str>,
2963        ) -> zlink::Result<Result<ListImagesReply, OciError>>;
2964
2965        /// Run an OCI-aware consistency check (wire method `Check`).
2966        #[zlink(rename = "Check")]
2967        async fn oci_fsck(
2968            &mut self,
2969            handle: u64,
2970            image: Option<&str>,
2971        ) -> zlink::Result<Result<OciFsckReply, OciError>>;
2972
2973        /// Inspect a single OCI image.
2974        async fn inspect(
2975            &mut self,
2976            handle: u64,
2977            image: &str,
2978        ) -> zlink::Result<Result<OciInspectReply, OciError>>;
2979
2980        /// Tag a manifest digest with a name.
2981        async fn tag(
2982            &mut self,
2983            handle: u64,
2984            manifest_digest: &str,
2985            name: &str,
2986        ) -> zlink::Result<Result<(), OciError>>;
2987
2988        /// Remove a tag.
2989        async fn untag(&mut self, handle: u64, name: &str) -> zlink::Result<Result<(), OciError>>;
2990
2991        /// Compute the composefs image ID for an OCI image.
2992        async fn compute_id(
2993            &mut self,
2994            handle: u64,
2995            image: &str,
2996            verity: Option<&str>,
2997            bootable: bool,
2998            xattrs: Option<composefs_oci::XattrFiltering>,
2999        ) -> zlink::Result<Result<OciComputeIdReply, OciError>>;
3000
3001        /// Pull an OCI image, streaming progress frames.
3002        #[zlink(more, rename = "Pull")]
3003        #[allow(clippy::too_many_arguments)]
3004        async fn pull(
3005            &mut self,
3006            handle: u64,
3007            image: &str,
3008            name: Option<&str>,
3009            local_fetch: &str,
3010            storage_root: Option<&str>,
3011            bootable: bool,
3012            xattrs: Option<composefs_oci::XattrFiltering>,
3013            expected_digest: Option<&str>,
3014        ) -> zlink::Result<impl Stream<Item = zlink::Result<Result<PullProgress, OciError>>>>;
3015
3016        /// Query capability tokens supported by the service.
3017        async fn get_info(&mut self) -> zlink::Result<Result<GetInfoReply, OciError>>;
3018
3019        /// Check whether a layer is present in the repository.
3020        async fn has_layer(
3021            &mut self,
3022            handle: u64,
3023            diff_id: &str,
3024        ) -> zlink::Result<Result<HasLayerReply, OciError>>;
3025
3026        /// Stream the layer as a `splitdirfdstream` with full hardened fd-transport
3027        /// contract (sparse dirfds, keepalive, lifetime fds, multi-frame).
3028        ///
3029        /// Drive the returned stream to completion (until `continues=false`),
3030        /// concatenating each frame's fd batch in order to reconstruct the full
3031        /// logical FD array `[pipe_read, dirfds.., lifetime_fds..]`.
3032        #[zlink(more, return_fds)]
3033        async fn get_layer(
3034            &mut self,
3035            handle: u64,
3036            params: GetLayerParams,
3037        ) -> zlink::Result<
3038            impl zlink::futures_util::Stream<
3039                Item = zlink::Result<(Result<GetLayerReply, OciError>, Vec<std::os::fd::OwnedFd>)>,
3040            >,
3041        >;
3042
3043        /// Receive a layer as a `splitdirfdstream` from the client and import
3044        /// it into the server's repository with diff_id verification.
3045        ///
3046        /// `fds[0]` is the pipe read end; `fds[1..]` are source object dirs.
3047        async fn put_layer(
3048            &mut self,
3049            handle: u64,
3050            diff_id: &str,
3051            zerocopy: bool,
3052            #[zlink(fds)] fds: Vec<std::os::fd::OwnedFd>,
3053        ) -> zlink::Result<Result<PutLayerReply, OciError>>;
3054
3055        /// Finalize an OCI image after all layers have been imported.
3056        ///
3057        /// `layers` must be in manifest layer order; each entry pairs the layer's
3058        /// OCI diff-id with the hex verity returned by `PutLayer`.  `name` is the
3059        /// tag to assign (optional). Idempotent.
3060        async fn finalize_image(
3061            &mut self,
3062            handle: u64,
3063            manifest_json: &str,
3064            config_json: &str,
3065            layers: Vec<LayerRef>,
3066            name: Option<&str>,
3067        ) -> zlink::Result<Result<FinalizeImageReply, OciError>>;
3068    }
3069}
3070
3071#[cfg(feature = "oci")]
3072pub(crate) use oci::*;
3073
3074/// Spawn a `CfsctlService` in-process over a Unix socket pair for testing.
3075///
3076/// Returns a connected client [`zlink::tokio::unix::Connection`] and a
3077/// [`std::thread::JoinHandle`] for the server thread.
3078///
3079/// Mirrors the pattern in `composefs-storage`'s `spawn_in_process`: the zlink
3080/// server is `!Send` so it runs on a dedicated OS thread with its own
3081/// current-thread Tokio runtime and [`tokio::task::LocalSet`].
3082///
3083/// The server thread exits when the client connection is closed.
3084#[cfg(feature = "oci")]
3085pub(crate) fn spawn_in_process(
3086    service: CfsctlService,
3087) -> std::io::Result<(zlink::tokio::unix::Connection, std::thread::JoinHandle<()>)> {
3088    let (client_std, server_std) = std::os::unix::net::UnixStream::pair()?;
3089    client_std.set_nonblocking(true)?;
3090    server_std.set_nonblocking(true)?;
3091
3092    let client_stream = tokio::net::UnixStream::from_std(client_std)?;
3093    let client_zlink =
3094        zlink::tokio::unix::Stream::try_from(client_stream).map_err(std::io::Error::other)?;
3095    let client_conn = zlink::Connection::new(client_zlink);
3096
3097    let handle = std::thread::Builder::new()
3098        .name("cfsctl-service-server".into())
3099        .spawn(move || {
3100            let rt = match tokio::runtime::Builder::new_current_thread()
3101                .enable_all()
3102                .build()
3103            {
3104                Ok(rt) => rt,
3105                Err(e) => {
3106                    log::error!("CfsctlService server runtime build failed: {e:#?}");
3107                    return;
3108                }
3109            };
3110            let local = tokio::task::LocalSet::new();
3111            local.block_on(&rt, async move {
3112                let server_stream = match tokio::net::UnixStream::from_std(server_std) {
3113                    Ok(s) => s,
3114                    Err(e) => {
3115                        log::error!("CfsctlService server stream conversion failed: {e:#?}");
3116                        return;
3117                    }
3118                };
3119                let server_zlink = match zlink::tokio::unix::Stream::try_from(server_stream) {
3120                    Ok(s) => s,
3121                    Err(e) => {
3122                        log::error!("CfsctlService server zlink stream conversion failed: {e:#?}");
3123                        return;
3124                    }
3125                };
3126                let listener = zlink::ReadyListener::new(server_zlink);
3127                let server = zlink::Server::new(listener, service);
3128                if let Err(e) = server.run().await {
3129                    log::warn!("CfsctlService in-process server error: {e:#?}");
3130                }
3131            });
3132        })?;
3133
3134    Ok((client_conn, handle))
3135}
3136
3137#[cfg(all(test, feature = "oci"))]
3138mod layer_sync_tests {
3139    //! In-process round-trip tests for the layer-sync methods of the
3140    //! `org.composefs.Oci` interface.
3141    //!
3142    //! These mirror the in-process transport test in
3143    //! `composefs-storage`'s `cstor_service.rs`.
3144
3145    use std::io::Read as _;
3146    use std::os::fd::AsFd as _;
3147    use std::sync::Arc;
3148
3149    use composefs::fsverity::{FsVerityHashValue as _, Sha256HashValue};
3150    use composefs::repository::{Repository, RepositoryConfig};
3151    use composefs_splitdirfdstream::reconstruct;
3152
3153    use super::layer_sync::GetLayerReply;
3154    use super::oci::OciError;
3155    use super::proxy::{OciProxy, RepositoryProxy as _};
3156    use super::{CfsctlService, spawn_in_process};
3157    use composefs_oci::varlink_types::GetLayerParams;
3158
3159    /// Drive a streaming `get_layer` call to completion, collecting all FDs.
3160    ///
3161    /// Returns `(reply, all_fds)` where `all_fds` is the concatenated FD vector
3162    /// from all frames in arrival order:
3163    /// ```text
3164    /// [ pipe_read | dirfds region (dir_count) | lifetime fds ]
3165    /// ```
3166    async fn collect_get_layer<C>(
3167        client: &mut C,
3168        handle: u64,
3169        diff_id: &str,
3170    ) -> Result<(GetLayerReply, Vec<std::os::fd::OwnedFd>), OciError>
3171    where
3172        C: OciProxy,
3173    {
3174        use zlink::futures_util::StreamExt as _;
3175
3176        let params = GetLayerParams {
3177            diff_id: Some(diff_id.to_owned()),
3178            storage: None,
3179            ..Default::default()
3180        };
3181        let mut stream = std::pin::pin!(
3182            client
3183                .get_layer(handle, params)
3184                .await
3185                .expect("get_layer transport error")
3186        );
3187
3188        let mut all_fds: Vec<std::os::fd::OwnedFd> = Vec::new();
3189        let mut last_reply: Option<GetLayerReply> = None;
3190
3191        while let Some(item) = stream.next().await {
3192            let (result, fds) = item.expect("get_layer stream error");
3193            match result {
3194                Ok(reply) => {
3195                    last_reply = Some(reply);
3196                }
3197                Err(e) => return Err(e),
3198            }
3199            all_fds.extend(fds);
3200        }
3201
3202        Ok((last_reply.expect("get_layer stream was empty"), all_fds))
3203    }
3204
3205    /// Like `collect_get_layer` but splits the fd array into:
3206    /// - `pipe_and_dirfds`: `fds[0..=dir_count]` (pipe + dirfds region)
3207    /// - `lifetime_fds`: `fds[dir_count+1..]` (keepalive + extras)
3208    ///
3209    /// Returns `(dir_count, pipe_and_dirfds, lifetime_fds)`.
3210    async fn collect_get_layer_split<C>(
3211        client: &mut C,
3212        handle: u64,
3213        diff_id: &str,
3214    ) -> (
3215        GetLayerReply,
3216        Vec<std::os::fd::OwnedFd>,
3217        Vec<std::os::fd::OwnedFd>,
3218    )
3219    where
3220        C: OciProxy,
3221    {
3222        let (reply, mut all_fds) = collect_get_layer(client, handle, diff_id)
3223            .await
3224            .expect("get_layer failed");
3225        let dir_count = reply.dir_count as usize;
3226        // pipe_and_dirfds = fds[0..=dir_count] (1 + dir_count)
3227        let pipe_and_dirfds_len = 1 + dir_count;
3228        assert!(
3229            all_fds.len() >= pipe_and_dirfds_len,
3230            "expected at least {pipe_and_dirfds_len} fds, got {}",
3231            all_fds.len()
3232        );
3233        let lifetime_fds = all_fds.split_off(pipe_and_dirfds_len);
3234        (reply, all_fds, lifetime_fds)
3235    }
3236
3237    /// Build a trivial tar stream with one file at `size` bytes and return the
3238    /// raw bytes.  Content is deterministic (repeating `i % 251`).
3239    fn build_tar_layer(file_size: usize) -> Vec<u8> {
3240        let content: Vec<u8> = (0..file_size).map(|i| (i % 251) as u8).collect();
3241        let mut builder = ::tar::Builder::new(vec![]);
3242        let mut header = ::tar::Header::new_ustar();
3243        header.set_uid(0);
3244        header.set_gid(0);
3245        header.set_mode(0o644);
3246        header.set_entry_type(::tar::EntryType::Regular);
3247        header.set_size(file_size as u64);
3248        builder
3249            .append_data(&mut header, format!("file_{file_size}"), &content[..])
3250            .unwrap();
3251        builder.into_inner().unwrap()
3252    }
3253
3254    /// Create an insecure test repo.
3255    fn create_test_repo() -> (Arc<Repository<Sha256HashValue>>, tempfile::TempDir) {
3256        let tempdir = tempfile::TempDir::new().unwrap();
3257        let (repo, _) = Repository::init_path(
3258            rustix::fs::CWD,
3259            tempdir.path().join("repo"),
3260            RepositoryConfig::default().set_insecure(),
3261        )
3262        .unwrap();
3263        (Arc::new(repo), tempdir)
3264    }
3265
3266    #[tokio::test(flavor = "multi_thread")]
3267    async fn test_layer_sync_in_process() {
3268        // --- set up a repo and import a synthetic layer ---
3269        let (repo, _tempdir) = create_test_repo();
3270
3271        // Build a tar layer that has one large (>64-byte = external) file.
3272        let tar_bytes = build_tar_layer(128 * 1024); // 128 KiB — external object
3273        let diff_id = composefs_oci::sha256_content_digest(&tar_bytes);
3274        let (verity, _stats) =
3275            composefs_oci::import_layer(&repo, &diff_id, None, tar_bytes.as_slice())
3276                .await
3277                .expect("import_layer");
3278
3279        // Record expected cat() output for comparison later.
3280        let mut expected = Vec::<u8>::new();
3281        {
3282            let mut reader = repo
3283                .open_stream("", Some(&verity), Some(composefs_oci::LAYER_CONTENT_TYPE))
3284                .expect("open_stream for cat");
3285            reader.cat(&repo, &mut expected).expect("cat");
3286        }
3287
3288        let repo_path = _tempdir.path().join("repo").to_str().unwrap().to_string();
3289
3290        // --- build and start the in-process service ---
3291        let service = CfsctlService::insecure_for_test();
3292        let (mut client, _server_handle) = spawn_in_process(service).unwrap();
3293
3294        // OpenRepository to get a handle + metadata.
3295        let open_reply = client
3296            .open_repository(Some(&repo_path), None, None)
3297            .await
3298            .unwrap()
3299            .expect("open_repository");
3300        let handle = open_reply.handle;
3301
3302        // Validate the new metadata fields.
3303        assert_eq!(
3304            open_reply.hash_algorithm.as_deref(),
3305            Some("sha256"),
3306            "hash_algorithm must be sha256 for a Sha256HashValue repo"
3307        );
3308        assert!(
3309            open_reply.objects_device_id.is_some(),
3310            "objects_device_id must be reported"
3311        );
3312
3313        // --- GetInfo ---
3314        let info = client.get_info().await.unwrap().expect("get_info");
3315        assert!(
3316            info.features.contains(&"splitdirfdstream-v0".to_string()),
3317            "expected splitdirfdstream-v0 in features"
3318        );
3319
3320        // --- HasLayer: present ---
3321        let has = client
3322            .has_layer(handle, diff_id.as_ref())
3323            .await
3324            .unwrap()
3325            .expect("has_layer");
3326        assert!(has.present, "layer must be present");
3327        assert_eq!(
3328            has.layer_verity.as_deref(),
3329            Some(verity.to_hex().as_str()),
3330            "verity mismatch"
3331        );
3332
3333        // --- HasLayer: absent ---
3334        let fake_digest = "sha256:0000000000000000000000000000000000000000000000000000000000000000";
3335        let has_absent = client
3336            .has_layer(handle, fake_digest)
3337            .await
3338            .unwrap()
3339            .expect("has_layer absent");
3340        assert!(!has_absent.present, "absent layer must not be present");
3341        assert!(has_absent.layer_verity.is_none());
3342
3343        // --- GetLayer: e2e round-trip ---
3344        // The new streaming form: collect all frames' fds, then split into
3345        // pipe+dirfds (wire positions 0..=dir_count) and lifetime fds (rest).
3346        let (get_reply, pipe_and_dirfds, lifetime_fds) =
3347            collect_get_layer_split(&mut client, handle, diff_id.as_ref()).await;
3348        let dir_count = get_reply.dir_count as usize;
3349
3350        // Keep lifetime fds alive until we are done reading the stream.
3351        let _lifetime_fds = lifetime_fds;
3352
3353        // fds[0] = pipe read; fds[1..=dir_count] = dirfds region (sparse).
3354        let pipe_fd = pipe_and_dirfds[0].as_fd();
3355        let dir_fds: Vec<_> = pipe_and_dirfds[1..=dir_count]
3356            .iter()
3357            .map(|f| f.as_fd())
3358            .collect();
3359
3360        // Read the splitdirfdstream from the pipe to EOF.
3361        let pipe_owned = rustix::io::dup(pipe_fd).expect("dup pipe read");
3362        let mut pipe_file = std::fs::File::from(pipe_owned);
3363        let mut stream_bytes = Vec::new();
3364        pipe_file.read_to_end(&mut stream_bytes).unwrap();
3365        assert!(!stream_bytes.is_empty(), "stream must be non-empty");
3366
3367        // Reconstruct via the sparse dirfds region.
3368        let mut actual = Vec::new();
3369        reconstruct(stream_bytes.as_slice(), &dir_fds, &mut actual)
3370            .expect("reconstruct splitdirfdstream");
3371
3372        similar_asserts::assert_eq!(
3373            actual,
3374            expected,
3375            "reconstructed layer must equal cat() output"
3376        );
3377
3378        // --- GetLayer: unknown diff-id ---
3379        let err = collect_get_layer(&mut client, handle, fake_digest).await;
3380        match err {
3381            Err(super::oci::OciError::NoSuchLayer { .. }) => {}
3382            other => panic!("expected NoSuchLayer, got {other:?}"),
3383        }
3384    }
3385
3386    /// Full GetLayer→PutLayer relay: serve repo A via one in-process server,
3387    /// call `get_layer` to obtain the stream fds, then relay them to a second
3388    /// in-process server hosting repo B via `put_layer`.
3389    ///
3390    /// Asserts:
3391    /// - `put_layer` succeeds with `already_present = false`.
3392    /// - repo B has the layer committed and its `cat` output matches repo A.
3393    /// - A second `put_layer` with the same data returns `already_present = true`.
3394    #[tokio::test(flavor = "multi_thread")]
3395    async fn test_put_layer_relay() {
3396        // --- set up repo A with a layer containing an external object ---
3397        let (repo_a, _td_a) = create_test_repo();
3398        let tar_bytes = build_tar_layer(128 * 1024); // 128 KiB — external object
3399        let diff_id = composefs_oci::sha256_content_digest(&tar_bytes);
3400        let (verity_a, _) =
3401            composefs_oci::import_layer(&repo_a, &diff_id, None, tar_bytes.as_slice())
3402                .await
3403                .expect("import_layer into repo_a");
3404
3405        // expected cat() output for later comparison
3406        let mut expected = Vec::<u8>::new();
3407        {
3408            let mut reader = repo_a
3409                .open_stream("", Some(&verity_a), Some(composefs_oci::LAYER_CONTENT_TYPE))
3410                .expect("open_stream for cat");
3411            reader.cat(&repo_a, &mut expected).expect("cat");
3412        }
3413        let repo_a_path = _td_a.path().join("repo").to_str().unwrap().to_string();
3414
3415        // --- set up repo B (empty) ---
3416        let (repo_b, _td_b) = create_test_repo();
3417        let repo_b_path = _td_b.path().join("repo").to_str().unwrap().to_string();
3418
3419        // --- start two in-process services ---
3420        let service_a = CfsctlService::insecure_for_test();
3421        let (mut client_a, _srv_a) = spawn_in_process(service_a).unwrap();
3422
3423        let service_b = CfsctlService::insecure_for_test();
3424        let (mut client_b, _srv_b) = spawn_in_process(service_b).unwrap();
3425
3426        // Open repos via each service.
3427        let handle_a = client_a
3428            .open_repository(Some(&repo_a_path), None, None)
3429            .await
3430            .unwrap()
3431            .expect("open_repository A")
3432            .handle;
3433        let handle_b = client_b
3434            .open_repository(Some(&repo_b_path), None, None)
3435            .await
3436            .unwrap()
3437            .expect("open_repository B")
3438            .handle;
3439
3440        // --- GetLayer from service A ---
3441        // Collect all frames; split into pipe+dirfds and lifetime fds.
3442        let (get_reply, pipe_and_dirfds, lifetime_fds) =
3443            collect_get_layer_split(&mut client_a, handle_a, diff_id.as_ref()).await;
3444        let dir_count = get_reply.dir_count as usize;
3445
3446        // --- PutLayer into service B (first time) ---
3447        // PutLayer receives fds[0..=dir_count] (pipe + dirfds region).
3448        // We hold lifetime_fds open until put_layer returns.
3449        let put_fds = pipe_and_dirfds; // fds[0] = pipe, fds[1..=dir_count] = dirs
3450        let put_reply = client_b
3451            .put_layer(handle_b, diff_id.as_ref(), false, put_fds)
3452            .await
3453            .unwrap()
3454            .expect("put_layer");
3455        // Drop lifetime fds after put_layer completes.
3456        drop(lifetime_fds);
3457
3458        assert!(
3459            !put_reply.already_present,
3460            "first put_layer must report already_present = false"
3461        );
3462        assert!(
3463            dir_count > 0,
3464            "dir_count must be > 0 (dirfds region has at least one slot)"
3465        );
3466
3467        // The layer has one large external object; at least one object must
3468        // have been stored via copy (same-host in-process, but tmpfs may not
3469        // support reflink). Verify the stats are populated.
3470        let total_stored =
3471            put_reply.objects_reflinked + put_reply.objects_hardlinked + put_reply.objects_copied;
3472        assert!(
3473            total_stored + put_reply.objects_already_present > 0,
3474            "put_layer must report at least one object stored, got {put_reply:?}"
3475        );
3476
3477        // Verify repo B now has the layer.
3478        let content_id = composefs_oci::layer_content_id(&diff_id);
3479        assert!(
3480            repo_b
3481                .has_stream(&content_id)
3482                .expect("has_stream B")
3483                .is_some(),
3484            "repo B must have the layer after put_layer"
3485        );
3486
3487        // Verify the cat() output matches.
3488        let verity_b: Sha256HashValue =
3489            Sha256HashValue::from_hex(&put_reply.layer_verity).expect("parse layer_verity hex");
3490        let mut actual = Vec::<u8>::new();
3491        {
3492            let mut reader = repo_b
3493                .open_stream("", Some(&verity_b), Some(composefs_oci::LAYER_CONTENT_TYPE))
3494                .expect("open_stream B for cat");
3495            reader.cat(&repo_b, &mut actual).expect("cat B");
3496        }
3497        similar_asserts::assert_eq!(actual, expected, "repo B cat must equal repo A cat");
3498
3499        // --- PutLayer a second time (idempotent): already_present = true ---
3500        let (get_reply2, pipe_and_dirfds2, lifetime_fds2) =
3501            collect_get_layer_split(&mut client_a, handle_a, diff_id.as_ref()).await;
3502        let _ = get_reply2;
3503
3504        let put_reply2 = client_b
3505            .put_layer(handle_b, diff_id.as_ref(), false, pipe_and_dirfds2)
3506            .await
3507            .unwrap()
3508            .expect("put_layer 2nd");
3509        drop(lifetime_fds2);
3510
3511        assert!(
3512            put_reply2.already_present,
3513            "second put_layer must report already_present = true"
3514        );
3515    }
3516
3517    /// Negative: `put_layer` with a wrong diff_id must return `DiffIdMismatch`
3518    /// and repo B must NOT have the stream committed.
3519    #[tokio::test(flavor = "multi_thread")]
3520    async fn test_put_layer_wrong_diff_id() {
3521        let (repo_a, _td_a) = create_test_repo();
3522        let tar_bytes = build_tar_layer(128 * 1024);
3523        let correct_diff_id = composefs_oci::sha256_content_digest(&tar_bytes);
3524        let (_verity_a, _) =
3525            composefs_oci::import_layer(&repo_a, &correct_diff_id, None, tar_bytes.as_slice())
3526                .await
3527                .expect("import_layer");
3528        let repo_a_path = _td_a.path().join("repo").to_str().unwrap().to_string();
3529
3530        let (_repo_b, _td_b) = create_test_repo();
3531        let repo_b_path = _td_b.path().join("repo").to_str().unwrap().to_string();
3532
3533        let service_a = CfsctlService::insecure_for_test();
3534        let (mut client_a, _srv_a) = spawn_in_process(service_a).unwrap();
3535        let service_b = CfsctlService::insecure_for_test();
3536        let (mut client_b, _srv_b) = spawn_in_process(service_b).unwrap();
3537
3538        let handle_a = client_a
3539            .open_repository(Some(&repo_a_path), None, None)
3540            .await
3541            .unwrap()
3542            .expect("open_repository A")
3543            .handle;
3544        let handle_b = client_b
3545            .open_repository(Some(&repo_b_path), None, None)
3546            .await
3547            .unwrap()
3548            .expect("open_repository B")
3549            .handle;
3550
3551        // Get layer fds from A (collect streaming frames, split off lifetime fds).
3552        let (_get_reply, pipe_and_dirfds, lifetime_fds) =
3553            collect_get_layer_split(&mut client_a, handle_a, correct_diff_id.as_ref()).await;
3554
3555        // Deliberately supply the wrong diff_id to service B.
3556        let wrong_diff_id =
3557            "sha256:0000000000000000000000000000000000000000000000000000000000000000";
3558        let put_err = client_b
3559            .put_layer(handle_b, wrong_diff_id, false, pipe_and_dirfds)
3560            .await
3561            .unwrap();
3562        drop(lifetime_fds);
3563
3564        match put_err {
3565            Err(super::oci::OciError::DiffIdMismatch { expected, actual }) => {
3566                assert_eq!(expected, wrong_diff_id);
3567                assert_eq!(actual, correct_diff_id.to_string());
3568            }
3569            other => panic!("expected DiffIdMismatch, got {other:?}"),
3570        }
3571
3572        // The wrong stream must NOT be committed in repo B.
3573        let wrong_content_id = composefs_oci::layer_content_id(
3574            &wrong_diff_id.parse::<composefs_oci::OciDigest>().unwrap(),
3575        );
3576        assert!(
3577            _repo_b
3578                .has_stream(&wrong_content_id)
3579                .expect("has_stream B")
3580                .is_none(),
3581            "repo B must NOT have a stream for the wrong diff_id"
3582        );
3583    }
3584
3585    // -------------------------------------------------------------------------
3586    // Helpers shared by the finalize_image test
3587    // -------------------------------------------------------------------------
3588
3589    /// Build a minimal tar layer with a valid OCI directory structure.
3590    ///
3591    /// Creates `./`, `./usr/`, `./usr/share/`, and one data file of `payload_size`
3592    /// bytes at `./usr/share/data_<payload_size>`.
3593    fn build_oci_tar_layer(payload_size: usize) -> Vec<u8> {
3594        let mut builder = ::tar::Builder::new(vec![]);
3595
3596        for (path, is_dir) in &[("./", true), ("./usr/", true), ("./usr/share/", true)] {
3597            let mut hdr = ::tar::Header::new_ustar();
3598            hdr.set_entry_type(::tar::EntryType::Directory);
3599            hdr.set_uid(0);
3600            hdr.set_gid(0);
3601            hdr.set_mode(0o755);
3602            hdr.set_size(0);
3603            let _ = is_dir; // suppress unused warning
3604            builder
3605                .append_data(&mut hdr, path, std::io::empty())
3606                .unwrap();
3607        }
3608
3609        let content: Vec<u8> = (0..payload_size).map(|i| (i % 251) as u8).collect();
3610        let mut file_hdr = ::tar::Header::new_ustar();
3611        file_hdr.set_entry_type(::tar::EntryType::Regular);
3612        file_hdr.set_uid(0);
3613        file_hdr.set_gid(0);
3614        file_hdr.set_mode(0o644);
3615        file_hdr.set_size(payload_size as u64);
3616        builder
3617            .append_data(
3618                &mut file_hdr,
3619                format!("./usr/share/data_{payload_size}"),
3620                content.as_slice(),
3621            )
3622            .unwrap();
3623
3624        builder.into_inner().unwrap()
3625    }
3626
3627    /// Build a minimal OCI config JSON with the given diff-id strings.
3628    ///
3629    /// Produces a JSON that `oci_spec::image::ImageConfiguration` would accept,
3630    /// without pulling in the `oci_spec` builders (not available in composefs-ctl).
3631    fn make_config_json(diff_ids: &[String]) -> String {
3632        let ids: Vec<String> = diff_ids.iter().map(|d| format!("\"{d}\"")).collect();
3633        format!(
3634            r#"{{"architecture":"amd64","os":"linux","rootfs":{{"type":"layers","diff_ids":[{}]}},"config":{{}}}}"#,
3635            ids.join(",")
3636        )
3637    }
3638
3639    /// Build a minimal OCI manifest JSON referencing `config_digest_str`.
3640    fn make_manifest_json(
3641        config_json: &str,
3642        config_digest_str: &str,
3643        diff_ids: &[String],
3644    ) -> String {
3645        let layer_entries: Vec<String> = diff_ids
3646            .iter()
3647            .map(|d| {
3648                format!(
3649                    r#"{{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"{d}","size":1}}"#
3650                )
3651            })
3652            .collect();
3653        format!(
3654            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":[{}]}}"#,
3655            config_json.len(),
3656            layer_entries.join(",")
3657        )
3658    }
3659
3660    /// Round-trip test for the `FinalizeImage` varlink method.
3661    ///
3662    /// Imports a layer directly into repo B, then calls `finalize_image` via
3663    /// the in-process varlink service on B, and asserts:
3664    /// - The reply digests are non-empty.
3665    /// - The manifest and config splitstreams now exist in repo B.
3666    /// - The composefs EROFS image was generated.
3667    #[tokio::test(flavor = "multi_thread")]
3668    async fn test_finalize_image_roundtrip() {
3669        use composefs_oci::OciDigest;
3670
3671        let (repo_b, _td_b) = create_test_repo();
3672        let repo_b_path = _td_b.path().join("repo").to_str().unwrap().to_string();
3673
3674        // Import a layer directly into repo_b.
3675        let tar_bytes = build_oci_tar_layer(128 * 1024);
3676        let diff_id = composefs_oci::sha256_content_digest(&tar_bytes);
3677        let (layer_verity, _) =
3678            composefs_oci::import_layer(&repo_b, &diff_id, None, tar_bytes.as_slice())
3679                .await
3680                .expect("import_layer into repo_b");
3681
3682        // Build config + manifest JSON.
3683        let diff_ids = vec![diff_id.to_string()];
3684        let config_json = make_config_json(&diff_ids);
3685        let config_digest = composefs_oci::sha256_content_digest(config_json.as_bytes());
3686        let manifest_json = make_manifest_json(&config_json, config_digest.as_ref(), &diff_ids);
3687
3688        // Start the in-process service on repo B.
3689        let service_b = CfsctlService::insecure_for_test();
3690        let (mut client_b, _srv_b) = spawn_in_process(service_b).unwrap();
3691
3692        let handle_b = client_b
3693            .open_repository(Some(&repo_b_path), None, None)
3694            .await
3695            .unwrap()
3696            .expect("open_repository B")
3697            .handle;
3698
3699        // Build the LayerRef list.
3700        let layers = vec![super::layer_sync::LayerRef {
3701            diff_id: diff_id.to_string(),
3702            layer_verity: layer_verity.to_hex(),
3703        }];
3704
3705        // Call finalize_image.
3706        let reply = client_b
3707            .finalize_image(
3708                handle_b,
3709                &manifest_json,
3710                &config_json,
3711                layers,
3712                Some("finalize-test:v1"),
3713            )
3714            .await
3715            .unwrap()
3716            .expect("finalize_image");
3717
3718        // Digests must be non-empty strings.
3719        assert!(
3720            !reply.manifest_digest.is_empty(),
3721            "manifest_digest must be non-empty"
3722        );
3723        assert!(
3724            !reply.manifest_verity.is_empty(),
3725            "manifest_verity must be non-empty"
3726        );
3727        assert!(
3728            !reply.config_digest.is_empty(),
3729            "config_digest must be non-empty"
3730        );
3731        assert!(
3732            !reply.config_verity.is_empty(),
3733            "config_verity must be non-empty"
3734        );
3735
3736        // Manifest and config splitstreams must now exist in repo_b.
3737        let manifest_digest: OciDigest = reply.manifest_digest.parse().unwrap();
3738        let config_digest2: OciDigest = reply.config_digest.parse().unwrap();
3739
3740        let manifest_id = composefs_oci::oci_image::manifest_identifier(&manifest_digest);
3741        assert!(
3742            repo_b
3743                .has_stream(&manifest_id)
3744                .expect("has_stream manifest")
3745                .is_some(),
3746            "manifest splitstream must exist in repo_b"
3747        );
3748
3749        // The config stream key follows the pattern "oci-config-<digest>".
3750        let config_id2 = format!("oci-config-{config_digest2}");
3751        assert!(
3752            repo_b
3753                .has_stream(&config_id2)
3754                .expect("has_stream config")
3755                .is_some(),
3756            "config splitstream must exist in repo_b"
3757        );
3758
3759        // EROFS must have been generated.
3760        let manifest_verity =
3761            Sha256HashValue::from_hex(&reply.manifest_verity).expect("parse manifest_verity");
3762        let erofs = composefs_oci::composefs_erofs_for_manifest(
3763            &repo_b,
3764            &manifest_digest,
3765            Some(&manifest_verity),
3766            repo_b.erofs_version(),
3767        )
3768        .expect("composefs_erofs_for_manifest");
3769        assert!(
3770            erofs.is_some(),
3771            "EROFS image must exist after finalize_image"
3772        );
3773    }
3774}