package cache import ( "fmt" "io" "os" "path/filepath" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" bolt "go.etcd.io/bbolt" "github.com/aquasecurity/trivy/pkg/fanal/types" ) func newTempDB(t *testing.T, dbPath string) (string, error) { dir := t.TempDir() if dbPath != "" { d := filepath.Join(dir, "fanal") if err := os.MkdirAll(d, 0700); err != nil { return "", err } dst := filepath.Join(d, "fanal.db") if _, err := copyFile(dbPath, dst); err != nil { return "", err } } return dir, nil } func copyFile(src, dst string) (int64, error) { sourceFileStat, err := os.Stat(src) if err != nil { return 0, err } if !sourceFileStat.Mode().IsRegular() { return 0, fmt.Errorf("%s is not a regular file", src) } source, err := os.Open(src) if err != nil { return 0, err } defer source.Close() destination, err := os.Create(dst) if err != nil { return 0, err } defer destination.Close() n, err := io.Copy(destination, source) return n, err } func TestFSCache_GetBlob(t *testing.T) { type args struct { layerID string } tests := []struct { name string dbPath string args args want types.BlobInfo wantErr bool }{ { name: "happy path", dbPath: "testdata/fanal.db", args: args{ layerID: "sha256:24df0d4e20c0f42d3703bf1f1db2bdd77346c7956f74f423603d651e8e5ae8a7/11101", }, want: types.BlobInfo{ SchemaVersion: 2, OS: types.OS{ Family: "alpine", Name: "3.10", }, }, }, { name: "sad path", dbPath: "testdata/fanal.db", args: args{ layerID: "sha256:unknown", }, wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tmpDir, err := newTempDB(t, tt.dbPath) require.NoError(t, err) fs, err := NewFSCache(tmpDir) require.NoError(t, err) defer func() { _ = fs.Clear() _ = fs.Close() }() got, err := fs.GetBlob(tt.args.layerID) assert.Equal(t, tt.wantErr, err != nil, err) assert.Equal(t, tt.want, got) }) } } func TestFSCache_PutBlob(t *testing.T) { type fields struct { db *bolt.DB directory string } type args struct { diffID string layerInfo types.BlobInfo } tests := []struct { name string fields fields args args want string wantLayerID string wantErr string }{ { name: "happy path", args: args{ diffID: "sha256:24df0d4e20c0f42d3703bf1f1db2bdd77346c7956f74f423603d651e8e5ae8a7", layerInfo: types.BlobInfo{ SchemaVersion: 1, OS: types.OS{ Family: "alpine", Name: "3.10", }, Repository: &types.Repository{ Family: "alpine", Release: "3.10", }, }, }, want: ` { "SchemaVersion": 1, "OS": { "Family": "alpine", "Name": "3.10" }, "Repository": { "Family": "alpine", "Release": "3.10" } }`, wantLayerID: "", }, { name: "happy path: different decompressed layer ID", args: args{ diffID: "sha256:dffd9992ca398466a663c87c92cfea2a2db0ae0cf33fcb99da60eec52addbfc5", layerInfo: types.BlobInfo{ SchemaVersion: 1, Digest: "sha256:dffd9992ca398466a663c87c92cfea2a2db0ae0cf33fcb99da60eec52addbfc5", DiffID: "sha256:dab15cac9ebd43beceeeda3ce95c574d6714ed3d3969071caead678c065813ec", OS: types.OS{ Family: "alpine", Name: "3.10", }, Repository: &types.Repository{ Family: "alpine", Release: "3.10", }, PackageInfos: []types.PackageInfo{ { FilePath: "lib/apk/db/installed", Packages: types.Packages{ { Name: "musl", Version: "1.1.22-r3", }, }, }, }, Applications: []types.Application{ { Type: "composer", FilePath: "php-app/composer.lock", Packages: types.Packages{ { Name: "guzzlehttp/guzzle", Version: "6.2.0", }, { Name: "guzzlehttp/promises", Version: "v1.3.1", }, }, }, }, OpaqueDirs: []string{"php-app/"}, WhiteoutFiles: []string{"etc/foobar"}, }, }, want: ` { "SchemaVersion": 1, "Digest": "sha256:dffd9992ca398466a663c87c92cfea2a2db0ae0cf33fcb99da60eec52addbfc5", "DiffID": "sha256:dab15cac9ebd43beceeeda3ce95c574d6714ed3d3969071caead678c065813ec", "OS": { "Family": "alpine", "Name": "3.10" }, "Repository": { "Family": "alpine", "Release": "3.10" }, "PackageInfos": [ { "FilePath": "lib/apk/db/installed", "Packages": [ { "Name": "musl", "Version": "1.1.22-r3", "Identifier": {}, "Layer": {} } ] } ], "Applications": [ { "Type": "composer", "FilePath": "php-app/composer.lock", "Packages": [ { "Name":"guzzlehttp/guzzle", "Version":"6.2.0", "Identifier": {}, "Layer": {} }, { "Name":"guzzlehttp/promises", "Version":"v1.3.1", "Identifier": {}, "Layer": {} } ] } ], "OpaqueDirs": [ "php-app/" ], "WhiteoutFiles": [ "etc/foobar" ] }`, wantLayerID: "sha256:dab15cac9ebd43beceeeda3ce95c574d6714ed3d3969071caead678c065813ec", }, { name: "sad path closed DB", args: args{ diffID: "sha256:dab15cac9ebd43beceeeda3ce95c574d6714ed3d3969071caead678c065813ec/11111", }, wantErr: "database not open", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tmpDir, err := newTempDB(t, "") require.NoError(t, err) fs, err := NewFSCache(tmpDir) require.NoError(t, err) defer func() { _ = fs.Clear() _ = fs.Close() }() if strings.HasPrefix(tt.name, "sad") { require.NoError(t, fs.Close()) } err = fs.PutBlob(tt.args.diffID, tt.args.layerInfo) if tt.wantErr != "" { require.Error(t, err) assert.Contains(t, err.Error(), tt.wantErr, tt.name) return } else { require.NoError(t, err, tt.name) } fs.db.View(func(tx *bolt.Tx) error { layerBucket := tx.Bucket([]byte(blobBucket)) b := layerBucket.Get([]byte(tt.args.diffID)) assert.JSONEq(t, tt.want, string(b)) return nil }) }) } } func TestFSCache_PutArtifact(t *testing.T) { type args struct { imageID string imageConfig types.ArtifactInfo } tests := []struct { name string args args want string wantErr string }{ { name: "happy path", args: args{ imageID: "sha256:58701fd185bda36cab0557bb6438661831267aa4a9e0b54211c4d5317a48aff4", imageConfig: types.ArtifactInfo{ SchemaVersion: 1, Architecture: "amd64", Created: time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC), DockerVersion: "18.06.1-ce", OS: "linux", HistoryPackages: types.Packages{ { Name: "musl", Version: "1.2.3", }, }, }, }, want: ` { "SchemaVersion": 1, "Architecture": "amd64", "Created": "2020-01-02T03:04:05Z", "DockerVersion": "18.06.1-ce", "OS": "linux", "HistoryPackages": [ { "Name": "musl", "Version": "1.2.3", "Identifier": {}, "Layer": {} } ] } `, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tmpDir, err := newTempDB(t, "") require.NoError(t, err) fs, err := NewFSCache(tmpDir) require.NoError(t, err) defer func() { _ = fs.Clear() _ = fs.Close() }() err = fs.PutArtifact(tt.args.imageID, tt.args.imageConfig) if tt.wantErr != "" { require.Error(t, err) assert.Contains(t, err.Error(), tt.wantErr, tt.name) return } else { require.NoError(t, err, tt.name) } err = fs.db.View(func(tx *bolt.Tx) error { // check decompressedDigestBucket imageBucket := tx.Bucket([]byte(artifactBucket)) b := imageBucket.Get([]byte(tt.args.imageID)) assert.JSONEq(t, tt.want, string(b)) return nil }) require.NoError(t, err) }) } } func TestFSCache_MissingBlobs(t *testing.T) { type args struct { imageID string layerIDs []string } tests := []struct { name string dbPath string args args wantMissingImage bool wantMissingLayerIDs []string wantErr string }{ { name: "happy path", dbPath: "testdata/fanal.db", args: args{ imageID: "sha256:58701fd185bda36cab0557bb6438661831267aa4a9e0b54211c4d5317a48aff4/1", layerIDs: []string{ "sha256:24df0d4e20c0f42d3703bf1f1db2bdd77346c7956f74f423603d651e8e5ae8a7/11101", "sha256:dffd9992ca398466a663c87c92cfea2a2db0ae0cf33fcb99da60eec52addbfc5/11101", "sha256:dab15cac9ebd43beceeeda3ce95c574d6714ed3d3969071caead678c065813ec/11101", }, }, wantMissingImage: false, wantMissingLayerIDs: []string{ "sha256:dffd9992ca398466a663c87c92cfea2a2db0ae0cf33fcb99da60eec52addbfc5/11101", "sha256:dab15cac9ebd43beceeeda3ce95c574d6714ed3d3969071caead678c065813ec/11101", }, }, { name: "happy path: broken layer JSON", dbPath: "testdata/broken-layer.db", args: args{ imageID: "sha256:58701fd185bda36cab0557bb6438661831267aa4a9e0b54211c4d5317a48aff4", layerIDs: []string{ "sha256:24df0d4e20c0f42d3703bf1f1db2bdd77346c7956f74f423603d651e8e5ae8a7", }, }, wantMissingImage: true, wantMissingLayerIDs: []string{ "sha256:24df0d4e20c0f42d3703bf1f1db2bdd77346c7956f74f423603d651e8e5ae8a7", }, }, { name: "happy path: broken image JSON", dbPath: "testdata/broken-image.db", args: args{ imageID: "sha256:58701fd185bda36cab0557bb6438661831267aa4a9e0b54211c4d5317a48aff4", layerIDs: []string{ "sha256:24df0d4e20c0f42d3703bf1f1db2bdd77346c7956f74f423603d651e8e5ae8a7", }, }, wantMissingImage: true, wantMissingLayerIDs: []string{ "sha256:24df0d4e20c0f42d3703bf1f1db2bdd77346c7956f74f423603d651e8e5ae8a7", }, }, { name: "happy path: the schema version of image JSON doesn't match", dbPath: "testdata/different-image-schema.db", args: args{ imageID: "sha256:58701fd185bda36cab0557bb6438661831267aa4a9e0b54211c4d5317a48aff4", layerIDs: []string{ "sha256:24df0d4e20c0f42d3703bf1f1db2bdd77346c7956f74f423603d651e8e5ae8a7", }, }, wantMissingImage: true, wantMissingLayerIDs: []string{ "sha256:24df0d4e20c0f42d3703bf1f1db2bdd77346c7956f74f423603d651e8e5ae8a7", }, }, { name: "happy path: new config analyzer", dbPath: "testdata/fanal.db", args: args{ imageID: "sha256:58701fd185bda36cab0557bb6438661831267aa4a9e0b54211c4d5317a48aff4/2", layerIDs: []string{ "sha256:24df0d4e20c0f42d3703bf1f1db2bdd77346c7956f74f423603d651e8e5ae8a7/11102", }, }, wantMissingImage: true, wantMissingLayerIDs: []string{ "sha256:24df0d4e20c0f42d3703bf1f1db2bdd77346c7956f74f423603d651e8e5ae8a7/11102", }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tmpDir, err := newTempDB(t, tt.dbPath) require.NoError(t, err) fs, err := NewFSCache(tmpDir) require.NoError(t, err) defer func() { _ = fs.Clear() _ = fs.Close() }() gotMissingImage, gotMissingLayerIDs, err := fs.MissingBlobs(tt.args.imageID, tt.args.layerIDs) if tt.wantErr != "" { assert.ErrorContains(t, err, tt.wantErr, tt.name) return } require.NoError(t, err, tt.name) assert.Equal(t, tt.wantMissingImage, gotMissingImage, tt.name) assert.Equal(t, tt.wantMissingLayerIDs, gotMissingLayerIDs, tt.name) }) } }