@@ -273,7 +273,7 @@ impl From<ErofsVersion> for composefs::erofs::format::FormatVersion {
273273/// start with `@`.
274274#[ cfg( feature = "oci" ) ]
275275#[ derive( Debug , Clone ) ]
276- pub ( crate ) enum OciReference {
276+ pub enum OciReference {
277277 /// A content-addressable digest such as `sha256:abcdef…`.
278278 Digest ( composefs_oci:: OciDigest ) ,
279279 /// A named ref resolved through the repository's ref tree, typically
@@ -387,6 +387,31 @@ enum OciCommand {
387387 #[ arg( long, value_enum, default_value_t = LocalFetchCli :: Disabled ) ]
388388 local_fetch : LocalFetchCli ,
389389 } ,
390+ /// Copy an OCI image (and its layers) from another composefs repository
391+ /// into this repository.
392+ ///
393+ /// The destination repository is selected by the global `--repo`/`--user`/
394+ /// `--system` flags. The source is `--from`.
395+ ///
396+ /// Pass `--zerocopy` to attempt reflink (then hardlink) instead of copying
397+ /// object data. This requires both repositories to be on the same
398+ /// filesystem, to use the same hash algorithm, and the caller to have
399+ /// `CAP_DAC_READ_SEARCH` (i.e. root).
400+ /// Without `--zerocopy`, objects are always copied, which is safe on any
401+ /// filesystem and across repositories using different hash algorithms.
402+ Copy {
403+ /// Image to copy (tag name or `@digest`).
404+ image : OciReference ,
405+ /// Path to the source composefs repository.
406+ #[ clap( long) ]
407+ from : PathBuf ,
408+ /// Tag to assign to the image in the destination repository.
409+ #[ clap( long) ]
410+ name : Option < String > ,
411+ /// Use reflink/hardlink zero-copy transfer (requires same filesystem, same hash algorithm, and root).
412+ #[ clap( long) ]
413+ zerocopy : bool ,
414+ } ,
390415 /// List all tagged OCI images in the repository
391416 #[ clap( name = "images" ) ]
392417 ListImages {
@@ -1305,6 +1330,111 @@ where
13051330 Ok ( repo)
13061331}
13071332
1333+ /// Copy an OCI image (and all its layers) from one repository to another using varlink connections.
1334+ #[ cfg( feature = "oci" ) ]
1335+ pub async fn copy_image (
1336+ conn_src : & mut zlink:: unix:: Connection ,
1337+ conn_dest : & mut zlink:: unix:: Connection ,
1338+ handle_src : u64 ,
1339+ handle_dest : u64 ,
1340+ image : & OciReference ,
1341+ name : Option < & str > ,
1342+ zerocopy : bool ,
1343+ ) -> Result < crate :: varlink:: layer_sync:: FinalizeImageReply > {
1344+ use crate :: varlink:: layer_sync:: LayerRef ;
1345+ use crate :: varlink:: oci:: OciError ;
1346+ use crate :: varlink:: proxy:: { GetLayerParams , OciProxy } ;
1347+ use anyhow:: ensure;
1348+ use zlink:: futures_util:: StreamExt as _;
1349+
1350+ let image_str = image. to_string ( ) ;
1351+ let inspect = conn_src
1352+ . inspect ( handle_src, & image_str)
1353+ . await
1354+ . context ( "zlink transport error calling Inspect" ) ?
1355+ . map_err ( |e : OciError | anyhow:: anyhow!( "Inspect failed: {e:?}" ) ) ?;
1356+
1357+ ensure ! (
1358+ !inspect. manifest. is_empty( ) ,
1359+ "inspect returned empty manifest"
1360+ ) ;
1361+ ensure ! ( !inspect. config. is_empty( ) , "inspect returned empty config" ) ;
1362+
1363+ // Extract ordered layer identifiers via the shared helper that handles
1364+ // both container images (rootfs.diff_ids) and OCI artifacts (manifest
1365+ // layer digests).
1366+ let diff_ids_ordered = composefs_oci:: extract_layer_ids ( & inspect. manifest , & inspect. config )
1367+ . context ( "extracting layer identifiers" ) ?;
1368+
1369+ let mut layer_refs: Vec < LayerRef > = Vec :: with_capacity ( diff_ids_ordered. len ( ) ) ;
1370+
1371+ for diff_id in & diff_ids_ordered {
1372+ let has = conn_dest
1373+ . has_layer ( handle_dest, diff_id)
1374+ . await
1375+ . context ( "zlink transport error calling HasLayer" ) ?
1376+ . map_err ( |e : OciError | anyhow:: anyhow!( "HasLayer failed: {e:?}" ) ) ?;
1377+
1378+ let layer_verity = if has. present {
1379+ has. layer_verity
1380+ . context ( "HasLayer returned present=true but no layer_verity" ) ?
1381+ } else {
1382+ let get_params = GetLayerParams {
1383+ diff_id : Some ( diff_id. to_string ( ) ) ,
1384+ storage : None ,
1385+ } ;
1386+ let mut get_stream = std:: pin:: pin!(
1387+ conn_src
1388+ . get_layer( handle_src, get_params)
1389+ . await
1390+ . context( "zlink transport error calling GetLayer" ) ?
1391+ ) ;
1392+ let mut all_fds: Vec < std:: os:: fd:: OwnedFd > = Vec :: new ( ) ;
1393+ let mut get_reply = None ;
1394+ while let Some ( item) = get_stream. next ( ) . await {
1395+ let ( result, fds) = item. context ( "GetLayer stream frame error" ) ?;
1396+ let reply =
1397+ result. map_err ( |e : OciError | anyhow:: anyhow!( "GetLayer failed: {e:?}" ) ) ?;
1398+ get_reply = Some ( reply) ;
1399+ all_fds. extend ( fds) ;
1400+ }
1401+ let get_reply = get_reply. context ( "GetLayer returned empty stream" ) ?;
1402+ let dir_count = get_reply. dir_count as usize ;
1403+
1404+ let pipe_and_dirfds_len = 1 + dir_count;
1405+ let lifetime_fds = all_fds. split_off ( pipe_and_dirfds_len) ;
1406+
1407+ let put_reply = conn_dest
1408+ . put_layer ( handle_dest, diff_id, zerocopy, all_fds)
1409+ . await
1410+ . context ( "zlink transport error calling PutLayer" ) ?
1411+ . map_err ( |e : OciError | anyhow:: anyhow!( "PutLayer failed: {e:?}" ) ) ?;
1412+ drop ( lifetime_fds) ;
1413+
1414+ put_reply. layer_verity
1415+ } ;
1416+
1417+ layer_refs. push ( LayerRef {
1418+ diff_id : diff_id. clone ( ) ,
1419+ layer_verity,
1420+ } ) ;
1421+ }
1422+
1423+ let finalize = conn_dest
1424+ . finalize_image (
1425+ handle_dest,
1426+ & inspect. manifest ,
1427+ & inspect. config ,
1428+ layer_refs,
1429+ name,
1430+ )
1431+ . await
1432+ . context ( "zlink transport error calling FinalizeImage" ) ?
1433+ . map_err ( |e : OciError | anyhow:: anyhow!( "FinalizeImage failed: {e:?}" ) ) ?;
1434+
1435+ Ok ( finalize)
1436+ }
1437+
13081438/// Resolve an [`OciReference`] to an [`OciImage`].
13091439#[ cfg( feature = "oci" ) ]
13101440pub ( crate ) fn resolve_oci_image < ObjectID : FsVerityHashValue > (
@@ -1508,6 +1638,8 @@ where
15081638 ObjectID : FsVerityHashValue ,
15091639{
15101640 let repo = Arc :: new ( repo) ;
1641+ #[ cfg( feature = "oci" ) ]
1642+ let dest_path = resolve_repo_path ( & args) ?;
15111643 match args. cmd {
15121644 Command :: Init { .. } => {
15131645 // Handled in run_app before we get here
@@ -1607,6 +1739,81 @@ where
16071739 println ! ( "Boot image: {}" , image_verity. to_hex( ) ) ;
16081740 }
16091741 }
1742+ OciCommand :: Copy {
1743+ ref image,
1744+ ref from,
1745+ ref name,
1746+ zerocopy,
1747+ } => {
1748+ use crate :: varlink:: proxy:: RepositoryProxy ;
1749+
1750+ let src_hash = resolve_hash_type ( from, args. hash , !args. no_upgrade )
1751+ . with_context ( || format ! ( "opening source repository {}" , from. display( ) ) ) ?;
1752+ let dest_hash = resolve_hash_type ( & dest_path, args. hash , !args. no_upgrade )
1753+ . with_context ( || {
1754+ format ! ( "opening destination repository {}" , dest_path. display( ) )
1755+ } ) ?;
1756+
1757+ if zerocopy && src_hash != dest_hash {
1758+ anyhow:: bail!(
1759+ "--zerocopy requires matching hash algorithms; \
1760+ source uses {src_hash:?} but destination uses {dest_hash:?}"
1761+ ) ;
1762+ }
1763+
1764+ let from_str = from. to_str ( ) . context ( "source path is not valid UTF-8" ) ?;
1765+ let dest_str = dest_path
1766+ . to_str ( )
1767+ . context ( "destination path is not valid UTF-8" ) ?;
1768+
1769+ let service_src = crate :: varlink:: CfsctlService :: new ( ) ;
1770+ let service_dest = crate :: varlink:: CfsctlService :: new ( ) ;
1771+
1772+ let ( mut conn_src, _srv_src) = crate :: varlink:: spawn_in_process ( service_src)
1773+ . context ( "spawning source in-process service" ) ?;
1774+ let ( mut conn_dest, _srv_dest) = crate :: varlink:: spawn_in_process ( service_dest)
1775+ . context ( "spawning destination in-process service" ) ?;
1776+
1777+ let handle_src = conn_src
1778+ . open_repository ( Some ( from_str) , None , None )
1779+ . await
1780+ . context ( "zlink transport error calling OpenRepository on source" ) ?
1781+ . map_err ( |e| anyhow:: anyhow!( "OpenRepository failed on source: {e:?}" ) ) ?
1782+ . handle ;
1783+
1784+ let handle_dest = conn_dest
1785+ . open_repository ( Some ( dest_str) , None , None )
1786+ . await
1787+ . context ( "zlink transport error calling OpenRepository on destination" ) ?
1788+ . map_err ( |e| anyhow:: anyhow!( "OpenRepository failed on destination: {e:?}" ) ) ?
1789+ . handle ;
1790+
1791+ let finalize_reply = copy_image (
1792+ & mut conn_src,
1793+ & mut conn_dest,
1794+ handle_src,
1795+ handle_dest,
1796+ image,
1797+ name. as_deref ( ) ,
1798+ zerocopy,
1799+ )
1800+ . await ?;
1801+
1802+ let tag_info = if let Some ( n) = name {
1803+ format ! ( ", tagged as {n}" )
1804+ } else {
1805+ String :: new ( )
1806+ } ;
1807+ println ! (
1808+ "Copied image {image} from {} to destination repo{}" ,
1809+ from. display( ) ,
1810+ tag_info
1811+ ) ;
1812+ println ! ( "Manifest digest: {}" , finalize_reply. manifest_digest) ;
1813+ println ! ( "Manifest verity: {}" , finalize_reply. manifest_verity) ;
1814+ println ! ( "Config digest: {}" , finalize_reply. config_digest) ;
1815+ println ! ( "Config verity: {}" , finalize_reply. config_verity) ;
1816+ }
16101817 OciCommand :: ListImages { json } => {
16111818 let images = composefs_oci:: oci_image:: list_images ( & repo) ?;
16121819
0 commit comments