@@ -215,7 +215,7 @@ integration_test!(test_oci_images_json_empty_repo);
215215fn create_oci_layout ( parent : & std:: path:: Path ) -> Result < std:: path:: PathBuf > {
216216 use cap_std_ext:: cap_std;
217217 use ocidir:: oci_spec:: image:: {
218- ImageConfigurationBuilder , Platform , PlatformBuilder , RootFsBuilder ,
218+ ConfigBuilder , ImageConfigurationBuilder , Platform , PlatformBuilder , RootFsBuilder ,
219219 } ;
220220
221221 let oci_dir = parent. join ( "oci-image" ) ;
@@ -227,6 +227,9 @@ fn create_oci_layout(parent: &std::path::Path) -> Result<std::path::PathBuf> {
227227 // Create a new empty manifest
228228 let mut manifest = ocidir. new_empty_manifest ( ) ?. build ( ) ?;
229229
230+ // Create runtime config (required for seal operation)
231+ let runtime_config = ConfigBuilder :: default ( ) . build ( ) ?;
232+
230233 // Create config with architecture and OS
231234 let rootfs = RootFsBuilder :: default ( )
232235 . typ ( "layers" )
@@ -236,6 +239,7 @@ fn create_oci_layout(parent: &std::path::Path) -> Result<std::path::PathBuf> {
236239 . architecture ( "amd64" )
237240 . os ( "linux" )
238241 . rootfs ( rootfs)
242+ . config ( runtime_config)
239243 . build ( ) ?;
240244
241245 // Create a simple layer with a usr/ directory and one file
@@ -1397,3 +1401,290 @@ fn test_init_reset_metadata_changes_algorithm() -> Result<()> {
13971401 Ok ( ( ) )
13981402}
13991403integration_test ! ( test_init_reset_metadata_changes_algorithm) ;
1404+
1405+ /// Test tagging and untagging OCI images.
1406+ ///
1407+ /// Verifies that:
1408+ /// - An image can be tagged with multiple names
1409+ /// - Tags appear in `oci images` output
1410+ /// - Tags can be removed with `oci untag`
1411+ /// - Untagging one name doesn't affect other tags
1412+ fn test_oci_tag_and_untag ( ) -> Result < ( ) > {
1413+ let sh = Shell :: new ( ) ?;
1414+ let cfsctl = cfsctl ( ) ?;
1415+ let repo_dir = init_insecure_repo ( & sh, & cfsctl) ?;
1416+ let repo = repo_dir. path ( ) ;
1417+ let fixture_dir = tempfile:: tempdir ( ) ?;
1418+ let oci_layout = create_oci_layout ( fixture_dir. path ( ) ) ?;
1419+
1420+ // Pull and tag with first name
1421+ let pull_output = cmd ! (
1422+ sh,
1423+ "{cfsctl} --insecure --repo {repo} oci pull oci:{oci_layout} myimage:v1"
1424+ )
1425+ . read ( ) ?;
1426+
1427+ // Extract manifest digest from pull output (e.g., "manifest sha256:abc...")
1428+ let manifest_digest = pull_output
1429+ . lines ( )
1430+ . find ( |line| line. contains ( "manifest sha256:" ) )
1431+ . and_then ( |line| line. split_whitespace ( ) . find ( |s| s. starts_with ( "sha256:" ) ) )
1432+ . expect ( "expected manifest digest in pull output" ) ;
1433+
1434+ // Add a second tag using the manifest digest
1435+ cmd ! (
1436+ sh,
1437+ "{cfsctl} --insecure --repo {repo} oci tag {manifest_digest} myimage:latest"
1438+ )
1439+ . read ( ) ?;
1440+
1441+ // Both tags should appear in list
1442+ let list_output = cmd ! ( sh, "{cfsctl} --insecure --repo {repo} oci images --json" ) . read ( ) ?;
1443+ let images: serde_json:: Value = serde_json:: from_str ( & list_output) ?;
1444+ let names: Vec < & str > = images
1445+ . as_array ( )
1446+ . unwrap ( )
1447+ . iter ( )
1448+ . map ( |img| img[ "name" ] . as_str ( ) . unwrap ( ) )
1449+ . collect ( ) ;
1450+ assert ! ( names. contains( & "myimage:v1" ) , "expected myimage:v1 in list" ) ;
1451+ assert ! (
1452+ names. contains( & "myimage:latest" ) ,
1453+ "expected myimage:latest in list"
1454+ ) ;
1455+
1456+ // Remove one tag
1457+ cmd ! ( sh, "{cfsctl} --insecure --repo {repo} oci untag myimage:v1" ) . read ( ) ?;
1458+
1459+ // Only the remaining tag should appear
1460+ let list_output = cmd ! ( sh, "{cfsctl} --insecure --repo {repo} oci images --json" ) . read ( ) ?;
1461+ let images: serde_json:: Value = serde_json:: from_str ( & list_output) ?;
1462+ let names: Vec < & str > = images
1463+ . as_array ( )
1464+ . unwrap ( )
1465+ . iter ( )
1466+ . map ( |img| img[ "name" ] . as_str ( ) . unwrap ( ) )
1467+ . collect ( ) ;
1468+ assert ! (
1469+ !names. contains( & "myimage:v1" ) ,
1470+ "myimage:v1 should be removed"
1471+ ) ;
1472+ assert ! (
1473+ names. contains( & "myimage:latest" ) ,
1474+ "myimage:latest should still exist"
1475+ ) ;
1476+
1477+ Ok ( ( ) )
1478+ }
1479+ integration_test ! ( test_oci_tag_and_untag) ;
1480+
1481+ /// Test that GC removes untagged OCI images.
1482+ ///
1483+ /// Verifies that:
1484+ /// - After untagging all references, GC collects the image
1485+ /// - Objects are actually removed from the repository
1486+ fn test_oci_gc_removes_untagged ( ) -> Result < ( ) > {
1487+ let sh = Shell :: new ( ) ?;
1488+ let cfsctl = cfsctl ( ) ?;
1489+ let repo_dir = init_insecure_repo ( & sh, & cfsctl) ?;
1490+ let repo = repo_dir. path ( ) ;
1491+ let fixture_dir = tempfile:: tempdir ( ) ?;
1492+ let oci_layout = create_oci_layout ( fixture_dir. path ( ) ) ?;
1493+
1494+ // Pull an image
1495+ cmd ! (
1496+ sh,
1497+ "{cfsctl} --insecure --repo {repo} oci pull oci:{oci_layout} test-image"
1498+ )
1499+ . read ( ) ?;
1500+
1501+ // Verify it exists
1502+ let list_before = cmd ! ( sh, "{cfsctl} --insecure --repo {repo} oci images --json" ) . read ( ) ?;
1503+ let images_before: Vec < serde_json:: Value > = serde_json:: from_str ( & list_before) ?;
1504+ assert_eq ! ( images_before. len( ) , 1 , "expected 1 image before untag" ) ;
1505+
1506+ // Untag it
1507+ cmd ! ( sh, "{cfsctl} --insecure --repo {repo} oci untag test-image" ) . read ( ) ?;
1508+
1509+ // Run GC
1510+ let gc_output = cmd ! ( sh, "{cfsctl} --insecure --repo {repo} gc" ) . read ( ) ?;
1511+ assert ! (
1512+ gc_output. contains( "removed" ) ,
1513+ "expected GC to report removed objects: {gc_output}"
1514+ ) ;
1515+
1516+ // Verify image is gone
1517+ let list_after = cmd ! ( sh, "{cfsctl} --insecure --repo {repo} oci images --json" ) . read ( ) ?;
1518+ let images_after: Vec < serde_json:: Value > = serde_json:: from_str ( & list_after) ?;
1519+ assert ! (
1520+ images_after. is_empty( ) ,
1521+ "expected no images after GC, got: {:?}" ,
1522+ images_after
1523+ ) ;
1524+
1525+ // Verify objects were actually removed (streams dir should be mostly empty)
1526+ let streams_dir = repo. join ( "streams" ) ;
1527+ let stream_count = if streams_dir. exists ( ) {
1528+ std:: fs:: read_dir ( & streams_dir) ?
1529+ . filter ( |e| e. as_ref ( ) . map ( |e| e. file_name ( ) != "refs" ) . unwrap_or ( false ) )
1530+ . count ( )
1531+ } else {
1532+ 0
1533+ } ;
1534+ assert_eq ! (
1535+ stream_count, 0 ,
1536+ "expected no non-ref streams after GC, got {}" ,
1537+ stream_count
1538+ ) ;
1539+
1540+ Ok ( ( ) )
1541+ }
1542+ integration_test ! ( test_oci_gc_removes_untagged) ;
1543+
1544+ /// Test layer tar roundtrip: import a layer, extract as tar, verify integrity.
1545+ ///
1546+ /// This verifies that the splitstream storage correctly preserves tar content
1547+ /// by comparing the original tar with the reconstructed one.
1548+ fn test_layer_tar_roundtrip ( ) -> Result < ( ) > {
1549+ use std:: io:: Read ;
1550+
1551+ let sh = Shell :: new ( ) ?;
1552+ let cfsctl = cfsctl ( ) ?;
1553+ let repo_dir = init_insecure_repo ( & sh, & cfsctl) ?;
1554+ let repo = repo_dir. path ( ) ;
1555+ let fixture_dir = tempfile:: tempdir ( ) ?;
1556+ let oci_layout = create_oci_layout ( fixture_dir. path ( ) ) ?;
1557+
1558+ // Pull the image
1559+ cmd ! (
1560+ sh,
1561+ "{cfsctl} --insecure --repo {repo} oci pull oci:{oci_layout} test-image"
1562+ )
1563+ . read ( ) ?;
1564+
1565+ // Get the layer diff_id
1566+ let config_output = cmd ! (
1567+ sh,
1568+ "{cfsctl} --insecure --repo {repo} oci inspect test-image --config"
1569+ )
1570+ . read ( ) ?;
1571+ let config: serde_json:: Value = serde_json:: from_str ( & config_output) ?;
1572+ let layer_id = config[ "rootfs" ] [ "diff_ids" ] [ 0 ]
1573+ . as_str ( )
1574+ . expect ( "expected layer diff_id" ) ;
1575+
1576+ // Extract the layer as tar
1577+ let tar_output = cmd ! ( sh, "{cfsctl} --insecure --repo {repo} oci layer {layer_id}" ) . output ( ) ?;
1578+ assert ! ( tar_output. status. success( ) , "layer extraction failed" ) ;
1579+
1580+ // Parse the tar and collect file entries
1581+ let mut archive = tar:: Archive :: new ( tar_output. stdout . as_slice ( ) ) ;
1582+ let mut entries: Vec < ( String , Vec < u8 > ) > = Vec :: new ( ) ;
1583+
1584+ for entry in archive. entries ( ) ? {
1585+ let mut entry = entry?;
1586+ let path = entry. path ( ) ?. to_string_lossy ( ) . to_string ( ) ;
1587+ let mut content = Vec :: new ( ) ;
1588+ entry. read_to_end ( & mut content) ?;
1589+ entries. push ( ( path, content) ) ;
1590+ }
1591+
1592+ // Verify we got the expected files (usr/ directory and hello.txt)
1593+ assert_eq ! (
1594+ entries. len( ) ,
1595+ 2 ,
1596+ "expected 2 entries in layer (usr/ and hello.txt)"
1597+ ) ;
1598+
1599+ // Find hello.txt and verify content
1600+ let hello_entry = entries
1601+ . iter ( )
1602+ . find ( |( path, _) | path == "hello.txt" )
1603+ . expect ( "expected hello.txt in layer" ) ;
1604+ assert_eq ! (
1605+ hello_entry. 1 , b"hello from test layer\n " ,
1606+ "hello.txt content mismatch"
1607+ ) ;
1608+
1609+ // Verify usr/ directory exists
1610+ assert ! (
1611+ entries
1612+ . iter( )
1613+ . any( |( path, _) | path == "usr" || path == "usr/" ) ,
1614+ "expected usr/ directory in layer"
1615+ ) ;
1616+
1617+ Ok ( ( ) )
1618+ }
1619+ integration_test ! ( test_layer_tar_roundtrip) ;
1620+
1621+ /// Test computing the composefs image ID for an OCI image.
1622+ ///
1623+ /// This verifies that we can compute the filesystem verity hash for an image,
1624+ /// which is the prerequisite for sealing and mounting.
1625+ fn test_compute_image_id ( ) -> Result < ( ) > {
1626+ let sh = Shell :: new ( ) ?;
1627+ let cfsctl = cfsctl ( ) ?;
1628+ let repo_dir = init_insecure_repo ( & sh, & cfsctl) ?;
1629+ let repo = repo_dir. path ( ) ;
1630+ let fixture_dir = tempfile:: tempdir ( ) ?;
1631+ let oci_layout = create_oci_layout ( fixture_dir. path ( ) ) ?;
1632+
1633+ // Pull an image
1634+ cmd ! (
1635+ sh,
1636+ "{cfsctl} --insecure --repo {repo} oci pull oci:{oci_layout} test-image"
1637+ )
1638+ . read ( ) ?;
1639+
1640+ // Get the config digest from inspect output
1641+ let inspect_output = cmd ! (
1642+ sh,
1643+ "{cfsctl} --insecure --repo {repo} oci inspect test-image"
1644+ )
1645+ . read ( ) ?;
1646+ let inspect: serde_json:: Value = serde_json:: from_str ( & inspect_output) ?;
1647+ let config_digest = inspect[ "manifest" ] [ "config" ] [ "digest" ]
1648+ . as_str ( )
1649+ . expect ( "expected config digest" ) ;
1650+ // Digests must be prefixed with '@' to distinguish from named refs
1651+ let config_ref = format ! ( "@{config_digest}" ) ;
1652+
1653+ // Compute the image ID
1654+ let compute_output = cmd ! (
1655+ sh,
1656+ "{cfsctl} --insecure --repo {repo} oci compute-id {config_ref}"
1657+ )
1658+ . read ( ) ?;
1659+
1660+ // The output should be a valid hex digest
1661+ // composefs uses SHA-256 fs-verity which produces 64 hex chars
1662+ // (but the underlying digest could be longer in some configurations)
1663+ let image_id = compute_output. trim ( ) ;
1664+ assert ! (
1665+ image_id. len( ) >= 64 ,
1666+ "image ID should be at least 64 hex chars, got {} chars: {}" ,
1667+ image_id. len( ) ,
1668+ image_id
1669+ ) ;
1670+ assert ! (
1671+ image_id. chars( ) . all( |c| c. is_ascii_hexdigit( ) ) ,
1672+ "image ID should be hex, got: {}" ,
1673+ image_id
1674+ ) ;
1675+
1676+ // Computing the same image should produce the same ID (deterministic)
1677+ let compute_output2 = cmd ! (
1678+ sh,
1679+ "{cfsctl} --insecure --repo {repo} oci compute-id {config_ref}"
1680+ )
1681+ . read ( ) ?;
1682+ assert_eq ! (
1683+ image_id,
1684+ compute_output2. trim( ) ,
1685+ "compute-id should be deterministic"
1686+ ) ;
1687+
1688+ Ok ( ( ) )
1689+ }
1690+ integration_test ! ( test_compute_image_id) ;
0 commit comments