diff --git a/frontend/dockerfile/dockerfile2llb/convert.go b/frontend/dockerfile/dockerfile2llb/convert.go index abab2ddcb628..fd0dc09eb761 100644 --- a/frontend/dockerfile/dockerfile2llb/convert.go +++ b/frontend/dockerfile/dockerfile2llb/convert.go @@ -236,7 +236,9 @@ func toDispatchState(ctx context.Context, dt []byte, opt ConvertOpt) (*dispatchS info := argInfo{definition: metaArg, location: cmd.Location()} if v, ok := opt.BuildArgs[metaArg.Key]; !ok { if metaArg.Value != nil { - *metaArg.Value, info.deps, _ = shlex.ProcessWordWithMatches(*metaArg.Value, metaArgsToMap(optMetaArgs)) + result, _ := shlex.ProcessWordWithMatches(*metaArg.Value, metaArgsToMap(optMetaArgs)) + *metaArg.Value = result.Word + info.deps = result.Matched } } else { metaArg.Value = &v @@ -258,7 +260,15 @@ func toDispatchState(ctx context.Context, dt []byte, opt ConvertOpt) (*dispatchS // set base state for every image for i, st := range stages { - name, used, err := shlex.ProcessWordWithMatches(st.BaseName, metaArgsToMap(optMetaArgs)) + result, err := shlex.ProcessWordWithMatches(st.BaseName, metaArgsToMap(optMetaArgs)) + name := result.Word + used := result.Matched + if len(result.Unmatched) > 0 { + for unmatched := range result.Unmatched { + msg := linter.RuleUndeclaredArgInFrom.Format(unmatched) + linter.RuleUndeclaredArgInFrom.Run(opt.Warn, st.Location, msg) + } + } if err != nil { return nil, parser.WithLocation(err, st.Location) } @@ -279,7 +289,16 @@ func toDispatchState(ctx context.Context, dt []byte, opt ConvertOpt) (*dispatchS } if v := st.Platform; v != "" { - v, u, err := shlex.ProcessWordWithMatches(v, metaArgsToMap(optMetaArgs)) + result, err := shlex.ProcessWordWithMatches(v, metaArgsToMap(optMetaArgs)) + v := result.Word + u := result.Matched + + if len(result.Unmatched) > 0 { + for unmatched := range result.Unmatched { + msg := linter.RuleUndeclaredArgInFrom.Format(unmatched) + linter.RuleUndeclaredArgInFrom.Run(opt.Warn, st.Location, msg) + } + } if err != nil { return nil, parser.WithLocation(errors.Wrapf(err, "failed to process arguments for platform %s", v), st.Location) } diff --git a/frontend/dockerfile/dockerfile_lint_test.go b/frontend/dockerfile/dockerfile_lint_test.go index 89ce50023e5a..c65e028a5b14 100644 --- a/frontend/dockerfile/dockerfile_lint_test.go +++ b/frontend/dockerfile/dockerfile_lint_test.go @@ -30,6 +30,7 @@ var lintTests = integration.TestFuncs( testReservedStageName, testMaintainerDeprecated, testWarningsBeforeError, + testUndeclaredArg, ) func testStageName(t *testing.T, sb integration.Sandbox) { @@ -350,6 +351,40 @@ FROM ${BAR} AS base }) } +func testUndeclaredArg(t *testing.T, sb integration.Sandbox) { + dockerfile := []byte(` +ARG base=scratch +FROM $base +COPY Dockerfile . +`) + checkLinterWarnings(t, sb, &lintTestParams{Dockerfile: dockerfile}) + + dockerfile = []byte(` +ARG platform=linux/amd64 +FROM --platform=$platform scratch +COPY Dockerfile . +`) + checkLinterWarnings(t, sb, &lintTestParams{Dockerfile: dockerfile}) + + dockerfile = []byte(` +ARG tag=latest +FROM busybox:${tag}${version} AS b +COPY Dockerfile . +`) + checkLinterWarnings(t, sb, &lintTestParams{ + Dockerfile: dockerfile, + Warnings: []expectedLintWarning{ + { + RuleName: "UndeclaredArgInFrom", + Description: "FROM command must use declared ARGs", + Detail: "FROM argument 'version' is not declared", + Level: 1, + Line: 3, + }, + }, + }) +} + func checkUnmarshal(t *testing.T, sb integration.Sandbox, lintTest *lintTestParams) { destDir, err := os.MkdirTemp("", "buildkit") require.NoError(t, err) diff --git a/frontend/dockerfile/dockerfile_test.go b/frontend/dockerfile/dockerfile_test.go index c0711080f842..97badbdd5e67 100644 --- a/frontend/dockerfile/dockerfile_test.go +++ b/frontend/dockerfile/dockerfile_test.go @@ -231,6 +231,10 @@ func init() { } } +func TestLint(t *testing.T) { + integration.Run(t, lintTests, opts...) +} + func TestIntegration(t *testing.T) { integration.Run(t, allTests, opts...) diff --git a/frontend/dockerfile/linter/ruleset.go b/frontend/dockerfile/linter/ruleset.go index e5f280263d76..f21e62a85520 100644 --- a/frontend/dockerfile/linter/ruleset.go +++ b/frontend/dockerfile/linter/ruleset.go @@ -63,4 +63,11 @@ var ( return "Maintainer instruction is deprecated in favor of using label" }, } + RuleUndeclaredArgInFrom = LinterRule[func(string) string]{ + Name: "UndeclaredArgInFrom", + Description: "FROM command must use declared ARGs", + Format: func(baseArg string) string { + return fmt.Sprintf("FROM argument '%s' is not declared", baseArg) + }, + } ) diff --git a/frontend/dockerfile/shell/lex.go b/frontend/dockerfile/shell/lex.go index 7f6934a91356..c0a7bbfb9853 100644 --- a/frontend/dockerfile/shell/lex.go +++ b/frontend/dockerfile/shell/lex.go @@ -57,12 +57,22 @@ func (s *Lex) ProcessWordWithMap(word string, env map[string]string) (string, er return word, err } +type ProcessWordMatchesResult struct { + Word string + Matched map[string]struct{} + Unmatched map[string]struct{} +} + // ProcessWordWithMatches will use the 'env' list of environment variables, // replace any env var references in 'word' and return the env that were used. -func (s *Lex) ProcessWordWithMatches(word string, env map[string]string) (string, map[string]struct{}, error) { +func (s *Lex) ProcessWordWithMatches(word string, env map[string]string) (ProcessWordMatchesResult, error) { sw := s.init(word, env) word, _, err := sw.process(word) - return word, sw.matches, err + return ProcessWordMatchesResult{ + Word: word, + Matched: sw.matches, + Unmatched: sw.nonmatches, + }, err } func (s *Lex) ProcessWordsWithMap(word string, env map[string]string) ([]string, error) { @@ -79,6 +89,7 @@ func (s *Lex) init(word string, env map[string]string) *shellWord { rawQuotes: s.RawQuotes, rawEscapes: s.RawEscapes, matches: make(map[string]struct{}), + nonmatches: make(map[string]struct{}), } sw.scanner.Init(strings.NewReader(word)) return sw @@ -98,6 +109,7 @@ type shellWord struct { skipUnsetEnv bool skipProcessQuotes bool matches map[string]struct{} + nonmatches map[string]struct{} } func (sw *shellWord) process(source string) (string, []string, error) { @@ -511,6 +523,7 @@ func (sw *shellWord) getEnv(name string) (string, bool) { return value, true } } + sw.nonmatches[name] = struct{}{} return "", false } diff --git a/frontend/dockerfile/shell/lex_test.go b/frontend/dockerfile/shell/lex_test.go index 2dc01d8e0ecd..288fcf2be697 100644 --- a/frontend/dockerfile/shell/lex_test.go +++ b/frontend/dockerfile/shell/lex_test.go @@ -221,7 +221,7 @@ func TestShellParser4Words(t *testing.T) { } func TestGetEnv(t *testing.T) { - sw := &shellWord{envs: nil, matches: make(map[string]struct{})} + sw := &shellWord{envs: nil, matches: make(map[string]struct{}), nonmatches: make(map[string]struct{})} getEnv := func(name string) string { value, _ := sw.getEnv(name) @@ -480,7 +480,9 @@ func TestProcessWithMatches(t *testing.T) { for _, c := range tc { c := c t.Run(c.input, func(t *testing.T) { - w, matches, err := shlex.ProcessWordWithMatches(c.input, c.envs) + result, err := shlex.ProcessWordWithMatches(c.input, c.envs) + w := result.Word + matches := result.Matched if c.expectedErr { require.Error(t, err) return @@ -505,30 +507,30 @@ func TestProcessWithMatchesPlatform(t *testing.T) { version = "v1.2.3" ) - w, _, err := shlex.ProcessWordWithMatches(release, map[string]string{ + results, err := shlex.ProcessWordWithMatches(release, map[string]string{ "VERSION": version, "TARGETOS": "linux", "TARGETARCH": "arm", "TARGETVARIANT": "v7", }) require.NoError(t, err) - require.Equal(t, "something-v1.2.3.linux-arm-v7.tar.gz", w) + require.Equal(t, "something-v1.2.3.linux-arm-v7.tar.gz", results.Word) - w, _, err = shlex.ProcessWordWithMatches(release, map[string]string{ + results, err = shlex.ProcessWordWithMatches(release, map[string]string{ "VERSION": version, "TARGETOS": "linux", "TARGETARCH": "arm64", "TARGETVARIANT": "", }) require.NoError(t, err) - require.Equal(t, "something-v1.2.3.linux-arm64.tar.gz", w) + require.Equal(t, "something-v1.2.3.linux-arm64.tar.gz", results.Word) - w, _, err = shlex.ProcessWordWithMatches(release, map[string]string{ + results, err = shlex.ProcessWordWithMatches(release, map[string]string{ "VERSION": version, "TARGETOS": "linux", "TARGETARCH": "arm64", // No "TARGETVARIANT": "", }) require.NoError(t, err) - require.Equal(t, "something-v1.2.3.linux-arm64.tar.gz", w) + require.Equal(t, "something-v1.2.3.linux-arm64.tar.gz", results.Word) }