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.
140#[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
141pub struct OpenRepositoryReply {
142    /// The opaque handle to pass to subsequent repository methods.
143    pub handle: u64,
144}
145
146/// Reply from initializing a repository.
147#[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
148pub struct InitRepositoryReply {
149    /// `true` if a new repository was created; `false` if one already existed
150    /// at the requested path with the same algorithm (idempotent).
151    pub created: bool,
152}
153
154/// An opened repository, monomorphized over its detected hash algorithm.
155///
156/// Stored as an [`Arc`] so streaming methods can clone an owned handle into a
157/// `'static` reply stream without borrowing the service.
158#[derive(Debug, Clone)]
159pub(crate) enum OpenRepo {
160    /// A repository using the SHA-256 digest algorithm.
161    Sha256(Arc<Repository<Sha256HashValue>>),
162    /// A repository using the SHA-512 digest algorithm.
163    Sha512(Arc<Repository<Sha512HashValue>>),
164}
165
166/// A single entry in the service's open-repository table.
167#[derive(Debug)]
168struct HandleEntry {
169    /// The opened repository.
170    repo: OpenRepo,
171    /// Owning connection id, recorded for a future per-connection disconnect
172    /// hook that will reclaim handles left open by a vanished client. `Option`
173    /// to leave room for handles not tied to a specific connection.
174    #[allow(dead_code)]
175    owner: Option<usize>,
176}
177
178/// Process-wide repository open options, fixed at startup.
179#[derive(Debug, Clone)]
180struct OpenOptions {
181    /// Open the repository in insecure (no verity required) mode.
182    insecure: bool,
183    /// Require fs-verity to be enabled on the repository.
184    require_verity: bool,
185    /// Skip auto-upgrading old-format repositories.
186    no_upgrade: bool,
187}
188
189impl OpenOptions {
190    /// Derive the open options from parsed CLI arguments.
191    fn from_app(args: &App) -> Self {
192        Self {
193            insecure: args.insecure,
194            require_verity: args.require_verity,
195            no_upgrade: args.no_upgrade,
196        }
197    }
198}
199
200impl Default for OpenOptions {
201    /// The uid-default open options used by the socket-activated entry point,
202    /// which serves before CLI parsing and so has no `App` to consult.
203    fn default() -> Self {
204        Self {
205            insecure: false,
206            require_verity: false,
207            no_upgrade: false,
208        }
209    }
210}
211
212/// Varlink service implementation backing the `org.composefs.Repository` (and,
213/// with the `oci` feature, `org.composefs.Oci`) interfaces.
214///
215/// Holds a table of opened repositories keyed by opaque handle. The zlink
216/// server serializes calls to a single service, so the table is a plain
217/// `HashMap` with no interior locking.
218#[derive(Debug)]
219pub(crate) struct CfsctlService {
220    /// Open repositories keyed by opaque handle.
221    repos: HashMap<u64, HandleEntry>,
222    /// Monotonically increasing handle counter; `0` is reserved as "none".
223    next_handle: u64,
224    /// Repository open options fixed at startup.
225    open_opts: OpenOptions,
226}
227
228impl CfsctlService {
229    /// Construct an empty service with the given repository open options.
230    ///
231    /// No repository is opened at startup: a client must explicitly select one
232    /// with `OpenRepository` and pass the returned handle to every subsequent
233    /// call.
234    fn with_open_opts(open_opts: OpenOptions) -> Self {
235        Self {
236            repos: HashMap::new(),
237            next_handle: 0,
238            open_opts,
239        }
240    }
241
242    /// Construct a service from parsed CLI arguments.
243    ///
244    /// The open flags (`--insecure`/`--require-verity`/`--no-upgrade`) carry
245    /// into repositories opened later via `OpenRepository`; the repository
246    /// selection flags (`--repo`/`--user`/`--system`) do not apply, since the
247    /// varlink service opens repositories on demand rather than at startup.
248    pub(crate) fn from_app(args: &App) -> Self {
249        Self::with_open_opts(OpenOptions::from_app(args))
250    }
251
252    /// Construct a service for the socket-activated entry point, which serves
253    /// before CLI parsing and so has no `App` to consult. Uses default open
254    /// options; the client supplies repository paths via `OpenRepository`.
255    pub(crate) fn activated() -> Self {
256        Self::with_open_opts(OpenOptions::default())
257    }
258
259    /// Allocate a fresh, never-reused handle. Starts at `1` (`0` is "none").
260    fn next_handle(&mut self) -> u64 {
261        self.next_handle += 1;
262        self.next_handle
263    }
264
265    /// Look up an open repository by handle for the Repository interface.
266    ///
267    /// Returns an owned [`OpenRepo`] (a cheap `Arc` clone) so callers do not
268    /// hold a borrow of `self` across the subsequent `.await`.
269    fn lookup_repo(&self, handle: u64) -> std::result::Result<OpenRepo, RepositoryError> {
270        self.repos
271            .get(&handle)
272            .map(|entry| entry.repo.clone())
273            .ok_or(RepositoryError::InvalidHandle { handle })
274    }
275
276    /// Look up an open repository by handle for the OCI interface.
277    ///
278    /// Like [`Self::lookup_repo`] but reports the OCI-interface error so the
279    /// wire error name is `org.composefs.Oci.InvalidHandle`.
280    #[cfg(feature = "oci")]
281    fn lookup_oci(&self, handle: u64) -> std::result::Result<OpenRepo, oci::OciError> {
282        self.repos
283            .get(&handle)
284            .map(|entry| entry.repo.clone())
285            .ok_or(oci::OciError::InvalidHandle { handle })
286    }
287
288    /// Resolve, open and register a repository at `path`, returning its handle.
289    ///
290    /// The digest algorithm is detected from the repository metadata; both
291    /// resolution and open failures are reported as
292    /// [`RepositoryError::RepoNotFound`].
293    fn do_open(
294        &mut self,
295        path: &Path,
296        owner: Option<usize>,
297    ) -> std::result::Result<u64, RepositoryError> {
298        let hash_type = resolve_hash_type(path, None, !self.open_opts.no_upgrade).map_err(|e| {
299            RepositoryError::RepoNotFound {
300                message: format!("{e:#}"),
301            }
302        })?;
303        let repo = match hash_type {
304            HashType::Sha256 => OpenRepo::Sha256(Arc::new(
305                open_repo_at::<Sha256HashValue>(
306                    path,
307                    self.open_opts.insecure,
308                    self.open_opts.require_verity,
309                    self.open_opts.no_upgrade,
310                )
311                .map_err(|e| RepositoryError::RepoNotFound {
312                    message: format!("{e:#}"),
313                })?,
314            )),
315            HashType::Sha512 => OpenRepo::Sha512(Arc::new(
316                open_repo_at::<Sha512HashValue>(
317                    path,
318                    self.open_opts.insecure,
319                    self.open_opts.require_verity,
320                    self.open_opts.no_upgrade,
321                )
322                .map_err(|e| RepositoryError::RepoNotFound {
323                    message: format!("{e:#}"),
324                })?,
325            )),
326        };
327        let handle = self.next_handle();
328        self.repos.insert(handle, HandleEntry { repo, owner });
329        Ok(handle)
330    }
331
332    /// Resolve a repository selector (`path`/`user`/`system`) to a path.
333    ///
334    /// Exactly one of the three must be set; otherwise
335    /// [`RepositoryError::InvalidSpec`] is returned.
336    fn resolve_selector(
337        path: Option<String>,
338        user: Option<bool>,
339        system: Option<bool>,
340    ) -> std::result::Result<PathBuf, RepositoryError> {
341        let user = user.unwrap_or(false);
342        let system = system.unwrap_or(false);
343        match (path, user, system) {
344            (Some(p), false, false) => Ok(PathBuf::from(p)),
345            (None, true, false) => user_path().map_err(|e| RepositoryError::InvalidSpec {
346                message: format!("{e:#}"),
347            }),
348            (None, false, true) => Ok(system_path()),
349            _ => Err(RepositoryError::InvalidSpec {
350                message: "exactly one of `path`, `user`, `system` must be set".into(),
351            }),
352        }
353    }
354}
355
356/// Open the repository and run an fsck.
357async fn run_fsck<ObjectID: FsVerityHashValue>(
358    repo: &Repository<ObjectID>,
359    metadata_only: bool,
360) -> std::result::Result<FsckResult, RepositoryError> {
361    let result = if metadata_only {
362        repo.fsck_metadata_only().await
363    } else {
364        repo.fsck().await
365    };
366    result.map_err(|e| RepositoryError::InternalError {
367        message: format!("{e:#}"),
368    })
369}
370
371/// Run garbage collection (or a dry run) on a repository.
372async fn run_gc<ObjectID: FsVerityHashValue>(
373    repo: &Repository<ObjectID>,
374    dry_run: bool,
375    roots: Vec<String>,
376) -> std::result::Result<GcReply, RepositoryError> {
377    let root_refs: Vec<&str> = roots.iter().map(String::as_str).collect();
378    let result = if dry_run {
379        repo.gc_dry_run(&root_refs)
380    } else {
381        repo.gc(&root_refs)
382    }
383    .map_err(|e| RepositoryError::InternalError {
384        message: format!("{e:#}"),
385    })?;
386    Ok(GcReply { result, dry_run })
387}
388
389/// Collect the objects referenced by an image.
390async fn run_image_objects<ObjectID: FsVerityHashValue>(
391    repo: &Repository<ObjectID>,
392    name: String,
393) -> std::result::Result<ImageObjectsReply, RepositoryError> {
394    let objects = repo.objects_for_image(&name).map_err(|e| {
395        if let Some(nf) = e.downcast_ref::<composefs::ImageNotFound>() {
396            RepositoryError::NoSuchRef {
397                reference: nf.name.clone(),
398            }
399        } else {
400            RepositoryError::InternalError {
401                message: format!("{e:#}"),
402            }
403        }
404    })?;
405    let mut object_ids: Vec<String> = objects.iter().map(|id| id.to_id()).collect();
406    object_ids.sort();
407    Ok(ImageObjectsReply { object_ids })
408}
409
410/// Options for a `Mount` call. All fields are optional for forward
411/// compatibility — new mount options can be added without breaking the
412/// wire format.
413#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, zlink::introspect::Type)]
414pub struct MountParams {
415    /// Whether to set up an overlayfs upper layer.
416    /// When true, the fd array must contain two fds: upperdir and workdir.
417    pub overlay: Option<bool>,
418    /// Whether to mount read-write (only meaningful with overlay).
419    pub read_write: Option<bool>,
420}
421
422impl MountParams {
423    /// Build [`MountOptions`] from these params, consuming the expected fds.
424    fn to_mount_options(
425        &self,
426        fds: Vec<std::os::fd::OwnedFd>,
427    ) -> std::result::Result<composefs::mount::MountOptions, RepositoryError> {
428        let overlay = self.overlay.unwrap_or(false);
429
430        let mut expected_fds = 0;
431        if overlay {
432            expected_fds += 2;
433        }
434
435        if fds.len() != expected_fds {
436            return Err(RepositoryError::InvalidSpec {
437                message: format!(
438                    "Mount expects {expected_fds} fds for the requested options, got {}",
439                    fds.len()
440                ),
441            });
442        }
443
444        let mut options = composefs::mount::MountOptions::default();
445        let mut fd_iter = fds.into_iter();
446        if overlay {
447            let upperdir = fd_iter.next().unwrap();
448            let workdir = fd_iter.next().unwrap();
449            options.set_overlay(upperdir, workdir);
450        }
451        options.set_read_write(self.read_write.unwrap_or(false));
452
453        Ok(options)
454    }
455}
456
457/// Reply for a `Mount` call — just an fd_index referencing the mount fd.
458#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, zlink::introspect::Type)]
459pub struct MountReply {
460    /// Index into the fd vector of the detached mount file descriptor.
461    pub fd_index: u32,
462}
463
464fn run_mount<ObjectID: FsVerityHashValue>(
465    repo: &Repository<ObjectID>,
466    name: &str,
467    params: &MountParams,
468    fds: Vec<std::os::fd::OwnedFd>,
469) -> std::result::Result<(MountReply, Vec<std::os::fd::OwnedFd>), RepositoryError> {
470    let options = params.to_mount_options(fds)?;
471
472    let mount_fd =
473        repo.mount_with_options(name, &options)
474            .map_err(|e| RepositoryError::InternalError {
475                message: format!("{e:#}"),
476            })?;
477
478    Ok((MountReply { fd_index: 0 }, vec![mount_fd]))
479}
480
481#[cfg(feature = "oci")]
482fn run_oci_mount<ObjectID: composefs::fsverity::FsVerityHashValue>(
483    repo: &Repository<ObjectID>,
484    image: &str,
485    bootable: bool,
486    params: &MountParams,
487    fds: Vec<std::os::fd::OwnedFd>,
488) -> std::result::Result<(MountReply, Vec<std::os::fd::OwnedFd>), oci::OciError> {
489    let img = if image.starts_with("sha256:") {
490        let digest: composefs_oci::OciDigest =
491            image.parse().map_err(|e| oci::OciError::InternalError {
492                message: format!("Invalid manifest digest: {e}"),
493            })?;
494        composefs_oci::OciImage::open(repo, &digest, None)
495    } else {
496        composefs_oci::OciImage::open_ref(repo, image)
497    }
498    .map_err(|e| oci::OciError::NoSuchImage {
499        image: format!("{image}: {e:#}"),
500    })?;
501
502    let erofs_id = if bootable {
503        img.boot_image_ref(repo.erofs_version())
504    } else {
505        img.image_ref(repo.erofs_version())
506    }
507    .ok_or_else(|| oci::OciError::InternalError {
508        message: if bootable {
509            "No boot EROFS image linked".into()
510        } else {
511            "No composefs EROFS image linked".into()
512        },
513    })?;
514
515    let options = params
516        .to_mount_options(fds)
517        .map_err(|e| oci::OciError::InternalError {
518            message: format!("{e:?}"),
519        })?;
520    let mount_fd = repo
521        .mount_with_options(&erofs_id.to_hex(), &options)
522        .map_err(|e| oci::OciError::InternalError {
523            message: format!("{e:#}"),
524        })?;
525
526    Ok((MountReply { fd_index: 0 }, vec![mount_fd]))
527}
528
529/// Initialize (or verify) a repository at `path` with the given algorithm.
530///
531/// Creates parent directories if needed, then delegates to
532/// [`Repository::init_path`]. Returns `true` when a new repository was
533/// created and `false` when an identical one already existed (idempotent).
534/// A conflicting existing repository (different algorithm) is an error.
535fn run_init_repository(
536    path: &Path,
537    algorithm: Algorithm,
538    insecure: bool,
539) -> std::result::Result<InitRepositoryReply, RepositoryError> {
540    // Ensure parent directories exist (init_path only creates the final dir).
541    if let Some(parent) = path.parent() {
542        std::fs::create_dir_all(parent).map_err(|e| RepositoryError::InternalError {
543            message: format!("creating parent directories for {}: {e:#}", path.display()),
544        })?;
545    }
546
547    let created = match algorithm {
548        Algorithm::Sha256 { .. } => {
549            let config = if insecure {
550                RepositoryConfig::new(algorithm).set_insecure()
551            } else {
552                RepositoryConfig::new(algorithm)
553            };
554            Repository::<Sha256HashValue>::init_path(CWD, path, config)
555                .map_err(|e| RepositoryError::InternalError {
556                    message: format!("{e:#}"),
557                })?
558                .1
559        }
560        Algorithm::Sha512 { .. } => {
561            let config = if insecure {
562                RepositoryConfig::new(algorithm).set_insecure()
563            } else {
564                RepositoryConfig::new(algorithm)
565            };
566            Repository::<Sha512HashValue>::init_path(CWD, path, config)
567                .map_err(|e| RepositoryError::InternalError {
568                    message: format!("{e:#}"),
569                })?
570                .1
571        }
572    };
573    Ok(InitRepositoryReply { created })
574}
575
576/// OCI helper functions backing the `org.composefs.Oci` interface, gated behind
577/// the `oci` feature.
578#[cfg(feature = "oci")]
579async fn run_list_images<ObjectID: FsVerityHashValue>(
580    repo: &Repository<ObjectID>,
581    filter: Option<String>,
582) -> std::result::Result<Vec<oci::ImageEntry>, oci::OciError> {
583    composefs_oci::oci_image::list_images(repo)
584        .map(|imgs| {
585            imgs.iter()
586                .filter(|img| match &filter {
587                    Some(needle) => img.name.contains(needle.as_str()),
588                    None => true,
589                })
590                .map(oci::ImageEntry::from)
591                .collect()
592        })
593        .map_err(|e| oci::OciError::InternalError {
594            message: format!("{e:#}"),
595        })
596}
597
598/// Run an OCI-aware consistency check on a repository.
599///
600/// When `image` is `Some`, only that tagged image is checked; otherwise all
601/// tagged images are checked.
602#[cfg(feature = "oci")]
603async fn run_oci_fsck<ObjectID: FsVerityHashValue>(
604    repo: &Repository<ObjectID>,
605    image: Option<String>,
606) -> std::result::Result<oci::OciFsckReply, oci::OciError> {
607    let result = match image {
608        Some(name) => composefs_oci::oci_fsck_image(repo, &name).await,
609        None => composefs_oci::oci_fsck(repo).await,
610    }
611    .map_err(|e| oci::OciError::InternalError {
612        message: format!("{e:#}"),
613    })?;
614    Ok(oci::OciFsckReply::from(&result))
615}
616
617/// Inspect a single OCI image.
618#[cfg(feature = "oci")]
619async fn run_inspect<ObjectID: FsVerityHashValue>(
620    repo: &Repository<ObjectID>,
621    image: String,
622) -> std::result::Result<oci::OciInspectReply, oci::OciError> {
623    let reference: crate::OciReference =
624        image.parse().map_err(|e| oci::OciError::InternalError {
625            message: format!("invalid image reference: {e:#}"),
626        })?;
627    let img = crate::resolve_oci_image(repo, &reference).map_err(|e| {
628        if let Some(nf) = e.downcast_ref::<composefs_oci::OciRefNotFound>() {
629            oci::OciError::NoSuchImage {
630                image: nf.name.clone(),
631            }
632        } else if let Some(nf) = e.downcast_ref::<composefs_oci::OciImageNotFound>() {
633            oci::OciError::NoSuchImage {
634                image: nf.digest.clone(),
635            }
636        } else {
637            oci::OciError::InternalError {
638                message: format!("{e:#}"),
639            }
640        }
641    })?;
642
643    oci::OciInspectReply::from_image(repo, &img).map_err(|e| oci::OciError::InternalError {
644        message: format!("{e:#}"),
645    })
646}
647
648/// Tag a manifest digest with a name.
649#[cfg(feature = "oci")]
650async fn run_tag<ObjectID: FsVerityHashValue>(
651    repo: &Repository<ObjectID>,
652    manifest_digest: String,
653    name: String,
654) -> std::result::Result<(), oci::OciError> {
655    let digest: composefs_oci::OciDigest =
656        manifest_digest
657            .parse()
658            .map_err(|e| oci::OciError::InternalError {
659                message: format!("invalid digest: {e}"),
660            })?;
661    composefs_oci::oci_image::tag_image(repo, &digest, &name).map_err(|e| {
662        oci::OciError::InternalError {
663            message: format!("{e:#}"),
664        }
665    })
666}
667
668/// Remove a tag.
669#[cfg(feature = "oci")]
670async fn run_untag<ObjectID: FsVerityHashValue>(
671    repo: &Repository<ObjectID>,
672    name: String,
673) -> std::result::Result<(), oci::OciError> {
674    composefs_oci::oci_image::untag_image(repo, &name).map_err(|e| oci::OciError::InternalError {
675        message: format!("{e:#}"),
676    })
677}
678
679/// Compute the composefs image ID for an OCI image.
680///
681/// Mirrors the CLI `compute-id` path: digest references (`@sha256:…`) use the
682/// supplied `verity` override, while named refs derive both the config digest
683/// and verity from the stored image metadata (ignoring `verity`).
684#[cfg(feature = "oci")]
685async fn run_compute_id<ObjectID: FsVerityHashValue>(
686    repo: &Repository<ObjectID>,
687    image: String,
688    verity: Option<String>,
689    bootable: bool,
690) -> std::result::Result<oci::OciComputeIdReply, oci::OciError> {
691    let reference: crate::OciReference =
692        image.parse().map_err(|e| oci::OciError::InternalError {
693            message: format!("invalid image reference: {e:#}"),
694        })?;
695    let verity_override =
696        crate::verity_opt::<ObjectID>(&verity).map_err(|e| oci::OciError::InternalError {
697            message: format!("invalid verity: {e:#}"),
698        })?;
699    let (config_digest, config_verity) =
700        crate::resolve_oci_config(repo, &reference, verity_override).map_err(|e| {
701            oci::OciError::InternalError {
702                message: format!("{e:#}"),
703            }
704        })?;
705
706    let mut fs =
707        composefs_oci::image::create_filesystem(repo, &config_digest, config_verity.as_ref())
708            .map_err(|e| oci::OciError::InternalError {
709                message: format!("{e:#}"),
710            })?;
711    if bootable {
712        use composefs_boot::BootOps as _;
713        fs.transform_for_boot(repo)
714            .map_err(|e| oci::OciError::InternalError {
715                message: format!("{e:#}"),
716            })?;
717    }
718    let id = fs.compute_image_id(repo.erofs_version());
719    Ok(oci::OciComputeIdReply {
720        image_id: id.to_hex(),
721    })
722}
723
724// The `zlink::service` macro emits several `pub` helper enums (method dispatch,
725// reply params, etc.) as siblings of the impl block. Those cannot be annotated
726// individually, so the macro invocation lives in a dedicated private submodule
727// where `missing_docs` is relaxed. The generated `Service` trait impl applies
728// to `CfsctlService` regardless of the module it is written in.
729//
730// There are two variants of this module selected at compile time. The macro
731// cannot cfg-gate individual methods (it doesn't propagate `#[cfg]`), and the
732// dispatch enum derives its variants from wire method names (so both
733// interfaces must live in ONE impl block). So when the `oci` feature is on we
734// emit a single impl that hosts BOTH `org.composefs.Repository` and
735// `org.composefs.Oci`; otherwise we emit a Repository-only impl.
736//
737// The interface attribute on each method is "sticky": once a method sets
738// `interface = "org.composefs.Oci"` the macro keeps using it for subsequent
739// methods until changed. The Repository methods come first and inherit the
740// seeded `org.composefs.Repository` interface.
741#[cfg(not(feature = "oci"))]
742mod service_impl {
743    #![allow(missing_docs)]
744
745    use super::{
746        CfsctlService, FsckReply, GcReply, ImageObjectsReply, InitRepositoryReply, MountParams,
747        MountReply, OpenRepo, OpenRepositoryReply, RepositoryError, run_fsck, run_gc,
748        run_image_objects, run_init_repository, run_mount,
749    };
750    use composefs::fsverity::{Algorithm, Sha256HashValue, Sha512HashValue};
751
752    #[zlink::service(
753        interface = "org.composefs.Repository",
754        vendor = "org.composefs",
755        product = "cfsctl",
756        version = env!("CARGO_PKG_VERSION"),
757        url = "https://github.com/composefs/composefs-rs"
758    )]
759    impl<Sock> CfsctlService {
760        /// Initialize a new repository at the given path, or verify that an
761        /// existing one matches the requested algorithm (idempotent).
762        ///
763        /// Creates the directory (and any parents) if they do not exist.
764        /// `algorithm` must be a valid fs-verity algorithm string such as
765        /// `"fsverity-sha512-12"` (the default) or `"fsverity-sha256-12"`.
766        /// When omitted the service default (`fsverity-sha512-12`) is used.
767        /// The `insecure` flag mirrors `cfsctl init --insecure`: when `true`,
768        /// fs-verity is not required on `meta.json`.
769        async fn init_repository(
770            &mut self,
771            path: String,
772            algorithm: Option<String>,
773            insecure: Option<bool>,
774        ) -> std::result::Result<InitRepositoryReply, RepositoryError> {
775            let algorithm: Algorithm = algorithm
776                .as_deref()
777                .unwrap_or("fsverity-sha512-12")
778                .parse()
779                .map_err(|e| RepositoryError::InvalidSpec {
780                    message: format!("invalid algorithm: {e}"),
781                })?;
782            let insecure = insecure.unwrap_or(self.open_opts.insecure);
783            run_init_repository(std::path::Path::new(&path), algorithm, insecure)
784        }
785
786        /// Open and validate a repository, returning an opaque handle.
787        ///
788        /// Exactly one of `path`, `user`, `system` must be set.
789        async fn open_repository(
790            &mut self,
791            path: Option<String>,
792            user: Option<bool>,
793            system: Option<bool>,
794            #[zlink(connection)] conn: &mut zlink::Connection<Sock>,
795        ) -> std::result::Result<OpenRepositoryReply, RepositoryError> {
796            let selected = Self::resolve_selector(path, user, system)?;
797            let handle = self.do_open(&selected, Some(conn.id()))?;
798            Ok(OpenRepositoryReply { handle })
799        }
800
801        /// Close a previously opened repository handle.
802        async fn close_repository(
803            &mut self,
804            handle: u64,
805        ) -> std::result::Result<(), RepositoryError> {
806            self.repos
807                .remove(&handle)
808                .map(|_| ())
809                .ok_or(RepositoryError::InvalidHandle { handle })
810        }
811
812        /// Check repository integrity and return the structured result.
813        ///
814        /// When `metadata_only` is true, the expensive per-object fs-verity
815        /// verification is skipped; only metadata and symlink structure are
816        /// checked.
817        async fn fsck(
818            &self,
819            handle: u64,
820            metadata_only: Option<bool>,
821        ) -> std::result::Result<FsckReply, RepositoryError> {
822            let metadata_only = metadata_only.unwrap_or(false);
823            let result = match self.lookup_repo(handle)? {
824                OpenRepo::Sha256(ref r) => run_fsck::<Sha256HashValue>(r, metadata_only).await,
825                OpenRepo::Sha512(ref r) => run_fsck::<Sha512HashValue>(r, metadata_only).await,
826            }?;
827            Ok(FsckReply::from(&result))
828        }
829
830        /// Run garbage collection (or a dry run) and return what was removed.
831        async fn gc(
832            &self,
833            handle: u64,
834            dry_run: bool,
835            roots: Vec<String>,
836        ) -> std::result::Result<GcReply, RepositoryError> {
837            match self.lookup_repo(handle)? {
838                OpenRepo::Sha256(ref r) => run_gc::<Sha256HashValue>(r, dry_run, roots).await,
839                OpenRepo::Sha512(ref r) => run_gc::<Sha512HashValue>(r, dry_run, roots).await,
840            }
841        }
842
843        /// List the objects referenced by a single image.
844        async fn image_objects(
845            &self,
846            handle: u64,
847            name: String,
848        ) -> std::result::Result<ImageObjectsReply, RepositoryError> {
849            match self.lookup_repo(handle)? {
850                OpenRepo::Sha256(ref r) => run_image_objects::<Sha256HashValue>(r, name).await,
851                OpenRepo::Sha512(ref r) => run_image_objects::<Sha512HashValue>(r, name).await,
852            }
853        }
854
855        /// Create a detached mount of an image and return the mount fd.
856        ///
857        /// If overlay upper/work directories are needed, pass them as two fds
858        /// (upperdir, workdir) via SCM_RIGHTS. The returned fd is a detached
859        /// mount that the caller can attach with `move_mount()`.
860        #[zlink(return_fds)]
861        async fn mount(
862            &self,
863            handle: u64,
864            name: String,
865            options: MountParams,
866            #[zlink(fds)] fds: Vec<std::os::fd::OwnedFd>,
867        ) -> (
868            std::result::Result<MountReply, RepositoryError>,
869            Vec<std::os::fd::OwnedFd>,
870        ) {
871            let result = match self.lookup_repo(handle) {
872                Ok(OpenRepo::Sha256(ref r)) => {
873                    run_mount::<Sha256HashValue>(r, &name, &options, fds)
874                }
875                Ok(OpenRepo::Sha512(ref r)) => {
876                    run_mount::<Sha512HashValue>(r, &name, &options, fds)
877                }
878                Err(e) => Err(e),
879            };
880            match result {
881                Ok((reply, fds)) => (Ok(reply), fds),
882                Err(e) => (Err(e), vec![]),
883            }
884        }
885    }
886}
887
888// Combined variant: hosts BOTH the `org.composefs.Repository` and
889// `org.composefs.Oci` interfaces from a single impl block on `CfsctlService`,
890// so one service answers both interfaces on one socket. See the comment above
891// for why this can't be cfg-gated method-by-method.
892#[cfg(feature = "oci")]
893mod service_impl {
894    #![allow(missing_docs)]
895
896    use super::oci::{
897        ListImagesReply, OciComputeIdReply, OciError, OciFsckReply, OciInspectReply, PullProgress,
898        parse_local_fetch, pull_stream,
899    };
900    use super::{
901        CfsctlService, FsckReply, GcReply, ImageObjectsReply, InitRepositoryReply, MountParams,
902        MountReply, OpenRepo, OpenRepositoryReply, RepositoryError, run_compute_id, run_fsck,
903        run_gc, run_image_objects, run_init_repository, run_inspect, run_list_images, run_mount,
904        run_oci_fsck, run_oci_mount, run_tag, run_untag,
905    };
906    use composefs::fsverity::{Algorithm, Sha256HashValue, Sha512HashValue};
907
908    #[zlink::service(
909        interface = "org.composefs.Repository",
910        vendor = "org.composefs",
911        product = "cfsctl",
912        version = env!("CARGO_PKG_VERSION"),
913        url = "https://github.com/composefs/composefs-rs"
914    )]
915    impl<Sock> CfsctlService {
916        // --- org.composefs.Repository (inherits the seeded interface) ---
917
918        /// Initialize a new repository at the given path, or verify that an
919        /// existing one matches the requested algorithm (idempotent).
920        ///
921        /// Creates the directory (and any parents) if they do not exist.
922        /// `algorithm` must be a valid fs-verity algorithm string such as
923        /// `"fsverity-sha512-12"` (the default) or `"fsverity-sha256-12"`.
924        /// When omitted the service default (`fsverity-sha512-12`) is used.
925        /// The `insecure` flag mirrors `cfsctl init --insecure`: when `true`,
926        /// fs-verity is not required on `meta.json`.
927        async fn init_repository(
928            &mut self,
929            path: String,
930            algorithm: Option<String>,
931            insecure: Option<bool>,
932        ) -> std::result::Result<InitRepositoryReply, RepositoryError> {
933            let algorithm: Algorithm = algorithm
934                .as_deref()
935                .unwrap_or("fsverity-sha512-12")
936                .parse()
937                .map_err(|e| RepositoryError::InvalidSpec {
938                    message: format!("invalid algorithm: {e}"),
939                })?;
940            let insecure = insecure.unwrap_or(self.open_opts.insecure);
941            run_init_repository(std::path::Path::new(&path), algorithm, insecure)
942        }
943
944        /// Open and validate a repository, returning an opaque handle.
945        ///
946        /// Exactly one of `path`, `user`, `system` must be set.
947        async fn open_repository(
948            &mut self,
949            path: Option<String>,
950            user: Option<bool>,
951            system: Option<bool>,
952            #[zlink(connection)] conn: &mut zlink::Connection<Sock>,
953        ) -> std::result::Result<OpenRepositoryReply, RepositoryError> {
954            let selected = Self::resolve_selector(path, user, system)?;
955            let handle = self.do_open(&selected, Some(conn.id()))?;
956            Ok(OpenRepositoryReply { handle })
957        }
958
959        /// Close a previously opened repository handle.
960        async fn close_repository(
961            &mut self,
962            handle: u64,
963        ) -> std::result::Result<(), RepositoryError> {
964            self.repos
965                .remove(&handle)
966                .map(|_| ())
967                .ok_or(RepositoryError::InvalidHandle { handle })
968        }
969
970        /// Check repository integrity and return the structured result.
971        ///
972        /// When `metadata_only` is true, the expensive per-object fs-verity
973        /// verification is skipped; only metadata and symlink structure are
974        /// checked.
975        async fn fsck(
976            &self,
977            handle: u64,
978            metadata_only: Option<bool>,
979        ) -> std::result::Result<FsckReply, RepositoryError> {
980            let metadata_only = metadata_only.unwrap_or(false);
981            let result = match self.lookup_repo(handle)? {
982                OpenRepo::Sha256(ref r) => run_fsck::<Sha256HashValue>(r, metadata_only).await,
983                OpenRepo::Sha512(ref r) => run_fsck::<Sha512HashValue>(r, metadata_only).await,
984            }?;
985            Ok(FsckReply::from(&result))
986        }
987
988        /// Run garbage collection (or a dry run) and return what was removed.
989        async fn gc(
990            &self,
991            handle: u64,
992            dry_run: bool,
993            roots: Vec<String>,
994        ) -> std::result::Result<GcReply, RepositoryError> {
995            match self.lookup_repo(handle)? {
996                OpenRepo::Sha256(ref r) => run_gc::<Sha256HashValue>(r, dry_run, roots).await,
997                OpenRepo::Sha512(ref r) => run_gc::<Sha512HashValue>(r, dry_run, roots).await,
998            }
999        }
1000
1001        /// List the objects referenced by a single image.
1002        async fn image_objects(
1003            &self,
1004            handle: u64,
1005            name: String,
1006        ) -> std::result::Result<ImageObjectsReply, RepositoryError> {
1007            match self.lookup_repo(handle)? {
1008                OpenRepo::Sha256(ref r) => run_image_objects::<Sha256HashValue>(r, name).await,
1009                OpenRepo::Sha512(ref r) => run_image_objects::<Sha512HashValue>(r, name).await,
1010            }
1011        }
1012
1013        /// Create a detached mount of an image and return the mount fd.
1014        ///
1015        /// If overlay upper/work directories are needed, pass them as two fds
1016        /// (upperdir, workdir) via SCM_RIGHTS. The returned fd is a detached
1017        /// mount that the caller can attach with `move_mount()`.
1018        #[zlink(return_fds)]
1019        async fn mount(
1020            &self,
1021            handle: u64,
1022            name: String,
1023            options: MountParams,
1024            #[zlink(fds)] fds: Vec<std::os::fd::OwnedFd>,
1025        ) -> (
1026            std::result::Result<MountReply, RepositoryError>,
1027            Vec<std::os::fd::OwnedFd>,
1028        ) {
1029            let result = match self.lookup_repo(handle) {
1030                Ok(OpenRepo::Sha256(ref r)) => {
1031                    run_mount::<Sha256HashValue>(r, &name, &options, fds)
1032                }
1033                Ok(OpenRepo::Sha512(ref r)) => {
1034                    run_mount::<Sha512HashValue>(r, &name, &options, fds)
1035                }
1036                Err(e) => Err(e),
1037            };
1038            match result {
1039                Ok((reply, fds)) => (Ok(reply), fds),
1040                Err(e) => (Err(e), vec![]),
1041            }
1042        }
1043
1044        // --- org.composefs.Oci ---
1045        //
1046        // The first OCI method sets `interface = "org.composefs.Oci"`; the
1047        // macro then keeps that interface sticky for subsequent methods. Each
1048        // OCI method is still annotated explicitly for clarity.
1049
1050        /// List tagged OCI images in the repository.
1051        ///
1052        /// When `filter` is given, only images whose name contains that
1053        /// substring are returned.
1054        #[zlink(interface = "org.composefs.Oci")]
1055        async fn list_images(
1056            &self,
1057            handle: u64,
1058            filter: Option<String>,
1059        ) -> std::result::Result<ListImagesReply, OciError> {
1060            let images = match self.lookup_oci(handle)? {
1061                OpenRepo::Sha256(ref r) => run_list_images::<Sha256HashValue>(r, filter).await,
1062                OpenRepo::Sha512(ref r) => run_list_images::<Sha512HashValue>(r, filter).await,
1063            }?;
1064            Ok(ListImagesReply { images })
1065        }
1066
1067        /// Run an OCI-aware consistency check on the repository.
1068        ///
1069        /// Renamed on the wire to `Check` so it does not collide with the
1070        /// repository-level `Fsck` method (the dispatch enum keys on the wire
1071        /// method name, which must be globally unique across both interfaces).
1072        #[zlink(interface = "org.composefs.Oci", rename = "Check")]
1073        async fn oci_fsck(
1074            &self,
1075            handle: u64,
1076            image: Option<String>,
1077        ) -> std::result::Result<OciFsckReply, OciError> {
1078            match self.lookup_oci(handle)? {
1079                OpenRepo::Sha256(ref r) => run_oci_fsck::<Sha256HashValue>(r, image).await,
1080                OpenRepo::Sha512(ref r) => run_oci_fsck::<Sha512HashValue>(r, image).await,
1081            }
1082        }
1083
1084        /// Inspect a single OCI image.
1085        #[zlink(interface = "org.composefs.Oci")]
1086        async fn inspect(
1087            &self,
1088            handle: u64,
1089            image: String,
1090        ) -> std::result::Result<OciInspectReply, OciError> {
1091            match self.lookup_oci(handle)? {
1092                OpenRepo::Sha256(ref r) => run_inspect::<Sha256HashValue>(r, image).await,
1093                OpenRepo::Sha512(ref r) => run_inspect::<Sha512HashValue>(r, image).await,
1094            }
1095        }
1096
1097        /// Tag a manifest digest with a name.
1098        #[zlink(interface = "org.composefs.Oci")]
1099        async fn tag(
1100            &self,
1101            handle: u64,
1102            manifest_digest: String,
1103            name: String,
1104        ) -> std::result::Result<(), OciError> {
1105            match self.lookup_oci(handle)? {
1106                OpenRepo::Sha256(ref r) => {
1107                    run_tag::<Sha256HashValue>(r, manifest_digest, name).await
1108                }
1109                OpenRepo::Sha512(ref r) => {
1110                    run_tag::<Sha512HashValue>(r, manifest_digest, name).await
1111                }
1112            }
1113        }
1114
1115        /// Remove a tag.
1116        #[zlink(interface = "org.composefs.Oci")]
1117        async fn untag(&self, handle: u64, name: String) -> std::result::Result<(), OciError> {
1118            match self.lookup_oci(handle)? {
1119                OpenRepo::Sha256(ref r) => run_untag::<Sha256HashValue>(r, name).await,
1120                OpenRepo::Sha512(ref r) => run_untag::<Sha512HashValue>(r, name).await,
1121            }
1122        }
1123
1124        /// Compute the composefs image ID for an OCI image.
1125        #[zlink(interface = "org.composefs.Oci")]
1126        async fn compute_id(
1127            &self,
1128            handle: u64,
1129            image: String,
1130            verity: Option<String>,
1131            bootable: bool,
1132        ) -> std::result::Result<OciComputeIdReply, OciError> {
1133            match self.lookup_oci(handle)? {
1134                OpenRepo::Sha256(ref r) => {
1135                    run_compute_id::<Sha256HashValue>(r, image, verity, bootable).await
1136                }
1137                OpenRepo::Sha512(ref r) => {
1138                    run_compute_id::<Sha512HashValue>(r, image, verity, bootable).await
1139                }
1140            }
1141        }
1142
1143        /// Pull an OCI image into the repository, streaming progress.
1144        ///
1145        /// Emits zero or more intermediate [`PullProgress`] frames describing
1146        /// fetch progress (only when `more` is true), followed by exactly one
1147        /// terminal frame whose `completed` field is set, carrying the pull result.
1148        #[zlink(interface = "org.composefs.Oci", more)]
1149        #[allow(clippy::too_many_arguments)]
1150        async fn pull(
1151            &self,
1152            more: bool,
1153            handle: u64,
1154            image: String,
1155            name: Option<String>,
1156            local_fetch: String,
1157            storage_root: Option<String>,
1158            bootable: bool,
1159        ) -> impl zlink::futures_util::Stream<
1160            Item = std::result::Result<zlink::Reply<PullProgress>, OciError>,
1161        > + Send {
1162            let lf = parse_local_fetch(&local_fetch);
1163            let sr = storage_root.map(std::path::PathBuf::from);
1164            // Resolve the handle synchronously and clone an owned Arc out so the
1165            // returned stream owns everything it needs ('static). On a missing
1166            // handle, yield a one-shot error stream (`pull_stream` and the
1167            // error path share the same boxed-trait-object return type).
1168            match self.repos.get(&handle).map(|entry| &entry.repo) {
1169                Some(OpenRepo::Sha256(r)) => {
1170                    pull_stream::<Sha256HashValue>(r.clone(), image, name, lf, sr, bootable, more)
1171                }
1172                Some(OpenRepo::Sha512(r)) => {
1173                    pull_stream::<Sha512HashValue>(r.clone(), image, name, lf, sr, bootable, more)
1174                }
1175                None => {
1176                    use zlink::futures_util::stream;
1177                    Box::pin(stream::once(async move {
1178                        Err(OciError::InvalidHandle { handle })
1179                    }))
1180                }
1181            }
1182        }
1183
1184        /// Mount an OCI image and return the detached mount fd.
1185        ///
1186        /// Resolves the image by ref name or `sha256:` digest, finds its
1187        /// EROFS image (or boot variant if `bootable` is true), and creates
1188        /// a composefs mount. If `options.overlay` is true, the fd array
1189        /// must contain upperdir and workdir fds.
1190        #[zlink(interface = "org.composefs.Oci", return_fds)]
1191        async fn oci_mount(
1192            &self,
1193            handle: u64,
1194            image: String,
1195            bootable: bool,
1196            options: MountParams,
1197            #[zlink(fds)] fds: Vec<std::os::fd::OwnedFd>,
1198        ) -> (
1199            std::result::Result<MountReply, OciError>,
1200            Vec<std::os::fd::OwnedFd>,
1201        ) {
1202            let result = match self.lookup_oci(handle) {
1203                Ok(OpenRepo::Sha256(ref r)) => {
1204                    run_oci_mount::<Sha256HashValue>(r, &image, bootable, &options, fds)
1205                }
1206                Ok(OpenRepo::Sha512(ref r)) => {
1207                    run_oci_mount::<Sha512HashValue>(r, &image, bootable, &options, fds)
1208                }
1209                Err(e) => Err(e),
1210            };
1211            match result {
1212                Ok((reply, fds)) => (Ok(reply), fds),
1213                Err(e) => (Err(e), vec![]),
1214            }
1215        }
1216    }
1217}
1218
1219/// A `Listener` that yields a single pre-connected socket, then blocks forever.
1220///
1221/// Used for socket activation where a connected socket pair is
1222/// passed on fd 3. After the first `accept()` returns the connection, subsequent
1223/// calls pend indefinitely (the server will be killed by the parent process once
1224/// the connection closes).
1225#[derive(Debug)]
1226pub(crate) struct ActivatedListener {
1227    /// The connection to yield on the first accept(), consumed after use.
1228    conn: Option<zlink::Connection<zlink::unix::Stream>>,
1229}
1230
1231impl zlink::Listener for ActivatedListener {
1232    type Socket = zlink::unix::Stream;
1233
1234    async fn accept(&mut self) -> zlink::Result<zlink::Connection<Self::Socket>> {
1235        match self.conn.take() {
1236            Some(conn) => Ok(conn),
1237            None => std::future::pending().await,
1238        }
1239    }
1240}
1241
1242/// Try to build an [`ActivatedListener`] from a socket-activated fd.
1243///
1244/// Uses `libsystemd` to receive file descriptors passed by the service
1245/// manager (checks `LISTEN_FDS`/`LISTEN_PID` and clears the env vars).
1246/// Returns `None` when the process was not socket-activated.
1247#[allow(unsafe_code)]
1248pub(crate) fn try_activated_listener() -> Result<Option<ActivatedListener>> {
1249    use std::os::fd::{FromRawFd as _, IntoRawFd as _};
1250
1251    let fds = libsystemd::activation::receive_descriptors(true)
1252        .map_err(|e| anyhow::anyhow!("Failed to receive activation fds: {e}"))?;
1253
1254    let fd = match fds.into_iter().next() {
1255        Some(fd) => fd,
1256        None => return Ok(None),
1257    };
1258
1259    // SAFETY: `libsystemd::activation::receive_descriptors(true)` validated
1260    // the fd and transferred ownership. `into_raw_fd()` consumes the
1261    // `FileDescriptor` wrapper, giving us sole ownership of a valid fd.
1262    let std_stream = unsafe { std::os::unix::net::UnixStream::from_raw_fd(fd.into_raw_fd()) };
1263    std_stream
1264        .set_nonblocking(true)
1265        .context("setting systemd socket to non-blocking")?;
1266    let tokio_stream = tokio::net::UnixStream::from_std(std_stream)
1267        .context("converting systemd UnixStream to tokio")?;
1268    let zlink_stream = zlink::unix::Stream::from(tokio_stream);
1269    let conn = zlink::Connection::from(zlink_stream);
1270    Ok(Some(ActivatedListener { conn: Some(conn) }))
1271}
1272
1273/// Serve `service` on an already-obtained socket-activated listener.
1274///
1275/// Status is logged, never written to stdout: under socket activation (e.g.
1276/// varlinkctl's `exec:` transport) the parent may treat our stdout as part of
1277/// the protocol handshake, and any stray bytes there reset the connection.
1278pub(crate) async fn serve_activated<S>(service: S, listener: ActivatedListener) -> Result<()>
1279where
1280    S: zlink::Service<zlink::unix::Stream>,
1281{
1282    log::info!("Listening on systemd-activated socket");
1283    let server = zlink::Server::new(listener, service);
1284    server
1285        .run()
1286        .await
1287        .context("running varlink server (activated)")
1288}
1289
1290/// Serve `service` either on a systemd-activated connected socket or by
1291/// binding a fresh Unix socket at `address`.
1292///
1293/// Socket activation takes precedence: if the process was started with a
1294/// connected activation socket, `address` is ignored. Otherwise `address`
1295/// must be `Some`, or this returns an error.
1296pub(crate) async fn serve<S>(service: S, address: Option<&Path>) -> Result<()>
1297where
1298    S: zlink::Service<zlink::unix::Stream>,
1299{
1300    if let Some(activated) = try_activated_listener()? {
1301        return serve_activated(service, activated).await;
1302    }
1303    let address = address.context("no --address given and not socket-activated")?;
1304    let listener = zlink::unix::bind(address)
1305        .with_context(|| format!("binding varlink socket at {}", address.display()))?;
1306    log::info!("Listening on {}", address.display());
1307    let server = zlink::Server::new(listener, service);
1308    server.run().await.context("running varlink server")
1309}
1310
1311/// Varlink support for the OCI interface (`org.composefs.Oci`).
1312///
1313/// Gated behind the `oci` feature; collected in one module so the feature
1314/// gate lives in a single place rather than on every item.
1315#[cfg(feature = "oci")]
1316pub mod oci {
1317    use super::*;
1318
1319    /// Summary of a stored OCI image for the varlink wire format.
1320    #[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
1321    pub struct ImageEntry {
1322        /// Tag/name of the image.
1323        pub name: String,
1324        /// Manifest digest, e.g. "sha256:...".
1325        pub manifest_digest: String,
1326        /// Whether this is a container image (vs an artifact).
1327        pub is_container: bool,
1328        /// Architecture (empty for artifacts).
1329        pub architecture: String,
1330        /// Operating system (empty for artifacts).
1331        pub os: String,
1332        /// Creation timestamp, if recorded.
1333        pub created: Option<String>,
1334        /// Number of layers/blobs.
1335        pub layer_count: u64,
1336        /// Number of OCI referrers (signatures, attestations, etc.).
1337        pub referrer_count: u64,
1338    }
1339
1340    impl From<&composefs_oci::oci_image::ImageInfo> for ImageEntry {
1341        fn from(info: &composefs_oci::oci_image::ImageInfo) -> Self {
1342            Self {
1343                name: info.name.clone(),
1344                manifest_digest: info.manifest_digest.to_string(),
1345                is_container: info.is_container,
1346                architecture: info.architecture.clone(),
1347                os: info.os.clone(),
1348                created: info.created.clone(),
1349                layer_count: info.layer_count as u64,
1350                referrer_count: info.referrer_count as u64,
1351            }
1352        }
1353    }
1354
1355    /// Reply format for listing OCI images.
1356    #[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
1357    pub struct ListImagesReply {
1358        /// The images found in the repository.
1359        pub images: Vec<ImageEntry>,
1360    }
1361
1362    /// Result of an OCI-level consistency check for the varlink wire format.
1363    ///
1364    /// Flattened projection of [`composefs_oci::oci_fsck`]'s `OciFsckResult`; the
1365    /// embedded [`FsckReply`] carries the underlying repository-level results.
1366    #[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
1367    pub struct OciFsckReply {
1368        /// Whether no corruption or errors were found at any level.
1369        pub ok: bool,
1370        /// Number of OCI images checked.
1371        pub images_checked: u64,
1372        /// Number of OCI images found to have issues.
1373        pub images_corrupted: u64,
1374        /// Human-readable descriptions of each OCI-level error found.
1375        pub errors: Vec<String>,
1376        /// The underlying repository-level fsck results.
1377        pub repo: FsckReply,
1378    }
1379
1380    impl From<&composefs_oci::OciFsckResult> for OciFsckReply {
1381        fn from(result: &composefs_oci::OciFsckResult) -> Self {
1382            Self {
1383                ok: result.is_ok(),
1384                images_checked: result.images_checked(),
1385                images_corrupted: result.images_corrupted(),
1386                errors: result.errors().iter().map(|e| e.to_string()).collect(),
1387                repo: FsckReply::from(result.repo_result()),
1388            }
1389        }
1390    }
1391
1392    /// Reply with the manifest, config and referrers of a single OCI image.
1393    #[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
1394    pub struct OciInspectReply {
1395        /// The raw manifest JSON as stored, as a UTF-8 string.
1396        pub manifest: String,
1397        /// The raw config JSON as stored, as a UTF-8 string.
1398        pub config: String,
1399        /// Digests of the OCI referrers (signatures, attestations, etc.).
1400        pub referrers: Vec<String>,
1401        /// Hex fs-verity ID of the linked composefs EROFS image, if any.
1402        pub composefs_erofs: Option<String>,
1403        /// Hex fs-verity ID of the linked bootable composefs EROFS image, if any.
1404        ///
1405        /// Present when the image was pulled with `bootable` support; bootc and
1406        /// other GC-aware callers use this to keep the derived boot EROFS object
1407        /// alive alongside the primary image.
1408        pub composefs_boot_erofs: Option<String>,
1409    }
1410
1411    impl OciInspectReply {
1412        /// Build an inspect reply from a resolved image, reading its manifest,
1413        /// config and referrers from the repository.
1414        pub fn from_image<ObjectID: FsVerityHashValue>(
1415            repo: &Repository<ObjectID>,
1416            img: &composefs_oci::oci_image::OciImage<ObjectID>,
1417        ) -> anyhow::Result<Self> {
1418            let manifest = String::from_utf8(img.read_manifest_json(repo)?)
1419                .context("manifest is not valid UTF-8")?;
1420            let config = String::from_utf8(img.read_config_json(repo)?)
1421                .context("config is not valid UTF-8")?;
1422            let referrers = composefs_oci::oci_image::list_referrers(repo, img.manifest_digest())?
1423                .iter()
1424                .map(|(digest, _verity)| digest.to_string())
1425                .collect();
1426            Ok(Self {
1427                manifest,
1428                config,
1429                referrers,
1430                composefs_erofs: img.image_ref(repo.erofs_version()).map(|id| id.to_hex()),
1431                composefs_boot_erofs: img
1432                    .boot_image_ref(repo.erofs_version())
1433                    .map(|id| id.to_hex()),
1434            })
1435        }
1436    }
1437
1438    /// Reply carrying the computed composefs image ID for an OCI image.
1439    #[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
1440    pub struct OciComputeIdReply {
1441        /// The hex-encoded composefs image ID.
1442        pub image_id: String,
1443    }
1444
1445    /// A single progress frame emitted by the streaming `Pull` method.
1446    ///
1447    /// varlink has no tagged/data-union type, so a sum-of-events is modelled as a
1448    /// struct with one optional field per event shape: exactly one field is set
1449    /// per frame, and its presence acts as the discriminant. (zlink does support
1450    /// nested struct fields, hence the dedicated [`Started`]/[`Progress`]/etc.
1451    /// payload types rather than a flat bag of always-empty columns.)
1452    ///
1453    /// The stream yields zero or more intermediate frames (with `continues=true`)
1454    /// describing fetch progress, followed by exactly one terminal frame whose
1455    /// [`completed`](PullProgress::completed) field is set (and `continues=false`)
1456    /// carrying the pull result.
1457    #[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
1458    pub struct PullProgress {
1459        /// A new component started downloading.
1460        #[serde(skip_serializing_if = "Option::is_none", default)]
1461        pub started: Option<Started>,
1462        /// Incremental transfer progress for a component.
1463        #[serde(skip_serializing_if = "Option::is_none", default)]
1464        pub progress: Option<Progress>,
1465        /// A component was skipped because it was already present.
1466        #[serde(skip_serializing_if = "Option::is_none", default)]
1467        pub skipped: Option<Skipped>,
1468        /// A component finished downloading.
1469        #[serde(skip_serializing_if = "Option::is_none", default)]
1470        pub done: Option<Done>,
1471        /// A human-readable status message.
1472        #[serde(skip_serializing_if = "Option::is_none", default)]
1473        pub message: Option<String>,
1474        /// The terminal frame carrying the pull result. Its presence marks the
1475        /// end of the stream (the reply also has `continues=false`).
1476        #[serde(skip_serializing_if = "Option::is_none", default)]
1477        pub completed: Option<Completed>,
1478    }
1479
1480    /// Unit of measurement for [`Started`]/[`Progress`] counters.
1481    #[derive(Debug, Clone, Copy, Serialize, Deserialize, zlink::introspect::Type)]
1482    pub enum ProgressUnit {
1483        /// Counters are byte counts.
1484        Bytes,
1485        /// Counters are discrete item counts.
1486        Items,
1487    }
1488
1489    impl From<composefs::progress::ProgressUnit> for ProgressUnit {
1490        fn from(unit: composefs::progress::ProgressUnit) -> Self {
1491            use composefs::progress::ProgressUnit as U;
1492            match unit {
1493                U::Bytes => ProgressUnit::Bytes,
1494                U::Items => ProgressUnit::Items,
1495                // `ProgressUnit` is `#[non_exhaustive]`; default to items.
1496                _ => ProgressUnit::Items,
1497            }
1498        }
1499    }
1500
1501    /// A new component (layer/object) started downloading.
1502    #[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
1503    pub struct Started {
1504        /// Component id (layer/object digest).
1505        pub id: String,
1506        /// Total bytes/items to transfer, if known.
1507        pub total: Option<u64>,
1508        /// Unit of `total` and subsequent [`Progress`] counters.
1509        pub unit: ProgressUnit,
1510    }
1511
1512    /// Incremental transfer progress for a component.
1513    #[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
1514    pub struct Progress {
1515        /// Component id (layer/object digest).
1516        pub id: String,
1517        /// Bytes/items transferred so far.
1518        pub fetched: u64,
1519        /// Total bytes/items to transfer, if known.
1520        pub total: Option<u64>,
1521    }
1522
1523    /// A component was skipped because it was already present.
1524    #[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
1525    pub struct Skipped {
1526        /// Component id (layer/object digest).
1527        pub id: String,
1528    }
1529
1530    /// A component finished downloading.
1531    #[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
1532    pub struct Done {
1533        /// Component id (layer/object digest).
1534        pub id: String,
1535        /// Total bytes/items actually transferred.
1536        pub transferred: u64,
1537    }
1538
1539    /// The result of a completed pull.
1540    #[derive(Debug, Clone, Serialize, Deserialize, zlink::introspect::Type)]
1541    pub struct Completed {
1542        /// Manifest digest of the pulled image.
1543        pub manifest_digest: String,
1544        /// Config digest of the pulled image.
1545        pub config_digest: String,
1546        /// Hex fs-verity of the manifest splitstream.
1547        pub manifest_verity: String,
1548        /// Hex fs-verity of the config splitstream.
1549        pub config_verity: String,
1550        /// `Display` rendering of the import stats.
1551        pub stats: String,
1552        /// Hex fs-verity of the generated boot EROFS image, when a bootable pull
1553        /// was requested; `None` otherwise.
1554        pub boot_image: Option<String>,
1555    }
1556
1557    impl PullProgress {
1558        /// An empty frame with every variant cleared. Construct a frame by
1559        /// setting exactly one field.
1560        fn empty() -> Self {
1561            PullProgress {
1562                started: None,
1563                progress: None,
1564                skipped: None,
1565                done: None,
1566                message: None,
1567                completed: None,
1568            }
1569        }
1570    }
1571
1572    impl From<composefs::progress::ProgressEvent> for PullProgress {
1573        /// Map a library [`composefs::progress::ProgressEvent`] to a wire frame,
1574        /// consuming the event so owned fields (e.g. a `Message` string) move
1575        /// rather than clone.
1576        fn from(event: composefs::progress::ProgressEvent) -> Self {
1577            use composefs::progress::ProgressEvent;
1578
1579            let mut p = PullProgress::empty();
1580            match event {
1581                ProgressEvent::Started { id, total, unit } => {
1582                    p.started = Some(Started {
1583                        id: id.into_inner(),
1584                        total,
1585                        unit: unit.into(),
1586                    });
1587                }
1588                ProgressEvent::Progress { id, fetched, total } => {
1589                    p.progress = Some(Progress {
1590                        id: id.into_inner(),
1591                        fetched,
1592                        total,
1593                    });
1594                }
1595                ProgressEvent::Skipped { id } => {
1596                    p.skipped = Some(Skipped {
1597                        id: id.into_inner(),
1598                    });
1599                }
1600                ProgressEvent::Done { id, transferred } => {
1601                    p.done = Some(Done {
1602                        id: id.into_inner(),
1603                        transferred,
1604                    });
1605                }
1606                ProgressEvent::Message(s) => {
1607                    p.message = Some(s);
1608                }
1609                // `ProgressEvent` is `#[non_exhaustive]`; map unknown variants to a
1610                // message frame so future additions remain forward-compatible.
1611                other => {
1612                    p.message = Some(format!("{other:?}"));
1613                }
1614            }
1615            p
1616        }
1617    }
1618
1619    /// A [`composefs::progress::ProgressReporter`] that forwards each event as a
1620    /// [`PullProgress`] frame over an unbounded channel to the streaming method.
1621    struct ChannelReporter {
1622        tx: tokio::sync::mpsc::UnboundedSender<PullProgress>,
1623    }
1624
1625    impl std::fmt::Debug for ChannelReporter {
1626        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1627            f.debug_struct("ChannelReporter").finish_non_exhaustive()
1628        }
1629    }
1630
1631    impl composefs::progress::ProgressReporter for ChannelReporter {
1632        fn report(&self, event: composefs::progress::ProgressEvent) {
1633            // The receiver may have been dropped (client cancelled the stream);
1634            // dropping the event is the right behaviour in that case.
1635            let _ = self.tx.send(PullProgress::from(event));
1636        }
1637    }
1638
1639    /// Aborts the wrapped pull task when dropped.
1640    ///
1641    /// If the client disconnects before the stream completes, dropping the
1642    /// returned stream drops this guard, which aborts the in-flight pull instead
1643    /// of leaking the task.
1644    struct AbortOnDrop {
1645        handle: Option<tokio::task::JoinHandle<std::result::Result<(), OciError>>>,
1646    }
1647
1648    impl AbortOnDrop {
1649        /// Take the join handle out, disarming the abort-on-drop behaviour.
1650        fn take(&mut self) -> Option<tokio::task::JoinHandle<std::result::Result<(), OciError>>> {
1651            self.handle.take()
1652        }
1653    }
1654
1655    impl std::fmt::Debug for AbortOnDrop {
1656        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1657            f.debug_struct("AbortOnDrop").finish_non_exhaustive()
1658        }
1659    }
1660
1661    impl Drop for AbortOnDrop {
1662        fn drop(&mut self) {
1663            if let Some(handle) = &self.handle {
1664                handle.abort();
1665            }
1666        }
1667    }
1668
1669    /// Parse the wire `local_fetch` string into a [`composefs_oci::LocalFetchOpt`].
1670    ///
1671    /// Unknown values fall back to [`LocalFetchOpt::Disabled`](composefs_oci::LocalFetchOpt::Disabled).
1672    pub(crate) fn parse_local_fetch(value: &str) -> composefs_oci::LocalFetchOpt {
1673        use composefs_oci::LocalFetchOpt;
1674        match value {
1675            "auto" | "if-possible" => LocalFetchOpt::IfPossible,
1676            "zerocopy" | "zero-copy" => LocalFetchOpt::ZeroCopy,
1677            _ => LocalFetchOpt::Disabled,
1678        }
1679    }
1680
1681    /// Run a streaming pull against an already-opened repository, returning a
1682    /// boxed stream of [`PullProgress`] frames.
1683    ///
1684    /// The return type is a concrete boxed trait object rather than `impl Stream`
1685    /// so that both monomorphisations (Sha256/Sha512) of this generic function
1686    /// produce the *same* type — letting the non-generic service `pull` method
1687    /// unify the two match arms under a single `impl Stream` return.
1688    ///
1689    /// When `more` is `false` the client asked for a single reply, so no progress
1690    /// reporter is attached and the stream yields only the terminal `completed`
1691    /// frame (or an error).
1692    #[allow(clippy::too_many_arguments)]
1693    pub(crate) fn pull_stream<ObjectID: FsVerityHashValue>(
1694        repo: Arc<Repository<ObjectID>>,
1695        image: String,
1696        name: Option<String>,
1697        local_fetch: composefs_oci::LocalFetchOpt,
1698        storage_root: Option<PathBuf>,
1699        bootable: bool,
1700        more: bool,
1701    ) -> std::pin::Pin<
1702        Box<
1703            dyn zlink::futures_util::Stream<
1704                    Item = std::result::Result<zlink::Reply<PullProgress>, OciError>,
1705                > + Send,
1706        >,
1707    > {
1708        use zlink::futures_util::stream;
1709
1710        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<PullProgress>();
1711        // Only attach a progress reporter when the client wants streaming frames.
1712        let reporter: Option<composefs::progress::SharedReporter> = if more {
1713            Some(std::sync::Arc::new(ChannelReporter { tx: tx.clone() }))
1714        } else {
1715            None
1716        };
1717
1718        // The task owns everything it needs ('static): it runs the pull, builds the
1719        // terminal `completed` frame from the result (plus optional boot image),
1720        // and sends it through the channel before the sender drops.  Pull errors
1721        // are carried out via the task's `JoinHandle` return value.
1722        let task_tx = tx.clone();
1723        let handle = tokio::spawn(async move {
1724            let opts = composefs_oci::PullOptions {
1725                local_fetch,
1726                storage_root: storage_root.as_deref(),
1727                progress: reporter,
1728                ..Default::default()
1729            };
1730            let result = composefs_oci::pull(&repo, &image, name.as_deref(), opts)
1731                .await
1732                .map_err(|e| OciError::InternalError {
1733                    message: format!("{e:#}"),
1734                })?;
1735
1736            let boot_image = if bootable {
1737                let id = composefs_oci::generate_boot_image(&repo, &result.manifest_digest)
1738                    .map_err(|e| OciError::InternalError {
1739                        message: format!("{e:#}"),
1740                    })?;
1741                Some(id.to_hex())
1742            } else {
1743                None
1744            };
1745
1746            let completed = PullProgress {
1747                completed: Some(Completed {
1748                    manifest_digest: result.manifest_digest.to_string(),
1749                    config_digest: result.config_digest.to_string(),
1750                    manifest_verity: result.manifest_verity.to_hex(),
1751                    config_verity: result.config_verity.to_hex(),
1752                    stats: result.stats.to_string(),
1753                    boot_image,
1754                }),
1755                ..PullProgress::empty()
1756            };
1757            // If the receiver is gone the client cancelled; that's fine.
1758            let _ = task_tx.send(completed);
1759            Ok(())
1760        });
1761
1762        // Drop our extra sender handle so the channel closes once the task's clone
1763        // is dropped (i.e. when the task finishes).
1764        drop(tx);
1765
1766        struct State {
1767            rx: tokio::sync::mpsc::UnboundedReceiver<PullProgress>,
1768            handle: Option<AbortOnDrop>,
1769            done: bool,
1770        }
1771
1772        let state = State {
1773            rx,
1774            handle: Some(AbortOnDrop {
1775                handle: Some(handle),
1776            }),
1777            done: false,
1778        };
1779
1780        let stream = stream::unfold(state, |mut state| async move {
1781            if state.done {
1782                return None;
1783            }
1784            match state.rx.recv().await {
1785                Some(frame) => {
1786                    let is_completed = frame.completed.is_some();
1787                    if is_completed {
1788                        state.done = true;
1789                        // Disarm the abort guard: the task has produced its result
1790                        // frame and is finished, so there is nothing left to abort.
1791                        if let Some(guard) = state.handle.as_mut() {
1792                            let _ = guard.take();
1793                        }
1794                    }
1795                    let reply = zlink::Reply::new(Some(frame)).set_continues(Some(!is_completed));
1796                    Some((Ok(reply), state))
1797                }
1798                None => {
1799                    // Channel closed without a terminal frame: the pull failed (or
1800                    // the task panicked). Await the join handle to recover the error.
1801                    state.done = true;
1802                    // Take the join handle out (disarming the abort guard, since
1803                    // the task has already finished) and recover the pull error.
1804                    let join = state.handle.as_mut().and_then(AbortOnDrop::take);
1805                    let err = match join {
1806                        Some(join) => match join.await {
1807                            Ok(Ok(())) => OciError::InternalError {
1808                                message: "pull completed without a result frame".to_string(),
1809                            },
1810                            Ok(Err(e)) => e,
1811                            Err(_) => OciError::InternalError {
1812                                message: "pull task panicked".to_string(),
1813                            },
1814                        },
1815                        None => OciError::InternalError {
1816                            message: "pull task panicked".to_string(),
1817                        },
1818                    };
1819                    Some((Err(err), state))
1820                }
1821            }
1822        });
1823
1824        Box::pin(stream)
1825    }
1826
1827    /// Errors that may be returned by the `org.composefs.Oci` interface.
1828    #[derive(Debug, zlink::ReplyError, zlink::introspect::ReplyError)]
1829    #[zlink(interface = "org.composefs.Oci")]
1830    pub enum OciError {
1831        /// The repository could not be found or opened at the configured path.
1832        RepoNotFound {
1833            /// Description of the failure.
1834            message: String,
1835        },
1836        /// The given handle does not refer to an open repository.
1837        InvalidHandle {
1838            /// The handle that was not found.
1839            handle: u64,
1840        },
1841        /// The named OCI image/reference does not exist.
1842        NoSuchImage {
1843            /// The image reference that was not found.
1844            image: String,
1845        },
1846        /// An unexpected internal error occurred while servicing the request.
1847        InternalError {
1848            /// Description of the failure.
1849            message: String,
1850        },
1851    }
1852}
1853
1854/// Typed Rust client bindings (the native-API mirror of the on-the-wire
1855/// varlink interfaces). These let a Rust consumer — the integration tests
1856/// today, and a future cfsctl-as-client — call the service with generated,
1857/// type-checked proxy methods, in addition to the wire protocol exercised by
1858/// external clients such as `varlinkctl`.
1859pub mod proxy {
1860    #![allow(missing_docs)]
1861
1862    #[cfg(feature = "oci")]
1863    use super::oci::{
1864        ListImagesReply, OciComputeIdReply, OciError, OciFsckReply, OciInspectReply, PullProgress,
1865    };
1866    use super::{
1867        FsckReply, GcReply, ImageObjectsReply, InitRepositoryReply, OpenRepositoryReply,
1868        RepositoryError,
1869    };
1870    #[cfg(feature = "oci")]
1871    use zlink::futures_util::Stream;
1872
1873    /// Typed client for the `org.composefs.Repository` interface.
1874    #[zlink::proxy(interface = "org.composefs.Repository")]
1875    pub trait RepositoryProxy {
1876        /// Initialize a new repository (or verify an existing one).
1877        async fn init_repository(
1878            &mut self,
1879            path: &str,
1880            algorithm: Option<&str>,
1881            insecure: Option<bool>,
1882        ) -> zlink::Result<Result<InitRepositoryReply, RepositoryError>>;
1883
1884        /// Open and validate a repository, returning an opaque handle.
1885        async fn open_repository(
1886            &mut self,
1887            path: Option<&str>,
1888            user: Option<bool>,
1889            system: Option<bool>,
1890        ) -> zlink::Result<Result<OpenRepositoryReply, RepositoryError>>;
1891
1892        /// Close a previously opened repository handle.
1893        async fn close_repository(
1894            &mut self,
1895            handle: u64,
1896        ) -> zlink::Result<Result<(), RepositoryError>>;
1897
1898        /// Check repository integrity.
1899        async fn fsck(
1900            &mut self,
1901            handle: u64,
1902            metadata_only: Option<bool>,
1903        ) -> zlink::Result<Result<FsckReply, RepositoryError>>;
1904
1905        /// Run garbage collection (or a dry run).
1906        async fn gc(
1907            &mut self,
1908            handle: u64,
1909            dry_run: bool,
1910            roots: Vec<String>,
1911        ) -> zlink::Result<Result<GcReply, RepositoryError>>;
1912
1913        /// List the objects referenced by a single image.
1914        async fn image_objects(
1915            &mut self,
1916            handle: u64,
1917            name: &str,
1918        ) -> zlink::Result<Result<ImageObjectsReply, RepositoryError>>;
1919    }
1920
1921    /// Typed client for the `org.composefs.Oci` interface.
1922    #[cfg(feature = "oci")]
1923    #[zlink::proxy(interface = "org.composefs.Oci")]
1924    pub trait OciProxy {
1925        /// List tagged OCI images.
1926        async fn list_images(
1927            &mut self,
1928            handle: u64,
1929            filter: Option<&str>,
1930        ) -> zlink::Result<Result<ListImagesReply, OciError>>;
1931
1932        /// Run an OCI-aware consistency check (wire method `Check`).
1933        #[zlink(rename = "Check")]
1934        async fn oci_fsck(
1935            &mut self,
1936            handle: u64,
1937            image: Option<&str>,
1938        ) -> zlink::Result<Result<OciFsckReply, OciError>>;
1939
1940        /// Inspect a single OCI image.
1941        async fn inspect(
1942            &mut self,
1943            handle: u64,
1944            image: &str,
1945        ) -> zlink::Result<Result<OciInspectReply, OciError>>;
1946
1947        /// Tag a manifest digest with a name.
1948        async fn tag(
1949            &mut self,
1950            handle: u64,
1951            manifest_digest: &str,
1952            name: &str,
1953        ) -> zlink::Result<Result<(), OciError>>;
1954
1955        /// Remove a tag.
1956        async fn untag(&mut self, handle: u64, name: &str) -> zlink::Result<Result<(), OciError>>;
1957
1958        /// Compute the composefs image ID for an OCI image.
1959        async fn compute_id(
1960            &mut self,
1961            handle: u64,
1962            image: &str,
1963            verity: Option<&str>,
1964            bootable: bool,
1965        ) -> zlink::Result<Result<OciComputeIdReply, OciError>>;
1966
1967        /// Pull an OCI image, streaming progress frames.
1968        #[zlink(more, rename = "Pull")]
1969        async fn pull(
1970            &mut self,
1971            handle: u64,
1972            image: &str,
1973            name: Option<&str>,
1974            local_fetch: &str,
1975            storage_root: Option<&str>,
1976            bootable: bool,
1977        ) -> zlink::Result<impl Stream<Item = zlink::Result<Result<PullProgress, OciError>>>>;
1978    }
1979}
1980
1981#[cfg(feature = "oci")]
1982pub(crate) use oci::*;