diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43fc70b4081ea..74a0f2b4f6596 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -443,6 +443,14 @@ jobs: run: | sudo modprobe erofs + - name: Load dm-verity kernel module + run: | + sudo modprobe dm_verity + + - name: Verify veritysetup version + run: | + veritysetup --version + - name: Install containerd env: CGO_ENABLED: 1 diff --git a/core/mount/manager/dmverity_linux.go b/core/mount/manager/dmverity_linux.go new file mode 100644 index 0000000000000..1c16c6405da4b --- /dev/null +++ b/core/mount/manager/dmverity_linux.go @@ -0,0 +1,146 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package manager + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/containerd/errdefs" + "github.com/containerd/log" + + "github.com/containerd/containerd/v2/core/mount" + "github.com/containerd/containerd/v2/internal/dmverity" +) + +const ( + // prefixDmverity is the option prefix for dm-verity specific options + prefixDmverity = "X-containerd.dmverity." +) + +// parseDmverityMountOptions extracts dm-verity parameters from mount options +// Returns the root hash, device name, hash offset, and filtered regular options +func parseDmverityMountOptions(mountOptions []string) (string, string, uint64, []string, error) { + var rootHash string + var deviceName string + var hashOffset uint64 + var regularOptions []string + + for _, o := range mountOptions { + if dmverityOption, isDmverity := strings.CutPrefix(o, prefixDmverity); isDmverity { + key, value, ok := strings.Cut(dmverityOption, "=") + if !ok { + // Ignore unknown boolean flags for forward compatibility + continue + } + switch key { + case "device-name": + deviceName = value + case "roothash": + rootHash = value + case "hash-offset": + var offset uint64 + if _, err := fmt.Sscanf(value, "%d", &offset); err != nil { + return "", "", 0, nil, fmt.Errorf("invalid hash-offset value %q: %w", value, errdefs.ErrInvalidArgument) + } + hashOffset = offset + default: + return "", "", 0, nil, fmt.Errorf("unknown dmverity option %q: %w", key, errdefs.ErrInvalidArgument) + } + } else { + regularOptions = append(regularOptions, o) + } + } + + return rootHash, deviceName, hashOffset, regularOptions, nil +} + +// dmverityTransformer is a mount transformer that sets up dm-verity devices +// for integrity verification. It reads dm-verity options from mount options +// and creates a read-only device-mapper target. +type dmverityTransformer struct{} + +func (dmverityTransformer) Transform(ctx context.Context, m mount.Mount, a []mount.ActiveMount) (mount.Mount, error) { + log.G(ctx).Debugf("transforming dmverity mount: %+v", m) + + supported, err := dmverity.IsSupported() + if err != nil { + return mount.Mount{}, fmt.Errorf("dm-verity support check failed: %w", err) + } + if !supported { + return mount.Mount{}, fmt.Errorf("dm-verity is not supported on this system: veritysetup not available or dm_verity module not loaded: %w", errdefs.ErrNotImplemented) + } + + // Parse dm-verity options from mount options + rootHash, deviceName, hashOffset, regularOptions, err := parseDmverityMountOptions(m.Options) + if err != nil { + return mount.Mount{}, err + } + + // Generate device name if not specified + if deviceName == "" { + deviceName = fmt.Sprintf("dmverity-%d", time.Now().UnixNano()) + } + + // Check if device already exists (for layer reuse) + devicePath := dmverity.DevicePath(deviceName) + if _, err := os.Stat(devicePath); err == nil { + log.G(ctx).WithField("device", devicePath).Debug("dm-verity device already exists, reusing") + m.Source = devicePath + m.Options = regularOptions + return m, nil + } + + // Create dm-verity device + log.G(ctx).WithFields(log.Fields{ + "source": m.Source, + "device-name": deviceName, + "hash-offset": hashOffset, + }).Debug("opening dm-verity device") + + devicePath, err = dmverity.Open(m.Source, deviceName, m.Source, rootHash, hashOffset, nil) + if err != nil { + return mount.Mount{}, fmt.Errorf("failed to open dm-verity device: %w", err) + } + + // Wait for device to appear + for i := 0; i < 100; i++ { + if _, err := os.Stat(devicePath); err == nil { + break + } + time.Sleep(10 * time.Millisecond) + } + + // Verify device exists + if _, err := os.Stat(devicePath); err != nil { + // Try to close the device we just created + if closeErr := dmverity.Close(deviceName); closeErr != nil { + log.G(ctx).WithError(closeErr).Warn("failed to cleanup dm-verity device after creation failure") + } + return mount.Mount{}, fmt.Errorf("dm-verity device %q not found after creation: %w", devicePath, err) + } + + log.G(ctx).WithField("device", devicePath).Info("dm-verity device created successfully") + + // Return updated mount pointing to dm-verity device + m.Source = devicePath + m.Options = regularOptions + return m, nil +} diff --git a/core/mount/manager/dmverity_linux_test.go b/core/mount/manager/dmverity_linux_test.go new file mode 100644 index 0000000000000..10f5ab199219b --- /dev/null +++ b/core/mount/manager/dmverity_linux_test.go @@ -0,0 +1,145 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package manager + +import ( + "context" + "strings" + "testing" + + "github.com/containerd/log/logtest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/containerd/containerd/v2/core/mount" +) + +// TestDmverityTransformer tests the core transformer functionality +func TestDmverityTransformer(t *testing.T) { + ctx := logtest.WithT(context.Background(), t) + tr := dmverityTransformer{} + + // Test parsing valid options with roothash, device-name, and hash-offset + t.Run("parses valid dmverity options", func(t *testing.T) { + rootHash, deviceName, hashOffset, regularOpts, err := parseDmverityMountOptions([]string{ + "ro", + "X-containerd.dmverity.roothash=abc123def456789012345678901234567890123456789012345678901234", + "X-containerd.dmverity.device-name=test-device", + "X-containerd.dmverity.hash-offset=12288", + }) + require.NoError(t, err) + assert.Equal(t, "abc123def456789012345678901234567890123456789012345678901234", rootHash) + assert.Equal(t, "test-device", deviceName) + assert.Equal(t, uint64(12288), hashOffset) + assert.Equal(t, []string{"ro"}, regularOpts) + }) + + // Test unknown dmverity option with = sign + t.Run("rejects unknown dmverity key-value options", func(t *testing.T) { + _, _, _, _, err := parseDmverityMountOptions([]string{ + "ro", + "X-containerd.dmverity.unknown=value", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown dmverity option") + }) + + // Test unknown boolean flag (should be ignored for forward compatibility) + t.Run("ignores unknown dmverity boolean flags", func(t *testing.T) { + rootHash, deviceName, hashOffset, regularOpts, err := parseDmverityMountOptions([]string{ + "ro", + "X-containerd.dmverity.roothash=abc123def456789012345678901234567890123456789012345678901234", + "X-containerd.dmverity.unknown-flag", + }) + require.NoError(t, err) + assert.Equal(t, "abc123def456789012345678901234567890123456789012345678901234", rootHash) + assert.Equal(t, "", deviceName) + assert.Equal(t, uint64(0), hashOffset) + assert.Equal(t, []string{"ro"}, regularOpts) + }) + + // Test invalid hash-offset value + t.Run("rejects invalid hash-offset", func(t *testing.T) { + _, _, _, _, err := parseDmverityMountOptions([]string{ + "ro", + "X-containerd.dmverity.hash-offset=invalid", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid hash-offset value") + }) + + // Test regular options are preserved + t.Run("preserves regular mount options", func(t *testing.T) { + rootHash, deviceName, hashOffset, regularOpts, err := parseDmverityMountOptions([]string{ + "ro", + "nodev", + "X-containerd.dmverity.roothash=abc123", + "X-containerd.dmverity.device-name=test", + "X-containerd.dmverity.hash-offset=16384", + "nosuid", + }) + require.NoError(t, err) + assert.Equal(t, "abc123", rootHash) + assert.Equal(t, "test", deviceName) + assert.Equal(t, uint64(16384), hashOffset) + assert.Equal(t, []string{"ro", "nodev", "nosuid"}, regularOpts) + }) + + // Test dmverity options are filtered out from regular options + t.Run("filters dmverity options from mount options", func(t *testing.T) { + _, _, _, regularOpts, err := parseDmverityMountOptions([]string{ + "ro", + "X-containerd.dmverity.roothash=abc123", + "noatime", + "X-containerd.dmverity.device-name=test", + "nodev", + }) + require.NoError(t, err) + assert.Equal(t, []string{"ro", "noatime", "nodev"}, regularOpts) + // Ensure no dmverity options are in regular options + for _, opt := range regularOpts { + assert.False(t, strings.HasPrefix(opt, "X-containerd.dmverity.")) + } + }) + + // Test empty options + t.Run("handles empty options", func(t *testing.T) { + rootHash, deviceName, hashOffset, regularOpts, err := parseDmverityMountOptions([]string{}) + require.NoError(t, err) + assert.Equal(t, "", rootHash) + assert.Equal(t, "", deviceName) + assert.Equal(t, uint64(0), hashOffset) + assert.Empty(t, regularOpts) + }) + + // Test Transform will fail without actual device (expected) + t.Run("Transform fails with non-existent source device", func(t *testing.T) { + m := mount.Mount{ + Source: "/path/to/nonexistent.erofs", + Type: "erofs", + Options: []string{ + "ro", + "X-containerd.dmverity.roothash=abc123def456789012345678901234567890123456789012345678901234", + "X-containerd.dmverity.device-name=test-device", + }, + } + _, err := tr.Transform(ctx, m, nil) + require.Error(t, err) + // Should fail because device doesn't exist, not because of parsing + assert.NotContains(t, err.Error(), "unknown dmverity option") + }) +} diff --git a/core/mount/manager/dmverity_other.go b/core/mount/manager/dmverity_other.go new file mode 100644 index 0000000000000..245907b2e0dd9 --- /dev/null +++ b/core/mount/manager/dmverity_other.go @@ -0,0 +1,33 @@ +//go:build !linux + +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package manager + +import ( + "context" + "fmt" + + "github.com/containerd/containerd/v2/core/mount" +) + +// dmverityTransformer is a no-op transformer for non-Linux platforms +type dmverityTransformer struct{} + +func (dmverityTransformer) Transform(ctx context.Context, m mount.Mount, a []mount.ActiveMount) (mount.Mount, error) { + return mount.Mount{}, fmt.Errorf("dm-verity is not supported on this platform") +} diff --git a/core/mount/manager/manager.go b/core/mount/manager/manager.go index d43c1beb8e7cb..8188f7cffc6d9 100644 --- a/core/mount/manager/manager.go +++ b/core/mount/manager/manager.go @@ -162,6 +162,7 @@ func (mm *mountManager) Activate(ctx context.Context, name string, mounts []moun "mkdir": &mkdir{ rootMap: mm.rootMap, }, + "dmverity": dmverityTransformer{}, } start := time.Now() diff --git a/docs/snapshotters/erofs.md b/docs/snapshotters/erofs.md index b42a8476bf478..e71349677c2ea 100644 --- a/docs/snapshotters/erofs.md +++ b/docs/snapshotters/erofs.md @@ -178,6 +178,48 @@ introduces additional runtime overhead since all container image reads from the container will be slower because it needs to verify the Merkle hash tree first. +### Data Integrity with dm-verity + +By setting `enable_dmverity = true`, the EROFS snapshotter will use device-mapper +verity to provide block-level integrity verification for each EROFS layer. This +method creates a dm-verity device for each layer and mounts it read-only. + +Configuration for dm-verity support: + +```toml +[plugins."io.containerd.snapshotter.v1.erofs"] + enable_dmverity = true + +[plugins."io.containerd.differ.v1.erofs"] + enable_dmverity = true +``` + +**Requirements:** +- Linux kernel with dm-verity support (CONFIG_DM_VERITY) +- `veritysetup` command-line tool (from cryptsetup package) +- Device-mapper kernel module loaded + +**How it works:** + +1. During image pull, the EROFS differ formats each layer with dm-verity, appending + a Merkle hash tree to the EROFS blob and generating a root hash. + +2. The root hash and hash offset are stored in a `.dmverity` metadata file alongside + the layer blob in JSON format. All other dm-verity parameters (block sizes, salt, etc.) + are stored in a superblock within the layer blob itself and are auto-detected when mounting. + +3. When mounting a layer, the EROFS snapshotter reads the metadata from the `.dmverity` + file and creates a dm-verity device. The dm-verity library automatically reads all + other parameters from the superblock, ensuring that any corruption or tampering will + be detected at read time. + +4. The dm-verity device is mounted as the backing layer in the OverlayFS stack. + +**Block size considerations:** + +- Regular mode: Uses 4096-byte blocks (standard page size) +- Tar-index mode: Uses 512-byte blocks (dm-verity logical_block_size constraint) + ## How It Works For each layer, the EROFS snapshotter prepares a directory containing the @@ -204,9 +246,19 @@ In this case, the snapshot layer directory will look like this: work ``` +If dm-verity is enabled, a `.dmverity` metadata file will also be present: +``` + .erofslayer + fs + layer.erofs + layer.erofs.dmverity + work +``` + Then the EROFS snapshotter will check for the existence of `layer.erofs`: it will mount the EROFS layer blob to `fs/` and return a valid overlayfs mount -with all parent layers. +with all parent layers. If dm-verity is enabled and the `.dmverity` file exists, +the snapshotter will create a dm-verity device and mount that instead. If other differs (not the EROFS differ) are used, the EROFS snapshotter will convert the flat directory into an EROFS layer blob on Commit instead. @@ -242,5 +294,3 @@ For the EROFS differ: - EROFS Flatten filesystem support (EROFS fsmerge feature); - ID-mapped mount spport; - - - DMVerity support. diff --git a/internal/dmverity/dmverity.go b/internal/dmverity/dmverity.go new file mode 100644 index 0000000000000..d3630305c55b9 --- /dev/null +++ b/internal/dmverity/dmverity.go @@ -0,0 +1,211 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +// Package dmverity provides functions for working with dm-verity for integrity verification +// using the veritysetup system tool +package dmverity + +import ( + "bufio" + "bytes" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/containerd/log" +) + +// VeritySetupCommand represents the type of veritysetup command to execute +type VeritySetupCommand string + +const ( + // FormatCommand corresponds to "veritysetup format" + FormatCommand VeritySetupCommand = "format" + // OpenCommand corresponds to "veritysetup open" + OpenCommand VeritySetupCommand = "open" + // CloseCommand corresponds to "veritysetup close" + CloseCommand VeritySetupCommand = "close" +) + +// DmverityOptions contains configuration options for dm-verity operations +type DmverityOptions struct { + // Salt for hashing, represented as a hex string + Salt string + // Hash algorithm to use (default: sha256) + HashAlgorithm string + // Size of data blocks in bytes (default: 4096) + DataBlockSize uint32 + // Size of hash blocks in bytes (default: 4096) + HashBlockSize uint32 + // Number of data blocks + DataBlocks uint64 + // Offset of hash area in bytes + HashOffset uint64 + // Hash type (default: 1) + HashType uint32 + // NoSuperblock disables superblock usage (matches library's NoSuperblock field) + NoSuperblock bool + // UUID for device to use + UUID string + // RootHashFile specifies a file path where the root hash should be saved + RootHashFile string +} + +// DefaultDmverityOptions returns a set of default options. +func DefaultDmverityOptions() *DmverityOptions { + return &DmverityOptions{ + Salt: "0000000000000000000000000000000000000000000000000000000000000000", + HashAlgorithm: "sha256", + DataBlockSize: 4096, + HashBlockSize: 4096, + HashType: 1, + NoSuperblock: false, // By default, use superblock + } +} + +// ValidateOptions validates dm-verity options to ensure they are valid +// before being passed to veritysetup commands +func ValidateOptions(opts *DmverityOptions) error { + if opts == nil { + return fmt.Errorf("options cannot be nil") + } + + // Validate block sizes are power of 2 (kernel requirement) + if opts.DataBlockSize > 0 { + if opts.DataBlockSize&(opts.DataBlockSize-1) != 0 { + return fmt.Errorf("data block size %d must be a power of 2", opts.DataBlockSize) + } + } + + if opts.HashBlockSize > 0 { + if opts.HashBlockSize&(opts.HashBlockSize-1) != 0 { + return fmt.Errorf("hash block size %d must be a power of 2", opts.HashBlockSize) + } + } + + // Validate salt format (must be hex string) + if opts.Salt != "" { + if _, err := hex.DecodeString(opts.Salt); err != nil { + return fmt.Errorf("salt must be a valid hex string: %w", err) + } + } + + return nil +} + +// ValidateRootHash validates that a root hash string is in valid hexadecimal format +func ValidateRootHash(rootHash string) error { + if rootHash == "" { + return fmt.Errorf("root hash cannot be empty") + } + + // Validate root hash (must be hex string) + if _, err := hex.DecodeString(rootHash); err != nil { + return fmt.Errorf("root hash must be a valid hex string: %w", err) + } + + return nil +} + +// ExtractRootHash extracts the root hash from veritysetup format command output. +// It first attempts to read from the root hash file (if specified in opts.RootHashFile), +// then falls back to parsing the stdout output. +// +// Note: This function expects English output when parsing stdout. The calling code +// ensures veritysetup runs with LC_ALL=C and LANG=C to prevent localization issues. +func ExtractRootHash(output string, opts *DmverityOptions) (string, error) { + log.L.Debugf("veritysetup format output:\n%s", output) + + var rootHash string + + // Try to read from root hash file first (if specified) + if opts != nil && opts.RootHashFile != "" { + hashBytes, err := os.ReadFile(opts.RootHashFile) + if err != nil { + return "", fmt.Errorf("failed to read root hash from file %q: %w", opts.RootHashFile, err) + } + // Trim any whitespace/newlines + rootHash = string(bytes.TrimSpace(hashBytes)) + } else { + // Parse stdout output to find the root hash + if output == "" { + return "", fmt.Errorf("output is empty") + } + + scanner := bufio.NewScanner(strings.NewReader(output)) + for scanner.Scan() { + line := scanner.Text() + // Look for the "Root hash:" line + if strings.HasPrefix(line, "Root hash:") { + parts := strings.Split(line, ":") + if len(parts) == 2 { + rootHash = strings.TrimSpace(parts[1]) + break + } + } + } + + if err := scanner.Err(); err != nil { + return "", fmt.Errorf("error scanning output: %w", err) + } + } + + // Validate root hash + if err := ValidateRootHash(rootHash); err != nil { + return "", fmt.Errorf("root hash is invalid: %w", err) + } + + return rootHash, nil +} + +// MetadataPath returns the path to the metadata file for a layer blob. +// The file contains the root hash and hash offset required for dm-verity verification. +func MetadataPath(layerBlobPath string) string { + return layerBlobPath + ".dmverity" +} + +// DevicePath returns the device path for a given dm-verity device name. +func DevicePath(name string) string { + return fmt.Sprintf("/dev/mapper/%s", name) +} + +// DmverityMetadata contains dm-verity parameters read from the metadata file +type DmverityMetadata struct { + RootHash string `json:"roothash"` + HashOffset uint64 `json:"hashoffset"` +} + +// ReadMetadata reads dm-verity metadata (root hash and hash offset) from the .dmverity file. +func ReadMetadata(layerBlobPath string) (*DmverityMetadata, error) { + metadataPath := MetadataPath(layerBlobPath) + data, err := os.ReadFile(metadataPath) + if err != nil { + return nil, fmt.Errorf("metadata file not found at %q: %w", metadataPath, err) + } + + var metadata DmverityMetadata + if err := json.Unmarshal(data, &metadata); err != nil { + return nil, fmt.Errorf("failed to parse metadata file %q: %w", metadataPath, err) + } + + if metadata.RootHash == "" { + return nil, fmt.Errorf("missing root hash in metadata file %q", metadataPath) + } + + return &metadata, nil +} diff --git a/internal/dmverity/dmverity_linux.go b/internal/dmverity/dmverity_linux.go new file mode 100644 index 0000000000000..49b560d6f7517 --- /dev/null +++ b/internal/dmverity/dmverity_linux.go @@ -0,0 +1,193 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package dmverity + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "time" +) + +const ( + // veritysetupTimeout is the maximum time allowed for a veritysetup command to complete + // Format operations can take time for large devices, but should complete within 5 minutes + veritysetupTimeout = 5 * time.Minute +) + +func IsSupported() (bool, error) { + moduleData, err := os.ReadFile("/proc/modules") + if err != nil { + return false, fmt.Errorf("failed to read /proc/modules: %w", err) + } + if !bytes.Contains(moduleData, []byte("dm_verity")) { + return false, fmt.Errorf("dm_verity module not loaded") + } + + veritysetupPath, err := exec.LookPath("veritysetup") + if err != nil { + return false, fmt.Errorf("veritysetup not found in PATH: %w", err) + } + + cmd := exec.Command(veritysetupPath, "--version") + if _, err := cmd.CombinedOutput(); err != nil { + return false, fmt.Errorf("veritysetup not functional: %w", err) + } + + return true, nil +} + +// actions executes a veritysetup command with the given arguments and options +func actions(cmd VeritySetupCommand, args []string, opts *DmverityOptions) (string, error) { + if opts == nil { + opts = DefaultDmverityOptions() + } + + if err := ValidateOptions(opts); err != nil { + return "", fmt.Errorf("invalid dm-verity options: %w", err) + } + + // Build command arguments + cmdArgs := []string{string(cmd)} + + // Helper to add option if value is non-empty/non-zero + addOpt := func(flag string, value interface{}) { + switch v := value.(type) { + case string: + if v != "" { + cmdArgs = append(cmdArgs, fmt.Sprintf("%s=%s", flag, v)) + } + case uint32: + if v > 0 { + cmdArgs = append(cmdArgs, fmt.Sprintf("%s=%d", flag, v)) + } + case uint64: + if v > 0 { + cmdArgs = append(cmdArgs, fmt.Sprintf("%s=%d", flag, v)) + } + case bool: + if v { + cmdArgs = append(cmdArgs, flag) + } + } + } + + // Add options based on what's set in opts + addOpt("--salt", opts.Salt) + addOpt("--hash", opts.HashAlgorithm) + addOpt("--data-block-size", opts.DataBlockSize) + addOpt("--hash-block-size", opts.HashBlockSize) + addOpt("--data-blocks", opts.DataBlocks) + addOpt("--hash-offset", opts.HashOffset) + addOpt("--no-superblock", opts.NoSuperblock) + addOpt("--uuid", opts.UUID) + addOpt("--root-hash-file", opts.RootHashFile) + + // Append positional arguments + cmdArgs = append(cmdArgs, args...) + + // Execute command + ctx, cancel := context.WithTimeout(context.Background(), veritysetupTimeout) + defer cancel() + + execCmd := exec.CommandContext(ctx, "veritysetup", cmdArgs...) + // Force C locale to ensure consistent, non-localized output that we can parse reliably + // This prevents localization issues where field names like "Root hash", "Salt", etc. + // might be translated to other languages, breaking our text parsing + execCmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C") + output, err := execCmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("veritysetup %s failed: %w, output: %s", cmd, err, string(output)) + } + + return string(output), nil +} + +// Format creates a dm-verity hash for a data device and returns the root hash. +// If hashDevice is the same as dataDevice, the hash will be stored on the same device. +func Format(dataDevice, hashDevice string, opts *DmverityOptions) (string, error) { + args := []string{dataDevice, hashDevice} + output, err := actions(FormatCommand, args, opts) + if err != nil { + return "", fmt.Errorf("failed to format dm-verity device: %w, output: %s", err, output) + } + + // Extract the root hash from the format output + // Pass opts so ExtractRootHash can read root hash from file if RootHashFile was specified + rootHash, err := ExtractRootHash(output, opts) + if err != nil { + return "", fmt.Errorf("failed to extract root hash: %w", err) + } + + return rootHash, nil +} + +// Open creates a read-only device-mapper target for transparent integrity verification. +// It supports both superblock and no-superblock modes: +// +// - Superblock mode (opts == nil or opts.NoSuperblock == false): +// Reads dm-verity parameters from the superblock at the specified hashOffset. +// Only rootHash needs to be provided; all other parameters are read from the device. +// Use hashOffset to specify where the superblock is located (required when hash tree +// is stored in the same file as data). +// +// - No-superblock mode (opts != nil and opts.NoSuperblock == true): +// Uses explicitly provided parameters from opts. All dm-verity parameters must be +// supplied programmatically since there's no superblock to read from. +func Open(dataDevice string, name string, hashDevice string, rootHash string, hashOffset uint64, opts *DmverityOptions) (string, error) { + // Validate required parameters + // When using RootHashFile, veritysetup reads the root hash from the file + if rootHash == "" && (opts == nil || opts.RootHashFile == "") { + return "", fmt.Errorf("rootHash cannot be empty") + } + + // Build options if not provided + if opts == nil { + opts = &DmverityOptions{ + HashOffset: hashOffset, + } + } else if hashOffset > 0 { + opts.HashOffset = hashOffset + } + + var args []string + // If RootHashFile is provided, use the alternate open syntax without root hash as command arg + if opts.RootHashFile != "" { + args = []string{dataDevice, name, hashDevice} + } else { + args = []string{dataDevice, name, hashDevice, rootHash} + } + output, err := actions(OpenCommand, args, opts) + if err != nil { + return "", fmt.Errorf("failed to open dm-verity device: %w, output: %s", err, output) + } + + // Return the device path + return DevicePath(name), nil +} + +// Close removes a dm-verity target and its underlying device from the device mapper table +func Close(name string) error { + args := []string{name} + output, err := actions(CloseCommand, args, nil) + if err != nil { + return fmt.Errorf("failed to close dm-verity device: %w, output: %s", err, output) + } + return nil +} diff --git a/internal/dmverity/dmverity_other.go b/internal/dmverity/dmverity_other.go new file mode 100644 index 0000000000000..248d5bf33138a --- /dev/null +++ b/internal/dmverity/dmverity_other.go @@ -0,0 +1,39 @@ +//go:build !linux + +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package dmverity + +import "fmt" + +var errUnsupported = fmt.Errorf("dmverity is only supported on Linux systems") + +func IsSupported() (bool, error) { + return false, errUnsupported +} + +func Format(_ string, _ string, _ *DmverityOptions) (string, error) { + return "", errUnsupported +} + +func Open(_ string, _ string, _ string, _ string, _ uint64, _ *DmverityOptions) (string, error) { + return "", errUnsupported +} + +func Close(_ string) error { + return errUnsupported +} diff --git a/internal/dmverity/dmverity_test.go b/internal/dmverity/dmverity_test.go new file mode 100644 index 0000000000000..d67e5dd681b60 --- /dev/null +++ b/internal/dmverity/dmverity_test.go @@ -0,0 +1,625 @@ +//go:build linux + +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package dmverity + +import ( + "bytes" + "os" + "strings" + "testing" + "time" + + "github.com/containerd/containerd/v2/core/mount" + "github.com/containerd/containerd/v2/pkg/testutil" + "github.com/docker/go-units" + "github.com/stretchr/testify/assert" +) + +const ( + testDeviceName = "test-verity-device" +) + +func TestDMVerity(t *testing.T) { + testutil.RequiresRoot(t) + + supported, err := IsSupported() + if !supported || err != nil { + t.Skipf("dm-verity is not supported on this system: %v", err) + } + + t.Run("IsSupported", func(t *testing.T) { + supported, err := IsSupported() + assert.True(t, supported) + assert.NoError(t, err) + }) + + t.Run("WithSuperblock", func(t *testing.T) { + t.Run("SameDevice", func(t *testing.T) { + tempDir := t.TempDir() + _, loopDevice := createLoopbackDevice(t, tempDir, "1Mb") + defer func() { + assert.NoError(t, mount.DetachLoopDevice(loopDevice)) + }() + + opts := DmverityOptions{ + Salt: "0000000000000000000000000000000000000000000000000000000000000000", + HashAlgorithm: "sha256", + DataBlockSize: 4096, + HashBlockSize: 4096, + DataBlocks: 256, + HashOffset: 1048576, + HashType: 1, + NoSuperblock: false, // Use superblock + UUID: "12345678-1234-1234-1234-123456789012", // Required for superblock + } + + // Format with superblock - data and hash on same device + rootHash, err := Format(loopDevice, loopDevice, &opts) + assert.NoError(t, err) + assert.NotEmpty(t, rootHash) + + // Open with superblock mode - provide hashOffset, opts is nil + deviceName := testDeviceName + "-sb-same" + devicePath, err := Open(loopDevice, deviceName, loopDevice, rootHash, opts.HashOffset, nil) + assert.NoError(t, err) + assert.Equal(t, "/dev/mapper/"+deviceName, devicePath) + + // Wait for device to appear (device-mapper symlink creation can be async) + var statErr error + for i := 0; i < 100; i++ { + _, statErr = os.Stat(devicePath) + if statErr == nil { + break + } + time.Sleep(10 * time.Millisecond) + } + assert.NoError(t, statErr) + + // Close device + err = Close(deviceName) + assert.NoError(t, err) + + // Verify device is removed + _, err = os.Stat(devicePath) + assert.True(t, os.IsNotExist(err)) + }) + + t.Run("SeparateDevices", func(t *testing.T) { + tempDir := t.TempDir() + _, dataDevice := createLoopbackDevice(t, tempDir, "1Mb") + _, hashDevice := createLoopbackDevice(t, tempDir, "512Kb") + defer func() { + assert.NoError(t, mount.DetachLoopDevice(dataDevice)) + assert.NoError(t, mount.DetachLoopDevice(hashDevice)) + }() + + opts := DmverityOptions{ + Salt: "0000000000000000000000000000000000000000000000000000000000000000", + HashAlgorithm: "sha256", + DataBlockSize: 4096, + HashBlockSize: 4096, + DataBlocks: 256, + // HashOffset is REQUIRED even for separate devices when using superblock. + // The library does not auto-calculate this - it must be explicitly provided. + // This offset tells where the hash tree data begins after the superblock metadata. + // Typically 4096 bytes (one block) is sufficient for superblock metadata. + HashOffset: 4096, + HashType: 1, + NoSuperblock: false, // Use superblock + UUID: "12345678-1234-5678-9012-123456789012", // Required for superblock + } + + // Format with superblock - data and hash on separate devices + rootHash, err := Format(dataDevice, hashDevice, &opts) + assert.NoError(t, err) + assert.NotEmpty(t, rootHash) + + // Open with superblock mode - separate devices + deviceName := testDeviceName + "-sb-sep" + devicePath, err := Open(dataDevice, deviceName, hashDevice, rootHash, opts.HashOffset, nil) + assert.NoError(t, err) + assert.Equal(t, "/dev/mapper/"+deviceName, devicePath) + + // Wait for device to appear (device-mapper symlink creation can be async) + var statErr error + for i := 0; i < 100; i++ { + _, statErr = os.Stat(devicePath) + if statErr == nil { + break + } + time.Sleep(10 * time.Millisecond) + } + assert.NoError(t, statErr) + + // Close device + err = Close(deviceName) + assert.NoError(t, err) + + // Verify device is removed + _, err = os.Stat(devicePath) + assert.True(t, os.IsNotExist(err)) + }) + + t.Run("WithRootHashFile", func(t *testing.T) { + tempDir := t.TempDir() + _, loopDevice := createLoopbackDevice(t, tempDir, "1Mb") + defer func() { + assert.NoError(t, mount.DetachLoopDevice(loopDevice)) + }() + + // Create root hash file for format command + rootHashFile, err := os.CreateTemp(tempDir, "root-hash-*.txt") + assert.NoError(t, err) + rootHashFilePath := rootHashFile.Name() + rootHashFile.Close() + defer os.Remove(rootHashFilePath) + + opts := DmverityOptions{ + Salt: "0000000000000000000000000000000000000000000000000000000000000000", + HashAlgorithm: "sha256", + DataBlockSize: 4096, + HashBlockSize: 4096, + DataBlocks: 256, + HashOffset: 1048576, + HashType: 1, + NoSuperblock: false, + UUID: "12345678-1234-1234-1234-123456789012", + RootHashFile: rootHashFilePath, + } + + // Format with root hash file + rootHashFromFormat, err := Format(loopDevice, loopDevice, &opts) + assert.NoError(t, err) + assert.NotEmpty(t, rootHashFromFormat) + + // Verify root hash was written to file + fileContent, err := os.ReadFile(rootHashFilePath) + assert.NoError(t, err) + fileHash := string(bytes.TrimSpace(fileContent)) + assert.Equal(t, rootHashFromFormat, fileHash) + + // Open using root hash from file + deviceName := testDeviceName + "-roothashfile" + optsOpen := opts + optsOpen.RootHashFile = rootHashFilePath + devicePath, err := Open(loopDevice, deviceName, loopDevice, "", opts.HashOffset, &optsOpen) + assert.NoError(t, err) + assert.Equal(t, "/dev/mapper/"+deviceName, devicePath) + + // Wait for device to appear + var statErr error + for i := 0; i < 100; i++ { + _, statErr = os.Stat(devicePath) + if statErr == nil { + break + } + time.Sleep(10 * time.Millisecond) + } + assert.NoError(t, statErr) + + // Close device + err = Close(deviceName) + assert.NoError(t, err) + + // Verify device is removed + _, err = os.Stat(devicePath) + assert.True(t, os.IsNotExist(err)) + }) + }) + + t.Run("NoSuperblock", func(t *testing.T) { + t.Run("SameDevice", func(t *testing.T) { + tempDir := t.TempDir() + _, loopDevice := createLoopbackDevice(t, tempDir, "1Mb") + defer func() { + assert.NoError(t, mount.DetachLoopDevice(loopDevice)) + }() + + opts := DmverityOptions{ + Salt: "0000000000000000000000000000000000000000000000000000000000000000", + HashAlgorithm: "sha256", + DataBlockSize: 4096, + HashBlockSize: 4096, + DataBlocks: 256, + HashOffset: 1048576, + HashType: 1, + NoSuperblock: true, // No superblock + } + + // Format without superblock - data and hash on same device + rootHash, err := Format(loopDevice, loopDevice, &opts) + assert.NoError(t, err) + assert.NotEmpty(t, rootHash) + + // Open with no-superblock mode - provide opts with NoSuperblock=true + deviceName := testDeviceName + "-nosb-same" + devicePath, err := Open(loopDevice, deviceName, loopDevice, rootHash, opts.HashOffset, &opts) + assert.NoError(t, err) + assert.Equal(t, "/dev/mapper/"+deviceName, devicePath) + + // Wait for device to appear (device-mapper symlink creation can be async) + var statErr error + for i := 0; i < 100; i++ { + _, statErr = os.Stat(devicePath) + if statErr == nil { + break + } + time.Sleep(10 * time.Millisecond) + } + assert.NoError(t, statErr) + + // Close device + err = Close(deviceName) + assert.NoError(t, err) + + // Verify device is removed + _, err = os.Stat(devicePath) + assert.True(t, os.IsNotExist(err)) + }) + + t.Run("SeparateDevices", func(t *testing.T) { + tempDir := t.TempDir() + _, dataDevice := createLoopbackDevice(t, tempDir, "1Mb") + _, hashDevice := createLoopbackDevice(t, tempDir, "512Kb") + defer func() { + assert.NoError(t, mount.DetachLoopDevice(dataDevice)) + assert.NoError(t, mount.DetachLoopDevice(hashDevice)) + }() + + opts := DmverityOptions{ + Salt: "0000000000000000000000000000000000000000000000000000000000000000", + HashAlgorithm: "sha256", + DataBlockSize: 4096, + HashBlockSize: 4096, + DataBlocks: 256, + HashOffset: 0, // Hash device is separate, starts at offset 0 + HashType: 1, + NoSuperblock: true, // No superblock + } + + // Format without superblock - data and hash on separate devices + rootHash, err := Format(dataDevice, hashDevice, &opts) + assert.NoError(t, err) + assert.NotEmpty(t, rootHash) + + // Open with no-superblock mode - separate devices + deviceName := testDeviceName + "-nosb-sep" + devicePath, err := Open(dataDevice, deviceName, hashDevice, rootHash, 0, &opts) + assert.NoError(t, err) + assert.Equal(t, "/dev/mapper/"+deviceName, devicePath) + + // Wait for device to appear (device-mapper symlink creation can be async) + var statErr error + for i := 0; i < 100; i++ { + _, statErr = os.Stat(devicePath) + if statErr == nil { + break + } + time.Sleep(10 * time.Millisecond) + } + assert.NoError(t, statErr) + + // Close device + err = Close(deviceName) + assert.NoError(t, err) + + // Verify device is removed + _, err = os.Stat(devicePath) + assert.True(t, os.IsNotExist(err)) + }) + }) +} + +func createLoopbackDevice(t *testing.T, dir string, size string) (string, string) { + file, err := os.CreateTemp(dir, "dmverity-tests-") + assert.NoError(t, err) + + sizeInBytes, err := units.RAMInBytes(size) + assert.NoError(t, err) + + err = file.Truncate(sizeInBytes * 2) + assert.NoError(t, err) + + err = file.Close() + assert.NoError(t, err) + + imagePath := file.Name() + + loopDevice, err := mount.AttachLoopDevice(imagePath) + assert.NoError(t, err) + + return imagePath, loopDevice +} + +func TestMetadataPath(t *testing.T) { + assert.Equal(t, "/path/to/layer.erofs.dmverity", MetadataPath("/path/to/layer.erofs")) +} + +func TestDevicePath(t *testing.T) { + assert.Equal(t, "/dev/mapper/test-device", DevicePath("test-device")) + assert.Equal(t, "/dev/mapper/containerd-erofs-abc123", DevicePath("containerd-erofs-abc123")) +} + +func TestReadMetadata(t *testing.T) { + tmpDir := t.TempDir() + + createMetadataFile := func(filename, content string) string { + layerBlob := tmpDir + "/" + strings.TrimSuffix(filename, ".dmverity") + os.WriteFile(tmpDir+"/"+filename, []byte(content), 0644) + return layerBlob + } + + // Valid case + layerBlob := createMetadataFile("layer.erofs.dmverity", `{"roothash":"abc123def456789012345678901234567890123456789012345678901234","hashoffset":12288}`) + metadata, err := ReadMetadata(layerBlob) + assert.NoError(t, err) + assert.Equal(t, "abc123def456789012345678901234567890123456789012345678901234", metadata.RootHash) + assert.Equal(t, uint64(12288), metadata.HashOffset) + + // Valid case with pretty-printed JSON + layerBlob = createMetadataFile("layer2.erofs.dmverity", `{ + "roothash": "def456789012345678901234567890123456789012345678901234567890", + "hashoffset": 16384 +}`) + metadata, err = ReadMetadata(layerBlob) + assert.NoError(t, err) + assert.Equal(t, "def456789012345678901234567890123456789012345678901234567890", metadata.RootHash) + assert.Equal(t, uint64(16384), metadata.HashOffset) + + // Error: empty root hash + layerBlob = createMetadataFile("layer3.erofs.dmverity", `{"roothash":"","hashoffset":12288}`) + _, err = ReadMetadata(layerBlob) + assert.ErrorContains(t, err, "missing root hash") + + // Error: missing root hash field + layerBlob = createMetadataFile("layer4.erofs.dmverity", `{"hashoffset":12288}`) + _, err = ReadMetadata(layerBlob) + assert.ErrorContains(t, err, "missing root hash") + + // Error: invalid JSON + layerBlob = createMetadataFile("layer5.erofs.dmverity", `not valid json`) + _, err = ReadMetadata(layerBlob) + assert.ErrorContains(t, err, "failed to parse") + + // Error: file not found + _, err = ReadMetadata(tmpDir + "/nonexistent.erofs") + assert.ErrorContains(t, err, "metadata file not found") +} + +func TestValidateOptions(t *testing.T) { + tests := []struct { + name string + opts *DmverityOptions + wantErr bool + errMsg string + }{ + { + name: "nil options", + opts: nil, + wantErr: true, + errMsg: "options cannot be nil", + }, + { + name: "valid options", + opts: &DmverityOptions{ + Salt: "0000000000000000000000000000000000000000000000000000000000000000", + HashAlgorithm: "sha256", + DataBlockSize: 4096, + HashBlockSize: 4096, + }, + wantErr: false, + }, + { + name: "invalid data block size", + opts: &DmverityOptions{ + DataBlockSize: 1000, // not power of 2 + }, + wantErr: true, + errMsg: "data block size 1000 must be a power of 2", + }, + { + name: "invalid hash block size", + opts: &DmverityOptions{ + HashBlockSize: 3000, // not power of 2 + }, + wantErr: true, + errMsg: "hash block size 3000 must be a power of 2", + }, + { + name: "invalid salt hex", + opts: &DmverityOptions{ + Salt: "not-hex", + }, + wantErr: true, + errMsg: "salt must be a valid hex string", + }, + { + name: "empty salt allowed", + opts: &DmverityOptions{ + Salt: "", + }, + wantErr: false, + }, + { + name: "valid power of 2 sizes", + opts: &DmverityOptions{ + DataBlockSize: 512, + HashBlockSize: 8192, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateOptions(tt.opts) + if tt.wantErr { + assert.Error(t, err) + if tt.errMsg != "" { + assert.Contains(t, err.Error(), tt.errMsg) + } + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestValidateRootHash(t *testing.T) { + tests := []struct { + name string + rootHash string + wantErr bool + errMsg string + }{ + { + name: "empty root hash", + rootHash: "", + wantErr: true, + errMsg: "root hash cannot be empty", + }, + { + name: "valid root hash", + rootHash: "abc123def456789012345678901234567890123456789012345678901234567890", + wantErr: false, + }, + { + name: "invalid hex characters", + rootHash: "xyz123", + wantErr: true, + errMsg: "root hash must be a valid hex string", + }, + { + name: "odd length hex", + rootHash: "abc12", + wantErr: true, + errMsg: "root hash must be a valid hex string", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateRootHash(tt.rootHash) + if tt.wantErr { + assert.Error(t, err) + if tt.errMsg != "" { + assert.Contains(t, err.Error(), tt.errMsg) + } + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestExtractRootHash(t *testing.T) { + tests := []struct { + name string + output string + opts *DmverityOptions + expected string + wantErr bool + errMsg string + }{ + { + name: "empty output", + output: "", + wantErr: true, + errMsg: "output is empty", + }, + { + name: "valid output", + output: `VERITY header information for /dev/loop0 +UUID: 12345678-1234-1234-1234-123456789012 +Hash type: 1 +Data blocks: 256 +Data block size: 4096 +Hash block size: 4096 +Hash algorithm: sha256 +Salt: 0000000000000000000000000000000000000000000000000000000000000000 +Root hash: abc123def456789012345678901234567890123456789012345678901234567890`, + expected: "abc123def456789012345678901234567890123456789012345678901234567890", + wantErr: false, + }, + { + name: "missing root hash", + output: "Some output without Root hash line", + wantErr: true, + errMsg: "root hash cannot be empty", + }, + { + name: "invalid root hash", + output: `Root hash: xyz123`, + wantErr: true, + errMsg: "root hash must be a valid hex string", + }, + { + name: "root hash from nonexistent file", + opts: &DmverityOptions{ + RootHashFile: "/tmp/nonexistent-roothash-file.txt", + }, + wantErr: true, + errMsg: "failed to read root hash from file", + }, + { + name: "skips header lines", + output: `Veritysetup 2.6.1 processing "format" action. +VERITY header information for /dev/loop0 +Root hash: def456abc7890123456789012345678901234567890123456789012345678901`, + expected: "def456abc7890123456789012345678901234567890123456789012345678901", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ExtractRootHash(tt.output, tt.opts) + if tt.wantErr { + assert.Error(t, err) + if tt.errMsg != "" { + assert.Contains(t, err.Error(), tt.errMsg) + } + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expected, result) + } + }) + } + + // Separate test for root hash from file takes priority + t.Run("root hash from file takes priority", func(t *testing.T) { + tmpFile, err := os.CreateTemp("", "roothash-*.txt") + assert.NoError(t, err) + defer os.Remove(tmpFile.Name()) + + expectedHash := "fedcba098765432109876543210987654321098765432109876543210987654321" + _, err = tmpFile.WriteString(expectedHash + "\n") + assert.NoError(t, err) + tmpFile.Close() + + output := `Root hash: different123456789012345678901234567890123456789012345678901234` + opts := &DmverityOptions{ + RootHashFile: tmpFile.Name(), + } + + result, err := ExtractRootHash(output, opts) + assert.NoError(t, err) + assert.Equal(t, expectedHash, result, "should use hash from file, not from output") + }) +} diff --git a/plugins/diff/erofs/differ.go b/plugins/diff/erofs/differ.go index c89ad2ddf7a3b..7daf856cd5211 100644 --- a/plugins/diff/erofs/differ.go +++ b/plugins/diff/erofs/differ.go @@ -52,6 +52,8 @@ type erofsDiff struct { // enableTarIndex enables generating tar index for tar content // instead of fully converting the tar to EROFS format enableTarIndex bool + // enableDmverity enables formatting layers with dm-verity after creation + enableDmverity bool } // DifferOpt is an option for configuring the erofs differ @@ -71,6 +73,13 @@ func WithTarIndexMode() DifferOpt { } } +// WithDmverity enables dm-verity formatting for EROFS layers +func WithDmverity() DifferOpt { + return func(d *erofsDiff) { + d.enableDmverity = true + } +} + // NewErofsDiffer creates a new EROFS differ with the provided options func NewErofsDiffer(store content.Store, opts ...DifferOpt) differ { d := &erofsDiff{ @@ -194,6 +203,13 @@ func (s erofsDiff) Apply(ctx context.Context, desc ocispec.Descriptor, mounts [] return emptyDesc, err } + // Format with dm-verity if enabled + if s.enableDmverity { + if err := s.formatDmverityLayer(ctx, layerBlobPath); err != nil { + return emptyDesc, fmt.Errorf("failed to format dm-verity layer: %w", err) + } + } + return ocispec.Descriptor{ MediaType: ocispec.MediaTypeImageLayer, Size: rc.c, diff --git a/plugins/diff/erofs/dmverity_linux.go b/plugins/diff/erofs/dmverity_linux.go new file mode 100644 index 0000000000000..462f34dd9de4a --- /dev/null +++ b/plugins/diff/erofs/dmverity_linux.go @@ -0,0 +1,114 @@ +//go:build linux + +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package erofs + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "github.com/containerd/log" + + "github.com/containerd/containerd/v2/internal/dmverity" +) + +// getDmverityOptions returns dm-verity options configured for this differ instance. +// The block size is determined by the differ's mode: +// - Tar index mode requires 512-byte blocks to match EROFS and dm-verity constraints +// - Regular mode uses 4096-byte blocks (standard page size) +func (s *erofsDiff) getDmverityOptions() *dmverity.DmverityOptions { + opts := dmverity.DefaultDmverityOptions() + + // Tar index mode requires 512-byte blocks because: + // 1. EROFS tar index mode uses 512-byte metadata blocks (mkfs.erofs --tar=i) + // 2. dm-verity sets the virtual block device logical_block_size to match the data block size + // 3. EROFS requires its block size (512) to be >= the underlying block device's logical_block_size + // Using 4096-byte dm-verity blocks would set logical_block_size=4096, causing EROFS sb_set_blocksize(512) to fail + if s.enableTarIndex { + opts.DataBlockSize = 512 + opts.HashBlockSize = 512 + } + // Regular mode uses the default 4096-byte blocks (standard page size) + + return opts +} + +// formatDmverityLayer formats an EROFS layer with dm-verity hash tree +func (s *erofsDiff) formatDmverityLayer(ctx context.Context, layerBlobPath string) error { + // Check if metadata file already exists - if so, layer already has dm-verity + metadataPath := dmverity.MetadataPath(layerBlobPath) + if _, err := os.Stat(metadataPath); err == nil { + log.G(ctx).WithField("path", layerBlobPath).Debug("Layer already formatted with dm-verity, skipping") + return nil + } + + // Get file size and validate it's block-aligned + fileInfo, err := os.Stat(layerBlobPath) + if err != nil { + return fmt.Errorf("failed to stat layer blob: %w", err) + } + + opts := s.getDmverityOptions() + blockSize := int64(opts.DataBlockSize) + fileSize := fileInfo.Size() + + // Calculate hash offset - round up to next block boundary + // dm-verity requires the hash area to start at a block-aligned offset + dataBlocks := (fileSize + blockSize - 1) / blockSize + hashOffset := uint64(dataBlocks * blockSize) + + // Pre-allocate 2x data size to ensure sufficient space for hash tree + // Filesystem sparse allocation makes this efficient + if err := os.Truncate(layerBlobPath, fileSize*2); err != nil { + return fmt.Errorf("failed to pre-allocate space for hash tree: %w", err) + } + + // Configure dm-verity parameters + opts.HashOffset = hashOffset + opts.DataBlocks = uint64(dataBlocks) + + // Create dm-verity hash tree + rootHash, err := dmverity.Format(layerBlobPath, layerBlobPath, opts) + if err != nil { + return fmt.Errorf("failed to format dm-verity: %w", err) + } + + metadata := dmverity.DmverityMetadata{ + RootHash: rootHash, + HashOffset: hashOffset, + } + metadataBytes, err := json.MarshalIndent(metadata, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal dm-verity metadata: %w", err) + } + if err := os.WriteFile(metadataPath, metadataBytes, 0644); err != nil { + return fmt.Errorf("failed to write dm-verity metadata: %w", err) + } + + log.G(ctx).WithFields(log.Fields{ + "path": layerBlobPath, + "size": fileSize, + "blockSize": opts.DataBlockSize, + "hashOffset": hashOffset, + "rootHash": rootHash, + }).Info("Successfully formatted dm-verity layer") + + return nil +} diff --git a/plugins/diff/erofs/dmverity_linux_test.go b/plugins/diff/erofs/dmverity_linux_test.go new file mode 100644 index 0000000000000..3771be248786a --- /dev/null +++ b/plugins/diff/erofs/dmverity_linux_test.go @@ -0,0 +1,130 @@ +//go:build linux + +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package erofs + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/containerd/log/logtest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/containerd/containerd/v2/internal/dmverity" +) + +// TestGetDmverityOptions tests the block size configuration +func TestGetDmverityOptions(t *testing.T) { + // tar-index mode uses 512-byte blocks + opts := (&erofsDiff{enableTarIndex: true, enableDmverity: true}).getDmverityOptions() + assert.Equal(t, uint32(512), opts.DataBlockSize) + assert.Equal(t, uint32(512), opts.HashBlockSize) + + // regular mode uses 4096-byte blocks + opts = (&erofsDiff{enableTarIndex: false, enableDmverity: true}).getDmverityOptions() + assert.Equal(t, uint32(4096), opts.DataBlockSize) + assert.Equal(t, uint32(4096), opts.HashBlockSize) +} + +// TestFormatDmverityLayer tests the layer formatting logic +func TestFormatDmverityLayer(t *testing.T) { + supported, err := dmverity.IsSupported() + if err != nil || !supported { + t.Skip("dm-verity is not supported on this system") + } + + ctx := logtest.WithT(context.Background(), t) + tmpDir := t.TempDir() + + t.Run("formats layer and creates metadata", func(t *testing.T) { + d := &erofsDiff{enableDmverity: true, enableTarIndex: false} + layerPath := filepath.Join(tmpDir, "layer.erofs") + require.NoError(t, os.WriteFile(layerPath, make([]byte, 8192), 0644)) + + require.NoError(t, d.formatDmverityLayer(ctx, layerPath)) + + // Verify metadata file was created + metadata, err := dmverity.ReadMetadata(layerPath) + require.NoError(t, err) + assert.NotEmpty(t, metadata.RootHash) + assert.Greater(t, metadata.HashOffset, uint64(0)) + assert.Equal(t, uint64(8192), metadata.HashOffset) + }) + + t.Run("skips formatting if metadata already exists", func(t *testing.T) { + d := &erofsDiff{enableDmverity: true, enableTarIndex: false} + layerPath := filepath.Join(tmpDir, "layer-idempotent.erofs") + require.NoError(t, os.WriteFile(layerPath, make([]byte, 8192), 0644)) + + // First format + require.NoError(t, d.formatDmverityLayer(ctx, layerPath)) + metadata1, _ := dmverity.ReadMetadata(layerPath) + origHash := metadata1.RootHash + + // Second format should be idempotent + require.NoError(t, d.formatDmverityLayer(ctx, layerPath)) + metadata2, _ := dmverity.ReadMetadata(layerPath) + assert.Equal(t, origHash, metadata2.RootHash) + }) + + t.Run("uses 4096-byte blocks in regular mode", func(t *testing.T) { + d := &erofsDiff{enableDmverity: true, enableTarIndex: false} + layerPath := filepath.Join(tmpDir, "layer-4k.erofs") + require.NoError(t, os.WriteFile(layerPath, make([]byte, 8192), 0644)) + + require.NoError(t, d.formatDmverityLayer(ctx, layerPath)) + + metadata, _ := dmverity.ReadMetadata(layerPath) + assert.Equal(t, uint64(8192), metadata.HashOffset) + }) + + t.Run("uses 512-byte blocks in tar-index mode", func(t *testing.T) { + d := &erofsDiff{enableDmverity: true, enableTarIndex: true} + layerPath := filepath.Join(tmpDir, "layer-512.erofs") + require.NoError(t, os.WriteFile(layerPath, make([]byte, 1024), 0644)) + + require.NoError(t, d.formatDmverityLayer(ctx, layerPath)) + + metadata, _ := dmverity.ReadMetadata(layerPath) + assert.Equal(t, uint64(1024), metadata.HashOffset) + }) + + t.Run("returns error for non-existent file", func(t *testing.T) { + d := &erofsDiff{enableDmverity: true, enableTarIndex: false} + err := d.formatDmverityLayer(ctx, filepath.Join(tmpDir, "missing.erofs")) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to stat layer blob") + }) + + t.Run("rounds up non-aligned file size to block boundary", func(t *testing.T) { + d := &erofsDiff{enableDmverity: true, enableTarIndex: false} + layerPath := filepath.Join(tmpDir, "layer-unaligned.erofs") + // Write 5000 bytes (not aligned to 4096), should round up to 8192 + require.NoError(t, os.WriteFile(layerPath, make([]byte, 5000), 0644)) + + require.NoError(t, d.formatDmverityLayer(ctx, layerPath)) + + metadata, err := dmverity.ReadMetadata(layerPath) + require.NoError(t, err) + // Hash offset should be rounded up to next 4096-byte boundary + assert.Equal(t, uint64(8192), metadata.HashOffset) + }) +} diff --git a/plugins/diff/erofs/dmverity_other.go b/plugins/diff/erofs/dmverity_other.go new file mode 100644 index 0000000000000..46521cd3a841d --- /dev/null +++ b/plugins/diff/erofs/dmverity_other.go @@ -0,0 +1,29 @@ +//go:build !linux + +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package erofs + +import ( + "context" + "fmt" +) + +// formatDmverityLayer returns not implemented on non-Linux platforms +func (s *erofsDiff) formatDmverityLayer(_ context.Context, _ string) error { + return fmt.Errorf("dm-verity is only supported on Linux") +} diff --git a/plugins/diff/erofs/plugin/plugin.go b/plugins/diff/erofs/plugin/plugin.go index c051ec9f85867..f2c9122f80436 100644 --- a/plugins/diff/erofs/plugin/plugin.go +++ b/plugins/diff/erofs/plugin/plugin.go @@ -24,6 +24,7 @@ import ( "github.com/containerd/plugin/registry" "github.com/containerd/containerd/v2/core/metadata" + "github.com/containerd/containerd/v2/internal/dmverity" "github.com/containerd/containerd/v2/internal/erofsutils" "github.com/containerd/containerd/v2/plugins" "github.com/containerd/containerd/v2/plugins/diff/erofs" @@ -37,6 +38,10 @@ type Config struct { // EnableTarIndex enables the tar index mode where the index is generated // for tar content without extracting the tar EnableTarIndex bool `toml:"enable_tar_index"` + + // EnableDmverity enables dm-verity formatting for EROFS layers + // Linux only + EnableDmverity bool `toml:"enable_dmverity"` } func init() { @@ -77,6 +82,17 @@ func init() { opts = append(opts, erofs.WithTarIndexMode()) } + if config.EnableDmverity { + supported, err := dmverity.IsSupported() + if err != nil { + return nil, fmt.Errorf("dm-verity support check failed: %w", err) + } + if !supported { + return nil, fmt.Errorf("dm-verity is not supported on this system (veritysetup not available or dm_verity module not loaded): %w", plugin.ErrSkipPlugin) + } + opts = append(opts, erofs.WithDmverity()) + } + return erofs.NewErofsDiffer(cs, opts...), nil }, }) diff --git a/plugins/snapshots/erofs/erofs.go b/plugins/snapshots/erofs/erofs.go index 0cf36a207b06b..80c13055a8ec2 100644 --- a/plugins/snapshots/erofs/erofs.go +++ b/plugins/snapshots/erofs/erofs.go @@ -26,10 +26,12 @@ import ( "github.com/containerd/continuity/fs" "github.com/containerd/errdefs" "github.com/containerd/log" + "github.com/containerd/plugin" "github.com/containerd/containerd/v2/core/mount" "github.com/containerd/containerd/v2/core/snapshots" "github.com/containerd/containerd/v2/core/snapshots/storage" + "github.com/containerd/containerd/v2/internal/dmverity" "github.com/containerd/containerd/v2/internal/fsverity" ) @@ -41,6 +43,8 @@ type SnapshotterConfig struct { enableFsverity bool // setImmutable enables IMMUTABLE_FL file attribute for EROFS layers setImmutable bool + // enableDmverity enables dmverity for EROFS layers + enableDmverity bool // defaultSize creates a default size writable layer for active snapshots defaultSize int64 } @@ -69,6 +73,13 @@ func WithImmutable() Opt { } } +// WithDmverity enables dmverity for EROFS layers +func WithDmverity() Opt { + return func(config *SnapshotterConfig) { + config.enableDmverity = true + } +} + // WithDefaultSize creates a default size writable layer for active snapshots func WithDefaultSize(size int64) Opt { return func(config *SnapshotterConfig) { @@ -88,6 +99,7 @@ type snapshotter struct { ovlOptions []string enableFsverity bool setImmutable bool + enableDmverity bool defaultWritable int64 blockMode bool } @@ -100,6 +112,16 @@ func NewSnapshotter(root string, opts ...Opt) (snapshots.Snapshotter, error) { opt(&config) } + // Ensure fsverity and dmverity are not both enabled + if config.enableFsverity && config.enableDmverity { + return nil, fmt.Errorf("fsverity and dmverity cannot be enabled simultaneously") + } + + // Ensure setImmutable and dmverity are not both enabled + if config.setImmutable && config.enableDmverity { + return nil, fmt.Errorf("setImmutable and dmverity cannot be enabled simultaneously") + } + if err := os.MkdirAll(root, 0700); err != nil { return nil, err } @@ -108,6 +130,16 @@ func NewSnapshotter(root string, opts ...Opt) (snapshots.Snapshotter, error) { return nil, err } + if config.enableDmverity { + supported, err := dmverity.IsSupported() + if err != nil { + return nil, fmt.Errorf("dm-verity support check failed: %w", err) + } + if !supported { + return nil, fmt.Errorf("dm-verity is not supported on this system (veritysetup not available or dm_verity module not loaded): %w", plugin.ErrSkipPlugin) + } + } + // Check fsverity support if enabled if config.enableFsverity { // TODO: Call specific function here @@ -139,6 +171,7 @@ func NewSnapshotter(root string, opts ...Opt) (snapshots.Snapshotter, error) { ovlOptions: config.ovlOptions, enableFsverity: config.enableFsverity, setImmutable: config.setImmutable, + enableDmverity: config.enableDmverity, defaultWritable: config.defaultSize, blockMode: config.defaultSize > 0, }, nil @@ -175,6 +208,11 @@ func (s *snapshotter) lowerPath(id string) (string, error) { return layerBlob, nil } +// dmverityDeviceName returns the dm-verity device name for a snapshot ID +func (s *snapshotter) dmverityDeviceName(id string) string { + return fmt.Sprintf("containerd-erofs-%s", id) +} + func (s *snapshotter) prepareDirectory(ctx context.Context, snapshotDir string, kind snapshots.Kind) (string, error) { td, err := os.MkdirTemp(snapshotDir, "new-") if err != nil { @@ -200,6 +238,44 @@ func (s *snapshotter) prepareDirectory(ctx context.Context, snapshotDir string, return td, nil } +// createErofsMount creates a mount specification for an EROFS layer. +// If dm-verity is enabled, it returns a dmverity/erofs mount type. +// Otherwise, it returns a standard erofs mount with loop option. +func (s *snapshotter) createErofsMount(id string, layerBlob string) (mount.Mount, error) { + if s.enableDmverity { + return s.createDmverityErofsMount(id, layerBlob) + } + return mount.Mount{ + Source: layerBlob, + Type: "erofs", + Options: []string{"ro", "loop"}, + }, nil +} + +// createDmverityErofsMount creates a mount specification for an EROFS layer with dm-verity integrity verification. +// It reads dm-verity metadata from the .dmverity file and includes all necessary parameters in mount options. +func (s *snapshotter) createDmverityErofsMount(id string, layerBlob string) (mount.Mount, error) { + // Parse dm-verity parameters from metadata file + metadata, err := dmverity.ReadMetadata(layerBlob) + if err != nil { + return mount.Mount{}, fmt.Errorf("failed to read dm-verity metadata for layer %s: %w", layerBlob, err) + } + + // Build mount options with dm-verity parameters + options := []string{ + "ro", + fmt.Sprintf("X-containerd.dmverity.roothash=%s", metadata.RootHash), + fmt.Sprintf("X-containerd.dmverity.device-name=%s", s.dmverityDeviceName(id)), + fmt.Sprintf("X-containerd.dmverity.hash-offset=%d", metadata.HashOffset), + } + + return mount.Mount{ + Source: layerBlob, + Type: "dmverity/erofs", + Options: options, + }, nil +} + func (s *snapshotter) mounts(snap storage.Snapshot, _ snapshots.Info) ([]mount.Mount, error) { var options []string @@ -213,13 +289,11 @@ func (s *snapshotter) mounts(snap storage.Snapshot, _ snapshots.Info) ([]mount.M return nil, err } } - return []mount.Mount{ - { - Source: layerBlob, - Type: "erofs", - Options: []string{"ro", "loop"}, - }, - }, nil + m, err := s.createErofsMount(snap.ID, layerBlob) + if err != nil { + return nil, fmt.Errorf("failed to create erofs mount: %w", err) + } + return []mount.Mount{m}, nil } // if we only have one layer/no parents then just return a bind mount as overlay // will not work @@ -297,13 +371,11 @@ func (s *snapshotter) mounts(snap storage.Snapshot, _ snapshots.Info) ([]mount.M if err != nil { return nil, err } - return []mount.Mount{ - { - Source: layerBlob, - Type: "erofs", - Options: []string{"ro", "loop"}, - }, - }, nil + m, err := s.createErofsMount(snap.ParentIDs[0], layerBlob) + if err != nil { + return nil, fmt.Errorf("failed to create erofs mount: %w", err) + } + return []mount.Mount{m}, nil } first := len(mounts) @@ -313,10 +385,9 @@ func (s *snapshotter) mounts(snap storage.Snapshot, _ snapshots.Info) ([]mount.M return nil, err } - m := mount.Mount{ - Source: layerBlob, - Type: "erofs", - Options: []string{"ro", "loop"}, + m, err := s.createErofsMount(snap.ParentIDs[i], layerBlob) + if err != nil { + return nil, fmt.Errorf("failed to create erofs mount for parent %s: %w", snap.ParentIDs[i], err) } mounts = append(mounts, m) @@ -484,6 +555,8 @@ func (s *snapshotter) Commit(ctx context.Context, name, key string, opts ...snap } } + // Note: dm-verity formatting is handled by the EROFS differ, not here + return s.ms.WithTransaction(ctx, true, func(ctx context.Context) error { if _, err := os.Stat(layerBlob); err != nil { return fmt.Errorf("failed to get the converted erofs blob: %w", err) @@ -564,6 +637,24 @@ func (s *snapshotter) Remove(ctx context.Context, key string) (err error) { log.G(ctx).WithError(err).WithField("id", id).Warnf("failed to cleanup upperdir") } + // Close dm-verity device if it exists for this snapshot + // The device should already be unmounted by the container runtime before Remove() is called + // If the device is still mounted, dmverity.Close will fail safely and we'll log a debug message + if s.enableDmverity && id != "" { + deviceName := s.dmverityDeviceName(id) + devicePath := dmverity.DevicePath(deviceName) + if _, statErr := os.Stat(devicePath); statErr == nil { + log.G(ctx).WithField("device", deviceName).Info("attempting to close dm-verity device for removed snapshot") + // Try to close the device - will fail if still mounted or in use + if closeErr := dmverity.Close(deviceName); closeErr != nil { + // This is expected if device is still in use by another container or not yet unmounted + log.G(ctx).WithError(closeErr).WithField("device", deviceName).Debug("failed to close dm-verity device (may still be in use or mounted)") + } else { + log.G(ctx).WithField("device", deviceName).Info("dm-verity device closed successfully") + } + } + } + for _, dir := range removals { if err := os.RemoveAll(dir); err != nil { log.G(ctx).WithError(err).WithField("path", dir).Warn("failed to remove directory") diff --git a/plugins/snapshots/erofs/erofs_linux_test.go b/plugins/snapshots/erofs/erofs_linux_test.go index b55f0b8731f38..f485c5310a133 100644 --- a/plugins/snapshots/erofs/erofs_linux_test.go +++ b/plugins/snapshots/erofs/erofs_linux_test.go @@ -26,14 +26,21 @@ import ( "testing" "time" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + bolt "go.etcd.io/bbolt" + "github.com/containerd/containerd/v2/core/content" "github.com/containerd/containerd/v2/core/mount" + mountmanager "github.com/containerd/containerd/v2/core/mount/manager" "github.com/containerd/containerd/v2/core/snapshots" "github.com/containerd/containerd/v2/core/snapshots/storage" "github.com/containerd/containerd/v2/core/snapshots/testsuite" + "github.com/containerd/containerd/v2/internal/dmverity" "github.com/containerd/containerd/v2/internal/erofsutils" "github.com/containerd/containerd/v2/internal/fsverity" "github.com/containerd/containerd/v2/pkg/archive/tartest" + "github.com/containerd/containerd/v2/pkg/namespaces" "github.com/containerd/containerd/v2/pkg/testutil" "github.com/containerd/containerd/v2/plugins/content/local" erofsdiffer "github.com/containerd/containerd/v2/plugins/diff/erofs" @@ -354,3 +361,339 @@ func createTestTarContent() io.ReadCloser { // Return the tar as a ReadCloser return tartest.TarFromWriterTo(tarWriter) } + +// TestCreateDmverityErofsMount tests dm-verity mount creation +func TestCreateDmverityErofsMount(t *testing.T) { + testutil.RequiresRoot(t) + + tmpDir := t.TempDir() + + // Helper to create layer with metadata + createLayer := func(name, roothash string, hashOffset int64) string { + layerBlob := filepath.Join(tmpDir, name) + metadataFile := layerBlob + ".dmverity" + + metadataContent := fmt.Sprintf(`{ + "roothash": "%s", + "hashoffset": %d +}`, roothash, hashOffset) + require.NoError(t, os.WriteFile(metadataFile, []byte(metadataContent), 0644)) + require.NoError(t, os.WriteFile(layerBlob, []byte{}, 0644)) + + return layerBlob + } + + // Helper to check if option exists + hasOption := func(options []string, opt string) bool { + for _, o := range options { + if o == opt { + return true + } + } + return false + } + + s := &snapshotter{ + root: tmpDir, + enableDmverity: true, + } + + t.Run("creates dmverity mount with metadata", func(t *testing.T) { + layerBlob := createLayer("layer.erofs", + "abc123def456789012345678901234567890123456789012345678901234", 8192) + + m, err := s.createDmverityErofsMount("test-id", layerBlob) + require.NoError(t, err) + + assert.Equal(t, "dmverity/erofs", m.Type) + assert.Equal(t, layerBlob, m.Source) + assert.True(t, hasOption(m.Options, "ro"), "should have ro option") + assert.True(t, hasOption(m.Options, "X-containerd.dmverity.roothash=abc123def456789012345678901234567890123456789012345678901234")) + assert.True(t, hasOption(m.Options, "X-containerd.dmverity.hash-offset=8192")) + assert.True(t, hasOption(m.Options, "X-containerd.dmverity.device-name=containerd-erofs-test-id")) + }) + + t.Run("fails without metadata file", func(t *testing.T) { + layerBlob := filepath.Join(tmpDir, "layer-no-meta.erofs") + require.NoError(t, os.WriteFile(layerBlob, []byte{}, 0644)) + + _, err := s.createDmverityErofsMount("test-id-2", layerBlob) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to read dm-verity metadata") + }) + + t.Run("fails with invalid metadata format", func(t *testing.T) { + layerBlob := filepath.Join(tmpDir, "layer-bad-meta.erofs") + metadataFile := layerBlob + ".dmverity" + + require.NoError(t, os.WriteFile(metadataFile, []byte("invalid metadata"), 0644)) + require.NoError(t, os.WriteFile(layerBlob, []byte{}, 0644)) + + _, err := s.createDmverityErofsMount("test-id-bad", layerBlob) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to read dm-verity metadata") + }) +} + +// TestCreateErofsMount tests mount creation without dm-verity +func TestCreateErofsMount(t *testing.T) { + tmpDir := t.TempDir() + layerBlob := filepath.Join(tmpDir, "layer.erofs") + require.NoError(t, os.WriteFile(layerBlob, []byte{}, 0644)) + + s := &snapshotter{ + root: tmpDir, + enableDmverity: false, + } + + t.Run("creates regular erofs mount", func(t *testing.T) { + m, err := s.createErofsMount("test-id", layerBlob) + require.NoError(t, err) + + assert.Equal(t, "erofs", m.Type) + assert.Equal(t, layerBlob, m.Source) + assert.Equal(t, []string{"ro", "loop"}, m.Options) + }) + + t.Run("dispatcher calls dmverity function when enabled", func(t *testing.T) { + s.enableDmverity = true + metadataFile := layerBlob + ".dmverity" + metadataContent := `{ + "roothash": "fedcba098765432109876543210987654321098765432109876543210987", + "hashoffset": 4096 +}` + require.NoError(t, os.WriteFile(metadataFile, []byte(metadataContent), 0644)) + + m, err := s.createErofsMount("test-id", layerBlob) + require.NoError(t, err) + assert.Equal(t, "dmverity/erofs", m.Type) + assert.True(t, len(m.Options) > 2, "should have dm-verity options") + assert.Contains(t, m.Options, "X-containerd.dmverity.roothash=fedcba098765432109876543210987654321098765432109876543210987") + }) +} + +// TestDmverityDeviceName tests device name generation +func TestDmverityDeviceName(t *testing.T) { + s := &snapshotter{} + + // Test basic device name generation + deviceName := s.dmverityDeviceName("abc123") + assert.Equal(t, "containerd-erofs-abc123", deviceName) + + // Verify it integrates with DevicePath helper + devicePath := dmverity.DevicePath(deviceName) + assert.Equal(t, "/dev/mapper/containerd-erofs-abc123", devicePath) +} + +// TestDmverityEndToEnd tests the full workflow: differ creates dm-verity layer, +// snapshotter mounts it via mount manager, and cleanup on removal +func TestDmverityEndToEnd(t *testing.T) { + testutil.RequiresRoot(t) + + // Check if dm-verity is supported + supported, err := dmverity.IsSupported() + if err != nil || !supported { + t.Skip("dm-verity is not supported on this system") + } + + ctx := context.Background() + ctx = namespaces.WithNamespace(ctx, "test") + tempDir := t.TempDir() + + // Create mount manager database + metadb := filepath.Join(tempDir, "mounts.db") + db, err := bolt.Open(metadb, 0600, nil) + require.NoError(t, err) + defer db.Close() + + // Create mount manager with dm-verity support + mountTargetDir := filepath.Join(tempDir, "mount-manager") + mgr, err := mountmanager.NewManager(db, mountTargetDir) + require.NoError(t, err) + + // Create content store for the differ + contentStore, err := local.NewStore(filepath.Join(tempDir, "content")) + require.NoError(t, err) + + // Create EROFS differ with dm-verity enabled + differ := erofsdiffer.NewErofsDiffer(contentStore, erofsdiffer.WithDmverity()) + + // Create EROFS snapshotter with dm-verity enabled + snapshotRoot := filepath.Join(tempDir, "snapshots") + sn, err := NewSnapshotter(snapshotRoot, WithDmverity()) + require.NoError(t, err) + defer sn.Close() + + s := sn.(*snapshotter) + + // Create test tar content + tarReader := createTestTarContent() + defer tarReader.Close() + + // Read the tar content into a buffer + tarContent, err := io.ReadAll(tarReader) + require.NoError(t, err) + + // Write tar content to content store + desc := ocispec.Descriptor{ + MediaType: ocispec.MediaTypeImageLayerGzip, + Digest: digest.FromBytes(tarContent), + Size: int64(len(tarContent)), + } + + writer, err := contentStore.Writer(ctx, + content.WithRef("test-layer"), + content.WithDescriptor(desc)) + require.NoError(t, err) + + _, err = writer.Write(tarContent) + require.NoError(t, err) + + err = writer.Commit(ctx, desc.Size, desc.Digest) + require.NoError(t, err) + writer.Close() + + // Step 1: Prepare snapshot + snapshotKey := "test-snapshot" + mounts, err := sn.Prepare(ctx, snapshotKey, "") + require.NoError(t, err) + + // Step 2: Apply layer using differ (creates EROFS + dm-verity) + appliedDesc, err := differ.Apply(ctx, desc, mounts) + require.NoError(t, err) + t.Logf("Applied layer with dm-verity: %s", appliedDesc.Digest) + + // Step 3: Commit the snapshot + commitKey := "test-commit" + err = sn.Commit(ctx, commitKey, snapshotKey) + require.NoError(t, err) + + // Get snapshot ID + var snapshotID string + err = s.ms.WithTransaction(ctx, false, func(ctx context.Context) error { + var err error + snapshotID, _, _, err = storage.GetInfo(ctx, commitKey) + return err + }) + require.NoError(t, err) + + // Step 4: Verify dm-verity metadata was created by differ + layerPath := s.layerBlobPath(snapshotID) + metadataPath := layerPath + ".dmverity" + + metadataData, err := os.ReadFile(metadataPath) + require.NoError(t, err, ".dmverity file should exist") + require.NotEmpty(t, metadataData, "metadata should not be empty") + t.Logf("Root hash: %s", string(metadataData)) + + // Step 5: Create a view and mount it (uses mount manager with dm-verity) + viewKey := "test-view" + viewMounts, err := sn.View(ctx, viewKey, commitKey) + require.NoError(t, err) + + // Verify the mount type is dmverity/erofs + require.Len(t, viewMounts, 1) + assert.Equal(t, "dmverity/erofs", viewMounts[0].Type) + assert.Contains(t, viewMounts[0].Options, "ro") + + // Check that dm-verity options are present + hasRootHash := false + hasDeviceName := false + deviceName := "" + for _, opt := range viewMounts[0].Options { + if len(opt) > len("X-containerd.dmverity.roothash=") && + opt[:len("X-containerd.dmverity.roothash=")] == "X-containerd.dmverity.roothash=" { + hasRootHash = true + } + if len(opt) > len("X-containerd.dmverity.device-name=") && + opt[:len("X-containerd.dmverity.device-name=")] == "X-containerd.dmverity.device-name=" { + hasDeviceName = true + deviceName = opt[len("X-containerd.dmverity.device-name="):] + } + } + assert.True(t, hasRootHash, "mount should have root hash option") + assert.True(t, hasDeviceName, "mount should have device name option") + + t.Logf("Step 5: View created with dm-verity mount options: %v", viewMounts[0].Options) + + // Step 6: Use mount manager to mount the view (processes dm-verity options) + viewTarget := filepath.Join(tempDir, "view-mount") + require.NoError(t, os.MkdirAll(viewTarget, 0755)) + + t.Logf("Step 6: Activating mount via mount manager (triggers dm-verity device creation)...") + mountID := "test-view-mount" + activateInfo, err := mgr.Activate(ctx, mountID, viewMounts) + require.NoError(t, err) + + t.Logf("Step 6: Mount activated, system mounts: %v", activateInfo.System) + + // Mount the activated system mounts + err = mount.All(activateInfo.System, viewTarget) + require.NoError(t, err) + defer testutil.Unmount(t, viewTarget) + + // Check if dm-verity device was created + devicePath := fmt.Sprintf("/dev/mapper/%s", deviceName) + _, err = os.Stat(devicePath) + require.NoError(t, err, "dm-verity device should exist at %s", devicePath) + t.Logf("dm-verity device created successfully: %s", devicePath) + + // Verify device is active using dmsetup + cmd := exec.Command("dmsetup", "info", deviceName) + output, err := cmd.CombinedOutput() + require.NoError(t, err, "dmsetup info should succeed") + t.Logf("Device status:\n%s", string(output)) + + // Verify we can read from the mounted filesystem + files, err := os.ReadDir(viewTarget) + require.NoError(t, err, "should be able to read mounted filesystem") + t.Logf("Mounted filesystem contains %d entries", len(files)) + + // Step 7: Unmount before cleanup + t.Logf("Step 7: Unmounting view...") + testutil.Unmount(t, viewTarget) + t.Logf("Step 7: View unmounted successfully") + + // Deactivate the mount manager mount + t.Logf("Deactivating mount manager mount...") + err = mgr.Deactivate(ctx, mountID) + require.NoError(t, err) + t.Logf("Mount manager mount deactivated") + + // Verify device is still present after unmount (not cleaned up yet) + _, err = os.Stat(devicePath) + if err == nil { + t.Logf("dm-verity device still exists after unmount: %s (will be cleaned up on Remove)", devicePath) + } + + // Step 8: Remove the view first (child must be removed before parent) + t.Logf("Step 8: Removing view snapshot...") + err = sn.Remove(ctx, viewKey) + require.NoError(t, err) + t.Logf("Step 8: View snapshot removed") + + // Step 9: Remove the committed snapshot + // This tests the full cleanup path including dm-verity device cleanup + t.Logf("Step 9: Removing committed snapshot (triggers dm-verity cleanup)...") + err = sn.Remove(ctx, commitKey) + require.NoError(t, err) + t.Logf("Step 9: Committed snapshot removed") + + // Verify device was cleaned up + _, err = os.Stat(devicePath) + if err == nil { + // Device still exists - check if it's still active + cmd = exec.Command("dmsetup", "info", deviceName) + output, _ = cmd.CombinedOutput() + t.Logf("Warning: dm-verity device still exists after Remove():\n%s", string(output)) + } else { + t.Logf("dm-verity device cleaned up successfully") + } // Verify snapshot was removed + err = s.ms.WithTransaction(ctx, false, func(ctx context.Context) error { + _, err := storage.GetSnapshot(ctx, commitKey) + return err + }) + assert.Error(t, err, "snapshot should be removed from metadata") + + t.Logf("Successfully completed end-to-end dm-verity test") +} diff --git a/plugins/snapshots/erofs/plugin/plugin.go b/plugins/snapshots/erofs/plugin/plugin.go index 6e734e84d3604..35b80742b25f1 100644 --- a/plugins/snapshots/erofs/plugin/plugin.go +++ b/plugins/snapshots/erofs/plugin/plugin.go @@ -46,6 +46,10 @@ type Config struct { // DefaultSize is the default size of a writable layer in string DefaultSize string `toml:"default_size"` + + // EnableDmverity enables dmverity for EROFS layers + // Linux only + EnableDmverity bool `toml:"enable_dmverity"` } func init() { @@ -87,6 +91,10 @@ func init() { opts = append(opts, erofs.WithDefaultSize(size)) } + if config.EnableDmverity { + opts = append(opts, erofs.WithDmverity()) + } + ic.Meta.Exports[plugins.SnapshotterRootDir] = root ic.Meta.Capabilities = append(ic.Meta.Capabilities, "rebase") return erofs.NewSnapshotter(root, opts...)