diff --git a/cmd/commands/commands_test.go b/cmd/commands/commands_test.go new file mode 100644 index 0000000..c91cbe5 --- /dev/null +++ b/cmd/commands/commands_test.go @@ -0,0 +1,556 @@ +/* +Copyright 2026 Google LLC + +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 + + https://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 commands + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/containerd/containerd/containers" + "github.com/containerd/containerd/metadata" + "github.com/containerd/containerd/namespaces" + "github.com/gogo/protobuf/types" + "github.com/google/container-explorer/utils" + oci "github.com/opencontainers/runtime-spec/specs-go" + "github.com/urfave/cli" + bolt "go.etcd.io/bbolt" +) + +type mockCommandCall struct { + Name string + Args []string +} + +type mockCommandResponse struct { + Output []byte + Stdout string + Stderr string + Err error +} + +type mockCommandRunner struct { + Calls []mockCommandCall + Responses map[string]mockCommandResponse +} + +func (m *mockCommandRunner) Run(_ context.Context, name string, args ...string) ([]byte, error) { + m.Calls = append(m.Calls, mockCommandCall{Name: name, Args: args}) + if r, ok := m.Responses[name]; ok { + return r.Output, r.Err + } + return nil, nil +} + +func (m *mockCommandRunner) RunSeparate(_ context.Context, name string, args []string, stdout, stderr io.Writer) error { + m.Calls = append(m.Calls, mockCommandCall{Name: name, Args: args}) + if r, ok := m.Responses[name]; ok { + if r.Stdout != "" { + _, _ = stdout.Write([]byte(r.Stdout)) + } + if r.Stderr != "" { + _, _ = stderr.Write([]byte(r.Stderr)) + } + return r.Err + } + return nil +} + +func (m *mockCommandRunner) RunWithoutContext(name string, args ...string) ([]byte, error) { + m.Calls = append(m.Calls, mockCommandCall{Name: name, Args: args}) + if r, ok := m.Responses[name]; ok { + return r.Output, r.Err + } + return nil, nil +} + +func setupMockContainerd(t *testing.T, containerdRoot string, ns string, ctrID string) { + metaDir := filepath.Join(containerdRoot, "io.containerd.metadata.v1.bolt") + _ = os.MkdirAll(metaDir, 0755) + dbPath := filepath.Join(metaDir, "meta.db") + db, err := bolt.Open(dbPath, 0644, nil) + if err != nil { + t.Fatalf("failed to open bolt db: %v", err) + } + defer db.Close() + + err = db.Update(func(tx *bolt.Tx) error { + nsStore := metadata.NewNamespaceStore(tx) + return nsStore.Create(context.Background(), ns, nil) + }) + if err != nil { + t.Fatalf("failed to populate namespace: %v", err) + } + + if ctrID != "" { + dbStore := metadata.NewDB(db, nil, nil) + cStore := metadata.NewContainerStore(dbStore) + + specObj := oci.Spec{ + Linux: &oci.Linux{ + CgroupsPath: "/default/" + ctrID, + }, + Process: &oci.Process{ + Args: []string{"sleep", "10"}, + }, + } + specJSON, _ := json.Marshal(specObj) + anySpec := &types.Any{ + TypeUrl: "types.containerd.io/opencontainers/runtime-spec/1/Spec", + Value: specJSON, + } + + c := containers.Container{ + ID: ctrID, + Image: "ubuntu:latest", + Snapshotter: "overlayfs", + SnapshotKey: "snap-" + ctrID, + Runtime: containers.RuntimeInfo{ + Name: "io.containerd.runc.v2", + }, + Spec: anySpec, + } + ctx := namespaces.WithNamespace(context.Background(), ns) + _, err = cStore.Create(ctx, c) + if err != nil { + t.Fatalf("failed to create container: %v", err) + } + } +} + +func setupMockDocker(t *testing.T, dockerRoot string, containerID string) { + containerDir := filepath.Join(dockerRoot, "containers", containerID) + if err := os.MkdirAll(containerDir, 0755); err != nil { + t.Fatalf("failed to create docker container dir: %v", err) + } + + config := fmt.Sprintf(`{ + "ID": "%s", + "Created": "2026-06-12T00:30:43Z", + "Path": "sleep", + "Args": ["10"], + "State": { + "Running": true, + "Pid": 1234 + }, + "Config": { + "Image": "ubuntu:latest", + "Labels": { + "app": "test-docker" + } + } + }`, containerID) + if err := os.WriteFile(filepath.Join(containerDir, "config.v2.json"), []byte(config), 0600); err != nil { + t.Fatalf("failed to write config.v2.json: %v", err) + } + if err := os.WriteFile(filepath.Join(containerDir, "hostconfig.json"), []byte(`{}`), 0600); err != nil { + t.Fatalf("failed to write hostconfig.json: %v", err) + } +} + +func createMetaSnapshot(tx *bolt.Tx, ns, snapshotter, key, name, parent string, created time.Time) error { + v1Bkt, err := tx.CreateBucketIfNotExists([]byte("v1")) + if err != nil { + return err + } + nsBkt, err := v1Bkt.CreateBucketIfNotExists([]byte(ns)) + if err != nil { + return err + } + snapshotsBkt, err := nsBkt.CreateBucketIfNotExists([]byte("snapshots")) + if err != nil { + return err + } + sterBkt, err := snapshotsBkt.CreateBucketIfNotExists([]byte(snapshotter)) + if err != nil { + return err + } + keyBkt, err := sterBkt.CreateBucketIfNotExists([]byte(key)) + if err != nil { + return err + } + + _ = keyBkt.Put([]byte("name"), []byte(name)) + _ = keyBkt.Put([]byte("parent"), []byte(parent)) + tBytes, _ := created.MarshalBinary() + _ = keyBkt.Put([]byte("createdat"), tBytes) + return nil +} + +func createOverlaySnapshot(tx *bolt.Tx, key string, id uint64, kind byte, parent string, size uint64, created time.Time) error { + v1Bkt, err := tx.CreateBucketIfNotExists([]byte("v1")) + if err != nil { + return err + } + snapsBucket, err := v1Bkt.CreateBucketIfNotExists([]byte("snapshots")) + if err != nil { + return err + } + keyBucket, err := snapsBucket.CreateBucketIfNotExists([]byte(key)) + if err != nil { + return err + } + + idBuf := make([]byte, binary.MaxVarintLen64) + n := binary.PutUvarint(idBuf, id) + _ = keyBucket.Put([]byte("id"), idBuf[:n]) + + kindBuf := make([]byte, binary.MaxVarintLen64) + n = binary.PutUvarint(kindBuf, uint64(kind)) + _ = keyBucket.Put([]byte("kind"), kindBuf[:n]) + + _ = keyBucket.Put([]byte("parent"), []byte(parent)) + + sizeBuf := make([]byte, binary.MaxVarintLen64) + n = binary.PutUvarint(sizeBuf, size) + _ = keyBucket.Put([]byte("size"), sizeBuf[:n]) + + tBytes, _ := created.MarshalBinary() + _ = keyBucket.Put([]byte("createdat"), tBytes) + return nil +} + +func runApp(args []string) (string, error) { + app := cli.NewApp() + app.Flags = []cli.Flag{ + cli.StringFlag{Name: "containerd-root, c"}, + cli.StringFlag{Name: "image-root, i"}, + cli.StringFlag{Name: "docker-root, D"}, + cli.StringFlag{Name: "output"}, + } + app.Commands = []cli.Command{ + ListCommand, + InfoCommand, + InspectCommand, + MountCommand, + DriftCommand, + ExportCommand, + } + app.Before = func(clictx *cli.Context) error { + return InitializeRuntime(clictx) + } + + // Capture output + var stdoutBuf bytes.Buffer + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := app.Run(args) + + w.Close() + os.Stdout = oldStdout + _, _ = io.Copy(&stdoutBuf, r) + + return stdoutBuf.String(), err +} + +func TestCLI_ListNamespaces(t *testing.T) { + tmpDir := t.TempDir() + containerdRoot := filepath.Join(tmpDir, "containerd_root") + setupMockContainerd(t, containerdRoot, "ns-test-1", "") + + args := []string{"container-explorer", "--containerd-root", containerdRoot, "list", "namespaces"} + output, err := runApp(args) + if err != nil { + t.Fatalf("runApp failed: %v", err) + } + + if !strings.Contains(output, "ns-test-1") { + t.Errorf("expected output to contain 'ns-test-1', got:\n%s", output) + } +} + +func TestCLI_ListContainers(t *testing.T) { + tmpDir := t.TempDir() + containerdRoot := filepath.Join(tmpDir, "containerd_root") + setupMockContainerd(t, containerdRoot, "ns-test-2", "container-cli-1") + + dockerRoot := filepath.Join(tmpDir, "docker_root") + setupMockDocker(t, dockerRoot, "container-docker-1") + + args := []string{"container-explorer", "--containerd-root", containerdRoot, "--docker-root", dockerRoot, "list", "containers"} + output, err := runApp(args) + if err != nil { + t.Fatalf("runApp failed: %v", err) + } + + if !strings.Contains(output, "container-cli-1") { + t.Errorf("expected output to contain 'container-cli-1', got:\n%s", output) + } + if !strings.Contains(output, "container-docker-1") { + t.Errorf("expected output to contain 'container-docker-1', got:\n%s", output) + } +} + +func TestCLI_InfoContainer(t *testing.T) { + tmpDir := t.TempDir() + containerdRoot := filepath.Join(tmpDir, "containerd_root") + setupMockContainerd(t, containerdRoot, "ns-test-3", "container-cli-2") + + args := []string{"container-explorer", "--containerd-root", containerdRoot, "info", "container", "container-cli-2"} + output, err := runApp(args) + if err != nil { + t.Fatalf("runApp failed: %v", err) + } + + if !strings.Contains(output, "container-cli-2") { + t.Errorf("expected output to contain 'container-cli-2', got:\n%s", output) + } +} + +func TestCLI_InspectContainer(t *testing.T) { + tmpDir := t.TempDir() + containerdRoot := filepath.Join(tmpDir, "containerd_root") + setupMockContainerd(t, containerdRoot, "ns-test-3", "container-cli-2") + + args := []string{"container-explorer", "--containerd-root", containerdRoot, "inspect", "container-cli-2"} + output, err := runApp(args) + if err != nil { + t.Fatalf("runApp failed: %v", err) + } + + if !strings.Contains(output, "container-cli-2") { + t.Errorf("expected output to contain 'container-cli-2', got:\n%s", output) + } +} + +func TestCLI_Drift(t *testing.T) { + tmpDir := t.TempDir() + containerdRoot := filepath.Join(tmpDir, "containerd_root") + setupMockContainerd(t, containerdRoot, "ns-test-4", "container-cli-3") + + // Setup databases and mock directory structure on disk for containerd drift + metaDir := filepath.Join(containerdRoot, "io.containerd.metadata.v1.bolt") + dbPath := filepath.Join(metaDir, "meta.db") + db, err := bolt.Open(dbPath, 0644, nil) + if err != nil { + t.Fatalf("failed to open meta.db: %v", err) + } + now := time.Now().UTC().Truncate(time.Second) + _ = db.Update(func(tx *bolt.Tx) error { + _ = createMetaSnapshot(tx, "ns-test-4", "overlayfs", "snap-container-cli-3", "snapshot-name-1", "snapshot-name-parent", now) + return createMetaSnapshot(tx, "ns-test-4", "overlayfs", "snapshot-name-parent", "snapshot-name-parent", "", now) + }) + db.Close() + + snapshotterDir := filepath.Join(containerdRoot, "io.containerd.snapshotter.v1.overlayfs") + _ = os.MkdirAll(snapshotterDir, 0755) + ssDBPath := filepath.Join(snapshotterDir, "metadata.db") + ssDB, err := bolt.Open(ssDBPath, 0644, nil) + if err != nil { + t.Fatalf("failed to open snapshotter metadata.db: %v", err) + } + _ = ssDB.Update(func(tx *bolt.Tx) error { + _ = createOverlaySnapshot(tx, "snapshot-name-1", 42, 2, "snapshot-name-parent", 10240, now) + return createOverlaySnapshot(tx, "snapshot-name-parent", 41, 2, "", 10240, now) + }) + ssDB.Close() + + upperDir := filepath.Join(snapshotterDir, "snapshots", "42", "fs") + _ = os.MkdirAll(upperDir, 0755) + _ = os.MkdirAll(filepath.Join(snapshotterDir, "snapshots", "42", "work"), 0755) + driftFile := filepath.Join(upperDir, "etc", "test-cli.conf") + _ = os.MkdirAll(filepath.Dir(driftFile), 0755) + _ = os.WriteFile(driftFile, []byte("some config change"), 0600) + + args := []string{"container-explorer", "--containerd-root", containerdRoot, "drift", "container-cli-3"} + output, err := runApp(args) + if err != nil { + t.Fatalf("runApp failed: %v", err) + } + + if !strings.Contains(output, "test-cli.conf") { + t.Errorf("expected output to contain 'test-cli.conf', got:\n%s", output) + } +} + +func TestCLI_Export(t *testing.T) { + tmpDir := t.TempDir() + containerdRoot := filepath.Join(tmpDir, "containerd_root") + setupMockContainerd(t, containerdRoot, "ns-test-5", "container-cli-4") + + metaDir := filepath.Join(containerdRoot, "io.containerd.metadata.v1.bolt") + dbPath := filepath.Join(metaDir, "meta.db") + db, err := bolt.Open(dbPath, 0644, nil) + if err != nil { + t.Fatalf("failed to open meta.db: %v", err) + } + now := time.Now().UTC().Truncate(time.Second) + _ = db.Update(func(tx *bolt.Tx) error { + _ = createMetaSnapshot(tx, "ns-test-5", "overlayfs", "snap-container-cli-4", "snapshot-name-1", "snapshot-name-parent", now) + return createMetaSnapshot(tx, "ns-test-5", "overlayfs", "snapshot-name-parent", "snapshot-name-parent", "", now) + }) + db.Close() + + snapshotterDir := filepath.Join(containerdRoot, "io.containerd.snapshotter.v1.overlayfs") + _ = os.MkdirAll(snapshotterDir, 0755) + ssDBPath := filepath.Join(snapshotterDir, "metadata.db") + ssDB, err := bolt.Open(ssDBPath, 0644, nil) + if err != nil { + t.Fatalf("failed to open snapshotter metadata.db: %v", err) + } + _ = ssDB.Update(func(tx *bolt.Tx) error { + _ = createOverlaySnapshot(tx, "snapshot-name-1", 42, 2, "snapshot-name-parent", 10240, now) + return createOverlaySnapshot(tx, "snapshot-name-parent", 41, 2, "", 10240, now) + }) + ssDB.Close() + + _ = os.MkdirAll(filepath.Join(snapshotterDir, "snapshots", "42", "work"), 0755) + _ = os.MkdirAll(filepath.Join(snapshotterDir, "snapshots", "42", "fs"), 0755) + _ = os.MkdirAll(filepath.Join(snapshotterDir, "snapshots", "41", "fs"), 0755) + + // Set up mock runner + origRunner := utils.Runner + mockRunner := &mockCommandRunner{ + Responses: map[string]mockCommandResponse{ + "losetup": {Stdout: "/dev/loop123\n", Err: nil}, + }, + } + utils.Runner = mockRunner + defer func() { utils.Runner = origRunner }() + + outputDir := filepath.Join(tmpDir, "output") + args := []string{"container-explorer", "--containerd-root", containerdRoot, "export", "--archive", "container-cli-4", outputDir} + _, err = runApp(args) + if err != nil { + t.Fatalf("runApp failed: %v", err) + } + + hasMount := false + hasUmount := false + hasTar := false + for _, c := range mockRunner.Calls { + if c.Name == "mount" { + hasMount = true + } + if c.Name == "umount" { + hasUmount = true + } + if c.Name == "tar" { + hasTar = true + } + } + + if !hasMount { + t.Errorf("expected 'mount' command to be executed") + } + if !hasUmount { + t.Errorf("expected 'umount' command to be executed") + } + if !hasTar { + t.Errorf("expected 'tar' command to be executed") + } +} + +func TestGetDockerDataRoot(t *testing.T) { + // Case 1: Config does not exist -> default + tmpDir := t.TempDir() + path := getDockerDataRoot(tmpDir) + if path != defaultDockerRootDir { + t.Errorf("expected default docker data root %q, got %q", defaultDockerRootDir, path) + } + + // Case 2: Config exists but is invalid JSON + dockerConfigDir := filepath.Join(tmpDir, "etc", "docker") + _ = os.MkdirAll(dockerConfigDir, 0755) + _ = os.WriteFile(filepath.Join(dockerConfigDir, "daemon.json"), []byte("{invalid-json}"), 0600) + path = getDockerDataRoot(tmpDir) + if path != defaultDockerRootDir { + t.Errorf("expected default docker data root on invalid JSON, got %q", path) + } + + // Case 3: Config exists, valid JSON, but missing data-root + _ = os.WriteFile(filepath.Join(dockerConfigDir, "daemon.json"), []byte(`{"debug": true}`), 0600) + path = getDockerDataRoot(tmpDir) + if path != defaultDockerRootDir { + t.Errorf("expected default docker data root on missing data-root, got %q", path) + } + + // Case 4: Config exists, valid JSON, custom data-root + _ = os.WriteFile(filepath.Join(dockerConfigDir, "daemon.json"), []byte(`{"data-root": "/custom/docker/root"}`), 0600) + path = getDockerDataRoot(tmpDir) + if path != "/custom/docker/root" { + t.Errorf("expected custom docker data root '/custom/docker/root', got %q", path) + } +} + +func TestGetContainerdDataDir(t *testing.T) { + // Case 1: Config does not exist -> default + tmpDir := t.TempDir() + path := getContainerdDataDir(tmpDir) + if path != defaultContainerdRootDir { + t.Errorf("expected default containerd root %q, got %q", defaultContainerdRootDir, path) + } + + // Case 2: Config exists but parsing fails (invalid TOML) + containerdConfigDir := filepath.Join(tmpDir, "etc", "containerd") + _ = os.MkdirAll(containerdConfigDir, 0755) + _ = os.WriteFile(filepath.Join(containerdConfigDir, "config.toml"), []byte("invalid-toml"), 0600) + path = getContainerdDataDir(tmpDir) + if path != defaultContainerdRootDir { + t.Errorf("expected default containerd root on invalid TOML, got %q", path) + } + + // Case 3: Config exists, valid TOML, but missing root + _ = os.WriteFile(filepath.Join(containerdConfigDir, "config.toml"), []byte(`version = 2`), 0600) + path = getContainerdDataDir(tmpDir) + if path != defaultContainerdRootDir { + t.Errorf("expected default containerd root on missing root key, got %q", path) + } + + // Case 4: Config exists, valid TOML, custom root + _ = os.WriteFile(filepath.Join(containerdConfigDir, "config.toml"), []byte(`root = "/custom/containerd/root"`), 0600) + path = getContainerdDataDir(tmpDir) + if path != "/custom/containerd/root" { + t.Errorf("expected custom containerd root '/custom/containerd/root', got %q", path) + } +} + +func TestGetFilterMap(t *testing.T) { + // Case 1: Empty filter + m := getFilterMap("") + if m != nil { + t.Errorf("expected nil filter map for empty string, got %v", m) + } + + // Case 2: Valid single pair + m = getFilterMap("key=val") + if len(m) != 1 || m["key"] != "val" { + t.Errorf("expected {'key': 'val'}, got %v", m) + } + + // Case 3: Valid multiple pairs with spaces + m = getFilterMap(" key1 = val1 , key2=val2 ") + if len(m) != 2 || m["key1"] != "val1" || m["key2"] != "val2" { + t.Errorf("expected {'key1': 'val1', 'key2': 'val2'}, got %v", m) + } + + // Case 4: Malformed filters (ignored) + m = getFilterMap("key1,key2=val2,key3=") + if len(m) != 2 || m["key2"] != "val2" || m["key3"] != "" { + t.Errorf("expected {'key2': 'val2', 'key3': ''}, got %v", m) + } +} diff --git a/explorers/containerd/containerd_test.go b/explorers/containerd/containerd_test.go index 55cd890..818d8f4 100644 --- a/explorers/containerd/containerd_test.go +++ b/explorers/containerd/containerd_test.go @@ -1260,6 +1260,100 @@ func TestListTasks(t *testing.T) { } } +func TestListTasks_WithCgroups(t *testing.T) { + tmpDir := t.TempDir() + containerdRoot := filepath.Join(tmpDir, "containerd_root") + _ = os.Mkdir(containerdRoot, 0755) + + metaDir := filepath.Join(containerdRoot, "io.containerd.metadata.v1.bolt") + _ = os.MkdirAll(metaDir, 0755) + dbPath := filepath.Join(metaDir, "meta.db") + db, err := bolt.Open(dbPath, 0644, nil) + if err != nil { + t.Fatalf("failed to open meta.db: %v", err) + } + + _ = db.Update(func(tx *bolt.Tx) error { + nsStore := metadata.NewNamespaceStore(tx) + return nsStore.Create(context.Background(), "ns1", nil) + }) + + dbStore := metadata.NewDB(db, nil, nil) + cStore := metadata.NewContainerStore(dbStore) + + specObj := oci.Spec{ + Linux: &oci.Linux{ + CgroupsPath: "/default/container-1", + }, + Process: &oci.Process{ + Args: []string{"sleep", "10"}, + }, + } + specJSON, _ := json.Marshal(specObj) + anySpec := &types.Any{ + TypeUrl: "types.containerd.io/opencontainers/runtime-spec/1/Spec", + Value: specJSON, + } + + c := containers.Container{ + ID: "container-1", + Image: "ubuntu:latest", + Snapshotter: "overlayfs", + SnapshotKey: "snap1", + Runtime: containers.RuntimeInfo{ + Name: "io.containerd.runc.v2", + }, + Spec: anySpec, + } + _, err = cStore.Create(namespaces.WithNamespace(context.Background(), "ns1"), c) + if err != nil { + db.Close() + t.Fatalf("failed to create container: %v", err) + } + db.Close() + + // Setup fake cgroups filesystem + imageRoot := filepath.Join(tmpDir, "image_root") + cgroupPath := filepath.Join(imageRoot, "sys", "fs", "cgroup", "default", "container-1") + if err := os.MkdirAll(cgroupPath, 0755); err != nil { + t.Fatalf("failed to create fake cgroup path: %v", err) + } + + // Write cgroup.events (RUNNING state) + if err := os.WriteFile(filepath.Join(cgroupPath, "cgroup.events"), []byte("populated 1\nfrozen 0\n"), 0600); err != nil { + t.Fatalf("failed to write cgroup.events: %v", err) + } + // Write cgroup.procs + if err := os.WriteFile(filepath.Join(cgroupPath, "cgroup.procs"), []byte("12345\n"), 0600); err != nil { + t.Fatalf("failed to write cgroup.procs: %v", err) + } + + sc, _ := explorers.NewSupportContainer("") + exp, err := NewExplorer(imageRoot, containerdRoot, "", "", sc) + if err != nil { + t.Fatalf("failed to create explorer: %v", err) + } + defer exp.Close() + + tasks, err := exp.ListTasks(namespaces.WithNamespace(context.Background(), "ns1")) + if err != nil { + t.Fatalf("ListTasks failed: %v", err) + } + + if len(tasks) != 1 { + t.Fatalf("expected 1 task, got %d", len(tasks)) + } + if tasks[0].Name != "container-1" { + t.Errorf("expected task name 'container-1', got '%s'", tasks[0].Name) + } + if tasks[0].Status != "RUNNING" { + t.Errorf("expected task status 'RUNNING', got '%s'", tasks[0].Status) + } + if tasks[0].PID != 12345 { + t.Errorf("expected task PID 12345, got %d", tasks[0].PID) + } +} + func TestGetContainerByID(t *testing.T) { tmpDir := t.TempDir() containerdRoot := filepath.Join(tmpDir, "containerd_root") @@ -1337,3 +1431,191 @@ func TestGetContainerByID(t *testing.T) { t.Errorf("expected container to be nil on error, got %+v", ctr) } } + +func TestResolveSnapshotter(t *testing.T) { + tmpDir := t.TempDir() + containerdRoot := filepath.Join(tmpDir, "containerd_root") + _ = os.MkdirAll(filepath.Join(containerdRoot, "io.containerd.metadata.v1.bolt"), 0755) + + dbPath := filepath.Join(containerdRoot, "io.containerd.metadata.v1.bolt", "meta.db") + db, err := bolt.Open(dbPath, 0644, nil) + if err != nil { + t.Fatalf("failed to open meta.db: %v", err) + } + + now := time.Now() + // Populate snapshot metadata in database + _ = db.Update(func(tx *bolt.Tx) error { + _ = createMetaSnapshot(tx, "ns-resolve", "overlayfs", "snap-resolved-key", "snapshot-actual-name", "snapshot-parent-name", now) + _ = createMetaSnapshot(tx, "ns-resolve", "native", "native-snap-key", "native-snapshot-actual-name", "", now) + return nil + }) + db.Close() + + sc, _ := explorers.NewSupportContainer("") + exp, err := NewExplorer("some_image_root", containerdRoot, "", "", sc) + if err != nil { + t.Fatalf("failed to create explorer: %v", err) + } + + ctx := namespaces.WithNamespace(context.Background(), "ns-resolve") + + // Case 1: Snapshotter and key are already populated -> returns immediately + c1 := containers.Container{ + ID: "container-1", + Snapshotter: "some-snapshotter", + SnapshotKey: "some-key", + } + err = exp.(*explorer).resolveSnapshotter(ctx, &c1) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if c1.Snapshotter != "some-snapshotter" || c1.SnapshotKey != "some-key" { + t.Errorf("expected no changes, got Snapshotter=%q, SnapshotKey=%q", c1.Snapshotter, c1.SnapshotKey) + } + + // Case 2: Snapshotter is empty, SnapshotKey is provided -> finds matching snapshotter + c2 := containers.Container{ + ID: "container-2", + Snapshotter: "", + SnapshotKey: "snap-resolved-key", + } + err = exp.(*explorer).resolveSnapshotter(ctx, &c2) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if c2.Snapshotter != "overlayfs" { + t.Errorf("expected Snapshotter to be resolved to 'overlayfs', got %q", c2.Snapshotter) + } + exp.Close() // Close explorer here so we can write to bolt DB below without blocking + + // Case 3: Snapshotter is empty, SnapshotKey is empty -> walks and matches by container.ID + db, _ = bolt.Open(dbPath, 0644, nil) + _ = db.Update(func(tx *bolt.Tx) error { + return createMetaSnapshot(tx, "ns-resolve", "overlayfs", "container-3-key-pattern", "snap-name", "", now) + }) + db.Close() + + // Recreate explorer + exp, err = NewExplorer("some_image_root", containerdRoot, "", "", sc) + if err != nil { + t.Fatalf("failed to recreate explorer: %v", err) + } + defer exp.Close() + + c3 := containers.Container{ + ID: "container-3", + Snapshotter: "", + SnapshotKey: "", + } + err = exp.(*explorer).resolveSnapshotter(ctx, &c3) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if c3.Snapshotter != "overlayfs" || c3.SnapshotKey != "container-3-key-pattern" { + t.Errorf("expected Snapshotter='overlayfs' and SnapshotKey='container-3-key-pattern', got Snapshotter=%q, SnapshotKey=%q", c3.Snapshotter, c3.SnapshotKey) + } + + // Case 4: No match -> fallback to overlayfs and container.ID + c4 := containers.Container{ + ID: "container-4", + Snapshotter: "", + SnapshotKey: "", + } + err = exp.(*explorer).resolveSnapshotter(ctx, &c4) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if c4.Snapshotter != "overlayfs" || c4.SnapshotKey != "container-4" { + t.Errorf("expected fallback to Snapshotter='overlayfs' and SnapshotKey='container-4', got Snapshotter=%q, SnapshotKey=%q", c4.Snapshotter, c4.SnapshotKey) + } + + // Case 5: Missing namespace -> fallback to default and returns error + c5 := containers.Container{ + ID: "container-5", + Snapshotter: "", + SnapshotKey: "", + } + err = exp.(*explorer).resolveSnapshotter(context.Background(), &c5) + if err == nil { + t.Errorf("expected error when namespace is missing from context") + } + if c5.Snapshotter != "overlayfs" || c5.SnapshotKey != "container-5" { + t.Errorf("expected fallback on error, got Snapshotter=%q, SnapshotKey=%q", c5.Snapshotter, c5.SnapshotKey) + } +} + +func TestGetContainerState(t *testing.T) { + tmpDir := t.TempDir() + containerdRoot := filepath.Join(tmpDir, "containerd_root") + metaDir := filepath.Join(containerdRoot, "io.containerd.metadata.v1.bolt") + _ = os.MkdirAll(metaDir, 0755) + db, err := bolt.Open(filepath.Join(metaDir, "meta.db"), 0644, nil) + if err != nil { + t.Fatalf("failed to create meta.db: %v", err) + } + db.Close() + + imageRoot := filepath.Join(tmpDir, "image_root") + + sc, _ := explorers.NewSupportContainer("") + exp, err := NewExplorer(imageRoot, containerdRoot, "", "", sc) + if err != nil { + t.Fatalf("failed to create explorer: %v", err) + } + defer exp.Close() + + ctr := explorers.Container{ + Container: containers.Container{ + ID: "container-state-test", + }, + Namespace: "ns-state", + } + + // Case 1: State directory does not exist -> error + _, err = exp.(*explorer).GetContainerState(context.Background(), ctr) + if err == nil { + t.Errorf("expected error when state directory is missing, got nil") + } + + // Case 2: State directory exists, but state.json is missing -> error + stateDir := filepath.Join(imageRoot, "run", "containerd", "runc", "ns-state", "container-state-test") + if err := os.MkdirAll(stateDir, 0755); err != nil { + t.Fatalf("failed to create state dir: %v", err) + } + + _, err = exp.(*explorer).GetContainerState(context.Background(), ctr) + if err == nil { + t.Errorf("expected error when state.json is missing, got nil") + } + + // Case 3: state.json is invalid JSON -> error + stateFile := filepath.Join(stateDir, "state.json") + if err := os.WriteFile(stateFile, []byte("{invalid-json"), 0600); err != nil { + t.Fatalf("failed to write state.json: %v", err) + } + _, err = exp.(*explorer).GetContainerState(context.Background(), ctr) + if err == nil { + t.Errorf("expected error when state.json is invalid JSON, got nil") + } + + // Case 4: Success path + validJSON := `{ + "init_process_pid": 54321, + "rootless": true + }` + if err := os.WriteFile(stateFile, []byte(validJSON), 0600); err != nil { + t.Fatalf("failed to write valid state.json: %v", err) + } + + state, err := exp.(*explorer).GetContainerState(context.Background(), ctr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if state.InitProcessPid != 54321 { + t.Errorf("expected init_process_pid to be 54321, got %d", state.InitProcessPid) + } + if !state.Rootless { + t.Errorf("expected rootless to be true, got false") + } +} diff --git a/explorers/docker/docker_test.go b/explorers/docker/docker_test.go index 9fd616a..e52a6d1 100644 --- a/explorers/docker/docker_test.go +++ b/explorers/docker/docker_test.go @@ -1150,3 +1150,67 @@ func TestContainerDrift(t *testing.T) { t.Errorf("expected drift path '%s', got '%s'", expectedPath, drift.AddedOrModified[0].FullPath) } } + +func TestGetRepositories(t *testing.T) { + // Case 1: Missing image repository directory entirely + tmpDir := t.TempDir() + dockerRoot := filepath.Join(tmpDir, "docker_root") + _ = os.MkdirAll(dockerRoot, 0755) + containerdRoot := filepath.Join(tmpDir, "containerd_root") + _ = os.MkdirAll(containerdRoot, 0755) + + exp, err := NewExplorer("", containerdRoot, dockerRoot) + if err != nil { + t.Fatalf("failed to create explorer: %v", err) + } + + _, err = exp.(*explorer).GetRepositories(context.Background()) + if err == nil { + t.Errorf("expected error when image repository directory is missing, got nil") + } + + // Case 2: Image repository directory exists, but no storage subdirectories + repositoriesDir := filepath.Join(dockerRoot, "image") + _ = os.MkdirAll(repositoriesDir, 0755) + repos, err := exp.(*explorer).GetRepositories(context.Background()) + if err != nil { + t.Errorf("expected no error when image directory has no subdirs, got %v", err) + } + if repos != nil { + t.Errorf("expected nil repositories, got %v", repos) + } + + // Case 3: Storage subdirectory exists, but repositories.json is missing + overlay2Dir := filepath.Join(repositoriesDir, "overlay2") + _ = os.MkdirAll(overlay2Dir, 0755) + _, err = exp.(*explorer).GetRepositories(context.Background()) + if err == nil { + t.Errorf("expected error when repositories.json is missing, got nil") + } + + // Case 4: repositories.json exists but is malformed + _ = os.WriteFile(filepath.Join(overlay2Dir, "repositories.json"), []byte("{malformed"), 0600) + _, err = exp.(*explorer).GetRepositories(context.Background()) + if err == nil { + t.Errorf("expected error when repositories.json is malformed, got nil") + } + + // Case 5: Success case + validJSON := `{ + "Repositories": { + "nginx": { + "nginx:latest": "sha256:605c77e624ddb75e6110f997c58876baa13f8754486b461117934b24a9dc3a85", + "nginx@sha256:0d17b565c37bcbd895e9d92315a05c1c3c9a29f762b011a10c54a66cd53c9b31": "sha256:605c77e624ddb75e6110f997c58876baa13f8754486b461117934b24a9dc3a85" + } + } + }` + _ = os.WriteFile(filepath.Join(overlay2Dir, "repositories.json"), []byte(validJSON), 0600) + repos, err = exp.(*explorer).GetRepositories(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expectedDigest := "sha256:605c77e624ddb75e6110f997c58876baa13f8754486b461117934b24a9dc3a85" + if repos[expectedDigest] != "nginx:latest" { + t.Errorf("expected mapping %s -> 'nginx:latest', got '%s'", expectedDigest, repos[expectedDigest]) + } +} diff --git a/explorers/fileinfo_test.go b/explorers/fileinfo_test.go index c99d1c3..c1a685e 100644 --- a/explorers/fileinfo_test.go +++ b/explorers/fileinfo_test.go @@ -202,3 +202,36 @@ func TestScanDiffDirectory(t *testing.T) { } } } + +func TestScanDiffDirectory_UnreadableDirectory(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + unreadableDir := filepath.Join(tmpDir, "unreadable_dir") + if err := os.Mkdir(unreadableDir, 0750); err != nil { + t.Fatalf("failed to create directory: %v", err) + } + + // Change permission to make it unreadable + if err := os.Chmod(unreadableDir, 0000); err != nil { + t.Fatalf("failed to chmod directory: %v", err) + } + defer func() { + _ = os.Chmod(unreadableDir, 0750) // Restore permission for cleanup + }() + + _, inaccessibleFiles, err := ScanDiffDirectory(tmpDir) + if err != nil { + t.Fatalf("ScanDiffDirectory failed: %v", err) + } + + // The unreadable directory itself should be returned as inaccessible + if len(inaccessibleFiles) != 1 { + t.Fatalf("expected 1 inaccessible file, got %d", len(inaccessibleFiles)) + } + + expectedPath := "/unreadable_dir" + if inaccessibleFiles[0].FullPath != expectedPath { + t.Errorf("expected inaccessible full path %q, got %q", expectedPath, inaccessibleFiles[0].FullPath) + } +} diff --git a/explorers/podman/podman_test.go b/explorers/podman/podman_test.go index ae42a5e..f27bfe9 100644 --- a/explorers/podman/podman_test.go +++ b/explorers/podman/podman_test.go @@ -118,6 +118,127 @@ func TestNewExplorer_SuccessRootless(t *testing.T) { } } +func TestNewExplorer_SuccessRootless_WithStorageConfGraphRoot(t *testing.T) { + tmpDir := t.TempDir() + + createMockPasswd(t, tmpDir, []string{ + "mockuser:x:1000:1000:Mock User:/home/mockuser:/bin/bash", + }) + + // Create custom graphroot directory on disk + customGraphRoot := filepath.Join(tmpDir, "custom", "containers", "storage") + if err := os.MkdirAll(customGraphRoot, 0755); err != nil { + t.Fatalf("failed to create custom graphroot: %v", err) + } + + // Create storage.conf pointing to custom graphroot + userConfigDir := filepath.Join(tmpDir, "home", "mockuser", ".config", "containers") + if err := os.MkdirAll(userConfigDir, 0755); err != nil { + t.Fatalf("failed to create user config dir: %v", err) + } + storageConf := ` +[storage] +graphroot = "/custom/containers/storage" +` + if err := os.WriteFile(filepath.Join(userConfigDir, "storage.conf"), []byte(storageConf), 0600); err != nil { + t.Fatalf("failed to write storage.conf: %v", err) + } + + exp, err := NewExplorer(tmpDir) + if err != nil { + t.Fatalf("NewExplorer failed: %v", err) + } + + pDirs := exp.(*explorer).podmanRootDirs + if len(pDirs) != 1 { + t.Fatalf("expected 1 podman root directory, got %d: %v", len(pDirs), pDirs) + } + + expectedDir := filepath.Join(tmpDir, "custom", "containers") + if pDirs[0] != expectedDir { + t.Errorf("expected podman root dir '%s', got '%s'", expectedDir, pDirs[0]) + } +} + +func TestNewExplorer_SuccessRootless_WithStorageConfRootlessStoragePath(t *testing.T) { + tmpDir := t.TempDir() + + createMockPasswd(t, tmpDir, []string{ + "mockuser:x:1000:1000:Mock User:/home/mockuser:/bin/bash", + }) + + // Create custom rootless storage path on disk + customRootlessPath := filepath.Join(tmpDir, "custom_rootless", "containers", "storage") + if err := os.MkdirAll(customRootlessPath, 0755); err != nil { + t.Fatalf("failed to create custom rootless path: %v", err) + } + + // Create storage.conf pointing to custom rootless storage path + userConfigDir := filepath.Join(tmpDir, "home", "mockuser", ".config", "containers") + if err := os.MkdirAll(userConfigDir, 0755); err != nil { + t.Fatalf("failed to create user config dir: %v", err) + } + storageConf := ` +[storage] +rootless_storage_path = "/custom_rootless/containers/storage" +` + if err := os.WriteFile(filepath.Join(userConfigDir, "storage.conf"), []byte(storageConf), 0600); err != nil { + t.Fatalf("failed to write storage.conf: %v", err) + } + + exp, err := NewExplorer(tmpDir) + if err != nil { + t.Fatalf("NewExplorer failed: %v", err) + } + + pDirs := exp.(*explorer).podmanRootDirs + if len(pDirs) != 1 { + t.Fatalf("expected 1 podman root directory, got %d: %v", len(pDirs), pDirs) + } + + expectedDir := filepath.Join(tmpDir, "custom_rootless", "containers") + if pDirs[0] != expectedDir { + t.Errorf("expected podman root dir '%s', got '%s'", expectedDir, pDirs[0]) + } +} + +func TestNewExplorer_SuccessRootless_WithStorageConfMalformed(t *testing.T) { + tmpDir := t.TempDir() + + createMockPasswd(t, tmpDir, []string{ + "mockuser:x:1000:1000:Mock User:/home/mockuser:/bin/bash", + }) + + // Malformed config, should fallback to default graphroot + defaultGraphRoot := filepath.Join(tmpDir, "home", "mockuser", ".local", "share", "containers", "storage") + if err := os.MkdirAll(defaultGraphRoot, 0755); err != nil { + t.Fatalf("failed to create default graphroot: %v", err) + } + + userConfigDir := filepath.Join(tmpDir, "home", "mockuser", ".config", "containers") + if err := os.MkdirAll(userConfigDir, 0755); err != nil { + t.Fatalf("failed to create user config dir: %v", err) + } + if err := os.WriteFile(filepath.Join(userConfigDir, "storage.conf"), []byte("invalid-toml-here"), 0600); err != nil { + t.Fatalf("failed to write storage.conf: %v", err) + } + + exp, err := NewExplorer(tmpDir) + if err != nil { + t.Fatalf("NewExplorer failed: %v", err) + } + + pDirs := exp.(*explorer).podmanRootDirs + if len(pDirs) != 1 { + t.Fatalf("expected 1 podman root directory, got %d: %v", len(pDirs), pDirs) + } + + expectedDir := filepath.Join(tmpDir, "home", "mockuser", ".local", "share", "containers") + if pDirs[0] != expectedDir { + t.Errorf("expected podman root dir '%s', got '%s'", expectedDir, pDirs[0]) + } +} + func TestListNamespacesAndSnapshots(t *testing.T) { // These are not implemented/supported in Podman, verify they return nil, nil tmpDir := t.TempDir() diff --git a/utils/export_test.go b/utils/export_test.go index 5f5bcf1..08ae1ea 100644 --- a/utils/export_test.go +++ b/utils/export_test.go @@ -232,6 +232,17 @@ func TestExportContainerImage_LosetupFailure(t *testing.T) { } } +func TestExportContainerImage_CalculateDirectorySizeFailure(t *testing.T) { + tmpDir := t.TempDir() + outputDir := filepath.Join(tmpDir, "output") + + // Call with non-existent mountpoint + err := ExportContainerImage(context.Background(), "ctr1", filepath.Join(tmpDir, "non_existent_mount"), outputDir) + if err == nil { + t.Error("ExportContainerImage expected error for non-existent mountpoint, got nil") + } +} + func TestExportContainerArchive_Success(t *testing.T) { tmpDir := t.TempDir() mountpoint := filepath.Join(tmpDir, "container_mount")