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