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