diff --git a/pkg/ast/executor.go b/pkg/ast/executor.go index ab84c6cb..5504e8bb 100644 --- a/pkg/ast/executor.go +++ b/pkg/ast/executor.go @@ -126,6 +126,7 @@ type DockerImageInfo struct { Namespace string Name string Tag string + Digest string // SHA256 digest if present (e.g., "sha256:76aae59c6259672ab68819b8960de5ef571394681089eab2b576f85f080c73ba") FullPath string } diff --git a/pkg/parser/dockerImageParser.go b/pkg/parser/dockerImageParser.go index fced439a..056a5cd4 100644 --- a/pkg/parser/dockerImageParser.go +++ b/pkg/parser/dockerImageParser.go @@ -6,7 +6,7 @@ import ( "github.com/CircleCI-Public/circleci-yaml-language-server/pkg/ast" ) -var dockerImageRegex = regexp.MustCompile(`^([a-z0-9\-_]+\/)?([a-z0-9\-_]+)(:(.*))?$`) +var dockerImageRegex = regexp.MustCompile(`^([a-z0-9\-_]+\/)?([a-z0-9\-_]+)(:([^@]*))?(@(.+))?$`) var aliasRemover = regexp.MustCompile(`^&[a-zA-Z0-9\-_]+\s*`) func ParseDockerImageValue(value string) ast.DockerImageInfo { @@ -18,13 +18,15 @@ func ParseDockerImageValue(value string) ast.DockerImageInfo { Namespace: "library", Name: "", Tag: "", + Digest: "", FullPath: value, } } namespace := imageName[0][1] repository := imageName[0][2] - tag := imageName[0][3] + tag := imageName[0][4] + digest := imageName[0][6] if namespace == "" { namespace = "library" @@ -33,15 +35,11 @@ func ParseDockerImageValue(value string) ast.DockerImageInfo { namespace = namespace[:len(namespace)-1] } - if tag != "" { - // The regex includes the leading ":", just snip it - tag = tag[1:] - } - return ast.DockerImageInfo{ Namespace: namespace, Name: repository, Tag: tag, + Digest: digest, FullPath: value, } } diff --git a/pkg/parser/dockerImageParser_test.go b/pkg/parser/dockerImageParser_test.go index 49afd0bc..75fbce74 100644 --- a/pkg/parser/dockerImageParser_test.go +++ b/pkg/parser/dockerImageParser_test.go @@ -26,6 +26,7 @@ func Test_parseDockerImageValue(t *testing.T) { Namespace: "library", Tag: "", Name: "node", + Digest: "", FullPath: "node", }, }, @@ -39,6 +40,7 @@ func Test_parseDockerImageValue(t *testing.T) { Namespace: "library", Tag: "", Name: "node", + Digest: "", FullPath: "node:", }, }, @@ -52,6 +54,7 @@ func Test_parseDockerImageValue(t *testing.T) { Namespace: "library", Tag: "12", Name: "node", + Digest: "", FullPath: "node:12", }, }, @@ -65,6 +68,7 @@ func Test_parseDockerImageValue(t *testing.T) { Namespace: "library", Tag: "latest", Name: "node", + Digest: "", FullPath: "node:latest", }, }, @@ -78,6 +82,7 @@ func Test_parseDockerImageValue(t *testing.T) { Namespace: "cimg", Tag: "latest", Name: "go", + Digest: "", FullPath: "cimg/go:latest", }, }, @@ -91,6 +96,7 @@ func Test_parseDockerImageValue(t *testing.T) { Namespace: "cimg", Tag: "", Name: "go", + Digest: "", FullPath: "cimg/go", }, }, @@ -104,6 +110,7 @@ func Test_parseDockerImageValue(t *testing.T) { Namespace: "cimg", Tag: "", Name: "go", + Digest: "", FullPath: "cimg/go:", }, }, @@ -117,9 +124,122 @@ func Test_parseDockerImageValue(t *testing.T) { Namespace: "cimg", Tag: "<>", Name: "go", + Digest: "", FullPath: "cimg/go:<>", }, }, + + { + name: "SHA256 digest without tag", + args: args{ + value: "cimg/node@sha256:76aae59c6259672ab68819b8960de5ef571394681089eab2b576f85f080c73ba", + }, + want: ast.DockerImageInfo{ + Namespace: "cimg", + Tag: "", + Name: "node", + Digest: "sha256:76aae59c6259672ab68819b8960de5ef571394681089eab2b576f85f080c73ba", + FullPath: "cimg/node@sha256:76aae59c6259672ab68819b8960de5ef571394681089eab2b576f85f080c73ba", + }, + }, + + { + name: "SHA256 digest with tag", + args: args{ + value: "cimg/node:22.11.0@sha256:76aae59c6259672ab68819b8960de5ef571394681089eab2b576f85f080c73ba", + }, + want: ast.DockerImageInfo{ + Namespace: "cimg", + Tag: "22.11.0", + Name: "node", + Digest: "sha256:76aae59c6259672ab68819b8960de5ef571394681089eab2b576f85f080c73ba", + FullPath: "cimg/node:22.11.0@sha256:76aae59c6259672ab68819b8960de5ef571394681089eab2b576f85f080c73ba", + }, + }, + + { + name: "Library image with SHA256 digest", + args: args{ + value: "node:18@sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + }, + want: ast.DockerImageInfo{ + Namespace: "library", + Tag: "18", + Name: "node", + Digest: "sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + FullPath: "node:18@sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + }, + }, + + { + name: "Library image with only SHA256 digest", + args: args{ + value: "node@sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + }, + want: ast.DockerImageInfo{ + Namespace: "library", + Tag: "", + Name: "node", + Digest: "sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + FullPath: "node@sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + }, + }, + + { + name: "Parse any string after @ as digest - short string", + args: args{ + value: "cimg/go:1.24@foo", + }, + want: ast.DockerImageInfo{ + Namespace: "cimg", + Name: "go", + Tag: "1.24", + Digest: "foo", + FullPath: "cimg/go:1.24@foo", + }, + }, + + { + name: "Parse any string after @ as digest - no sha256 prefix", + args: args{ + value: "cimg/node:18@abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + }, + want: ast.DockerImageInfo{ + Namespace: "cimg", + Name: "node", + Tag: "18", + Digest: "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + FullPath: "cimg/node:18@abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + }, + }, + + { + name: "Parse any string after @ as digest - wrong hash length", + args: args{ + value: "cimg/go:latest@sha256:abc123", + }, + want: ast.DockerImageInfo{ + Namespace: "cimg", + Name: "go", + Tag: "latest", + Digest: "sha256:abc123", + FullPath: "cimg/go:latest@sha256:abc123", + }, + }, + + { + name: "Parse any string after @ as digest - non-hex characters", + args: args{ + value: "node:alpine@sha256:ghijklmnopqrstuvwxyz1234567890abcdef1234567890abcdef1234567890", + }, + want: ast.DockerImageInfo{ + Namespace: "library", + Name: "node", + Tag: "alpine", + Digest: "sha256:ghijklmnopqrstuvwxyz1234567890abcdef1234567890abcdef1234567890", + FullPath: "node:alpine@sha256:ghijklmnopqrstuvwxyz1234567890abcdef1234567890abcdef1234567890", + }, + }, } for _, tt := range tests { diff --git a/pkg/parser/validate/dockerImage.go b/pkg/parser/validate/dockerImage.go index 2d32a117..94f3387e 100644 --- a/pkg/parser/validate/dockerImage.go +++ b/pkg/parser/validate/dockerImage.go @@ -1,6 +1,7 @@ package validate import ( + "regexp" "strings" "github.com/CircleCI-Public/circleci-yaml-language-server/pkg/ast" @@ -11,6 +12,12 @@ import ( "go.lsp.dev/protocol" ) +var validDigestRegex = regexp.MustCompile(`^sha256:[a-f0-9]{64}$`) + +func isValidDockerDigest(digest string) bool { + return validDigestRegex.MatchString(digest) +} + func DoesDockerImageExists(img *ast.DockerImage, cache *utils.DockerCache, api dockerhub.DockerHubAPI) bool { cachedDockerImage := cache.Get(img.Image.FullPath) diff --git a/pkg/parser/validate/dockerImage_test.go b/pkg/parser/validate/dockerImage_test.go index a6279385..09294638 100644 --- a/pkg/parser/validate/dockerImage_test.go +++ b/pkg/parser/validate/dockerImage_test.go @@ -200,6 +200,110 @@ workflows: image-tag: tag `, }, + { + Name: "Should give no diagnostic on valid SHA256 digest with tag", + + Diagnostics: []ComparableDiagnostic{}, + + MockAPI: DockerHubMock{}, + + YamlContent: `version: 2.1 + +executors: + some-executor: + docker: + - image: cimg/node:22.11.0@sha256:76aae59c6259672ab68819b8960de5ef571394681089eab2b576f85f080c73ba`, + }, + { + Name: "Should give no diagnostic on valid SHA256 digest without tag", + + Diagnostics: []ComparableDiagnostic{}, + + MockAPI: DockerHubMock{}, + + YamlContent: `version: 2.1 + +executors: + some-executor: + docker: + - image: cimg/node@sha256:76aae59c6259672ab68819b8960de5ef571394681089eab2b576f85f080c73ba`, + }, + { + Name: "Should give error on invalid digest format - too short", + + Diagnostics: []ComparableDiagnostic{ + { + Severity: protocol.DiagnosticSeverityError, + Message: "Invalid Docker image digest format \"foo\". Expected format: sha256:<64 hex characters>", + }, + }, + + MockAPI: DockerHubMock{}, + + YamlContent: `version: 2.1 + +executors: + some-executor: + docker: + - image: cimg/go:1.24@foo`, + }, + { + Name: "Should give error on invalid digest format - wrong prefix", + + Diagnostics: []ComparableDiagnostic{ + { + Severity: protocol.DiagnosticSeverityError, + Message: "Invalid Docker image digest format \"abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890\". Expected format: sha256:<64 hex characters>", + }, + }, + + MockAPI: DockerHubMock{}, + + YamlContent: `version: 2.1 + +executors: + some-executor: + docker: + - image: cimg/node:18@abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890`, + }, + { + Name: "Should give error on invalid digest format - wrong hash length", + + Diagnostics: []ComparableDiagnostic{ + { + Severity: protocol.DiagnosticSeverityError, + Message: "Invalid Docker image digest format \"sha256:abc123\". Expected format: sha256:<64 hex characters>", + }, + }, + + MockAPI: DockerHubMock{}, + + YamlContent: `version: 2.1 + +executors: + some-executor: + docker: + - image: cimg/go:latest@sha256:abc123`, + }, + { + Name: "Should give error on invalid digest format - non-hex characters", + + Diagnostics: []ComparableDiagnostic{ + { + Severity: protocol.DiagnosticSeverityError, + Message: "Invalid Docker image digest format \"sha256:ghijklmnopqrstuvwxyz1234567890abcdef1234567890abcdef1234567890\". Expected format: sha256:<64 hex characters>", + }, + }, + + MockAPI: DockerHubMock{}, + + YamlContent: `version: 2.1 + +executors: + some-executor: + docker: + - image: node:alpine@sha256:ghijklmnopqrstuvwxyz1234567890abcdef1234567890abcdef1234567890`, + }, } for _, tt := range testCases { diff --git a/pkg/parser/validate/executor.go b/pkg/parser/validate/executor.go index 3be0a302..c29205c7 100644 --- a/pkg/parser/validate/executor.go +++ b/pkg/parser/validate/executor.go @@ -196,37 +196,54 @@ func (val Validate) validateDockerExecutor(executor ast.DockerExecutor) { ), ) } else { - // Validate image tag - imgTag := img.Image.Tag - - if imgTag == "" { - imgTag = "latest" - } - - tagExists := DoesTagExist(&img, imgTag, &val.Cache.DockerTagsCache, val.APIs.DockerHub) - - if !tagExists { - actions := GetImageTagActions(&val.Doc, &img, &val.Cache.DockerTagsCache, val.APIs.DockerHub) + // Validate digest format if present + if img.Image.Digest != "" && !isValidDockerDigest(img.Image.Digest) { val.addDiagnostic( - utils.CreateDiagnosticFromRange( + utils.CreateErrorDiagnosticFromRange( img.ImageRange, - protocol.DiagnosticSeverityError, - fmt.Sprintf("Docker image \"%s\" has no tag \"%s\"", img.Image.FullPath, imgTag), - actions, + fmt.Sprintf( + "Invalid Docker image digest format \"%s\". Expected format: sha256:<64 hex characters>", + img.Image.Digest, + ), ), ) + continue } - if tagExists && img.Image.Tag == "" { - actions := GetImageTagActions(&val.Doc, &img, &val.Cache.DockerTagsCache, val.APIs.DockerHub) - val.addDiagnostic( - utils.CreateDiagnosticFromRange( - img.ImageRange, - protocol.DiagnosticSeverityHint, - "It is recommended to set explicit tags", - actions, - ), - ) + // Exclude cases with only digest without tags, such as node@sha256:... + if img.Image.Tag != "" || img.Image.Digest == "" { + // Validate image tag + imgTag := img.Image.Tag + + if imgTag == "" { + imgTag = "latest" + } + + tagExists := DoesTagExist(&img, imgTag, &val.Cache.DockerTagsCache, val.APIs.DockerHub) + + if !tagExists { + actions := GetImageTagActions(&val.Doc, &img, &val.Cache.DockerTagsCache, val.APIs.DockerHub) + val.addDiagnostic( + utils.CreateDiagnosticFromRange( + img.ImageRange, + protocol.DiagnosticSeverityError, + fmt.Sprintf("Docker image \"%s\" has no tag \"%s\"", img.Image.FullPath, imgTag), + actions, + ), + ) + } + + if tagExists && img.Image.Tag == "" { + actions := GetImageTagActions(&val.Doc, &img, &val.Cache.DockerTagsCache, val.APIs.DockerHub) + val.addDiagnostic( + utils.CreateDiagnosticFromRange( + img.ImageRange, + protocol.DiagnosticSeverityHint, + "It is recommended to set explicit tags", + actions, + ), + ) + } } }