diff --git a/README.md b/README.md index 4e2f8ae..01da578 100644 --- a/README.md +++ b/README.md @@ -155,7 +155,7 @@ runs without configuration; the rest are `off` until you enable them: | --- | --- | --- | | [`correctness`](https://bare-devcontainer.github.io/decolint/rules/#correctness) | `error` | 13 | | [`security`](https://bare-devcontainer.github.io/decolint/rules/#security) | `off` | 11 | -| [`reproducibility`](https://bare-devcontainer.github.io/decolint/rules/#reproducibility) | `off` | 4 | +| [`reproducibility`](https://bare-devcontainer.github.io/decolint/rules/#reproducibility) | `off` | 6 | | [`style`](https://bare-devcontainer.github.io/decolint/rules/#style) | `off` | 2 | diff --git a/containerdef/containerdef.go b/containerdef/containerdef.go new file mode 100644 index 0000000..3c79913 --- /dev/null +++ b/containerdef/containerdef.go @@ -0,0 +1,150 @@ +// Package containerdef reads what a devcontainer.json declares its container is made from: an image +// it pulls, a Dockerfile it builds, or a Compose service it attaches to. It reads the declaration +// and nothing else — resolving it is the caller's, whether that is the merge fetching what the +// declaration names or a lint rule reading it from the linted directory. +// +// Every reader returns the byte offsets of both the key and the value, so a caller can anchor at +// whichever the reader of its output expects to see. +package containerdef + +import "github.com/tailscale/hujson" + +// Decl is a property's value as declared, with where it is written. +type Decl struct { + // KeyOffset is the byte offset of the property name. + KeyOffset int + // ValueOffset is the byte offset of the value. + ValueOffset int +} + +// Image returns the image "image" names. ok is false when the property is absent or is not a string. +func Image(obj *hujson.Object) (ref string, decl Decl, ok bool) { + m := memberNamed(obj, "image") + if m == nil { + return "", Decl{}, false + } + lit, isLit := m.Value.Value.(hujson.Literal) + if !isLit || lit.Kind() != '"' { + return "", Decl{}, false + } + return lit.String(), declOf(m), true +} + +// Dockerfile returns the Dockerfile path the configuration builds from. The specification defines +// two mutually exclusive forms, the top-level "dockerFile" and the nested "build.dockerfile"; the +// top-level one wins, as the reference implementation prefers it (getDockerfile: 'dockerFile' in +// config ? config.dockerFile : config.build.dockerfile). ok is false when neither names one. +func Dockerfile(obj *hujson.Object) (path string, decl Decl, ok bool) { + if m := memberNamed(obj, "dockerFile"); m != nil { + if lit, isLit := m.Value.Value.(hujson.Literal); isLit && lit.Kind() == '"' { + return lit.String(), declOf(m), true + } + } + if build := buildObject(obj); build != nil { + if m := memberNamed(build, "dockerfile"); m != nil { + if lit, isLit := m.Value.Value.(hujson.Literal); isLit && lit.Kind() == '"' { + return lit.String(), declOf(m), true + } + } + } + return "", Decl{}, false +} + +// BuildOptions returns the "build" options that shape what the Dockerfile produces: the arguments +// passed to the build, and the stage it stops at. Both are zero when "build" declares none. +func BuildOptions(obj *hujson.Object) (args map[string]string, target string) { + build := buildObject(obj) + if build == nil { + return nil, "" + } + if m := memberNamed(build, "args"); m != nil { + if argsObj, isObj := m.Value.Value.(*hujson.Object); isObj { + for _, arg := range argsObj.Members { + name, nameOK := arg.Name.Value.(hujson.Literal) + value, valueOK := arg.Value.Value.(hujson.Literal) + if !nameOK || name.Kind() != '"' || !valueOK || value.Kind() != '"' { + continue + } + if args == nil { + args = map[string]string{} + } + args[name.String()] = value.String() + } + } + } + if m := memberNamed(build, "target"); m != nil { + if lit, isLit := m.Value.Value.(hujson.Literal); isLit && lit.Kind() == '"' { + target = lit.String() + } + } + return args, target +} + +// ComposeFiles returns the Compose file paths "dockerComposeFile" names, in the order they are +// declared, later ones overriding earlier ones. The property is a single path or an array of them. +// +// declared reports whether the property is there at all, which is what tells a Compose-based +// configuration from one that builds or pulls an image. A declaration whose value, or whose element, +// is not a string contributes no path, so declared can be true with no paths: the configuration says +// it is Compose-based while naming nothing readable. +func ComposeFiles(obj *hujson.Object) (paths []string, decl Decl, declared bool) { + m := memberNamed(obj, "dockerComposeFile") + if m == nil { + return nil, Decl{}, false + } + switch v := m.Value.Value.(type) { + case hujson.Literal: + if v.Kind() == '"' { + paths = []string{v.String()} + } + case *hujson.Array: + for _, e := range v.Elements { + if lit, isLit := e.Value.(hujson.Literal); isLit && lit.Kind() == '"' { + paths = append(paths, lit.String()) + } + } + } + return paths, declOf(m), true +} + +// ComposeService returns the Compose service "service" names, the one the dev container runs in. ok +// is false when the property is absent or is not a string. +func ComposeService(obj *hujson.Object) (name string, decl Decl, ok bool) { + m := memberNamed(obj, "service") + if m == nil { + return "", Decl{}, false + } + lit, isLit := m.Value.Value.(hujson.Literal) + if !isLit || lit.Kind() != '"' { + return "", Decl{}, false + } + return lit.String(), declOf(m), true +} + +// buildObject returns the "build" object, or nil when the configuration declares none or declares it +// as something other than an object. +func buildObject(obj *hujson.Object) *hujson.Object { + m := memberNamed(obj, "build") + if m == nil { + return nil + } + build, ok := m.Value.Value.(*hujson.Object) + if !ok { + return nil + } + return build +} + +// memberNamed returns obj's member named name, or nil if obj has no such member. +func memberNamed(obj *hujson.Object, name string) *hujson.ObjectMember { + for i := range obj.Members { + if lit, ok := obj.Members[i].Name.Value.(hujson.Literal); ok && lit.String() == name { + return &obj.Members[i] + } + } + return nil +} + +func declOf(m *hujson.ObjectMember) Decl { + return Decl{KeyOffset: m.Name.StartOffset, ValueOffset: m.Value.StartOffset} +} diff --git a/containerdef/containerdef_test.go b/containerdef/containerdef_test.go new file mode 100644 index 0000000..7ae38bd --- /dev/null +++ b/containerdef/containerdef_test.go @@ -0,0 +1,183 @@ +package containerdef_test + +import ( + "testing" + + "github.com/bare-devcontainer/decolint/containerdef" + "github.com/google/go-cmp/cmp" + "github.com/tailscale/hujson" +) + +// object parses src and returns its root object, failing the test if it is not one. +func object(t *testing.T, src string) *hujson.Object { + t.Helper() + + value, err := hujson.Parse([]byte(src)) + if err != nil { + t.Fatalf("parse: %v", err) + } + obj, ok := value.Value.(*hujson.Object) + if !ok { + t.Fatalf("parsed %s, want an object", src) + } + return obj +} + +func TestImage(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + src string + want string + ok bool + }{ + {"a named image", `{"image": "ubuntu:24.04"}`, "ubuntu:24.04", true}, + {"no image", `{"name": "x"}`, "", false}, + {"a non-string image", `{"image": 42}`, "", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, _, ok := containerdef.Image(object(t, tt.src)) + if got != tt.want || ok != tt.ok { + t.Errorf("Image = (%q, %v), want (%q, %v)", got, ok, tt.want, tt.ok) + } + }) + } +} + +func TestDockerfile(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + src string + want string + ok bool + }{ + {"build.dockerfile", `{"build": {"dockerfile": "Dockerfile"}}`, "Dockerfile", true}, + {"the legacy top-level property", `{"dockerFile": "Dockerfile"}`, "Dockerfile", true}, + { + // The reference implementation reads the top-level property first, so it is the one + // built when a configuration carries both. + "the top-level property wins", + `{"dockerFile": "top", "build": {"dockerfile": "nested"}}`, + "top", true, + }, + {"neither", `{"image": "ubuntu:24.04"}`, "", false}, + {"a non-object build", `{"build": "Dockerfile"}`, "", false}, + {"a non-string dockerfile", `{"build": {"dockerfile": 42}}`, "", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, _, ok := containerdef.Dockerfile(object(t, tt.src)) + if got != tt.want || ok != tt.ok { + t.Errorf("Dockerfile = (%q, %v), want (%q, %v)", got, ok, tt.want, tt.ok) + } + }) + } +} + +func TestDockerfile_Offsets(t *testing.T) { + t.Parallel() + + // `{"build": {"dockerfile": "Dockerfile"}}` — the key opens at 11 and the value at 25. + _, decl, ok := containerdef.Dockerfile(object(t, `{"build": {"dockerfile": "Dockerfile"}}`)) + if !ok { + t.Fatal("Dockerfile: not found") + } + if decl.KeyOffset != 11 || decl.ValueOffset != 25 { + t.Errorf("decl = %+v, want {KeyOffset:11 ValueOffset:25}", decl) + } +} + +func TestBuildOptions(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + src string + wantArgs map[string]string + wantTgt string + }{ + {"args and target", `{"build": {"args": {"A": "1"}, "target": "dev"}}`, map[string]string{"A": "1"}, "dev"}, + {"neither", `{"build": {"dockerfile": "Dockerfile"}}`, nil, ""}, + {"no build", `{"image": "ubuntu:24.04"}`, nil, ""}, + {"a non-object build", `{"build": 42}`, nil, ""}, + {"a non-string arg is left out", `{"build": {"args": {"A": 1, "B": "2"}}}`, map[string]string{"B": "2"}, ""}, + {"a non-string target is no target", `{"build": {"target": 42}}`, nil, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + args, target := containerdef.BuildOptions(object(t, tt.src)) + if diff := cmp.Diff(tt.wantArgs, args); diff != "" { + t.Errorf("args mismatch (-want +got):\n%s", diff) + } + if target != tt.wantTgt { + t.Errorf("target = %q, want %q", target, tt.wantTgt) + } + }) + } +} + +func TestComposeFiles(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + src string + want []string + wantDeclared bool + }{ + {"a single path", `{"dockerComposeFile": "docker-compose.yml"}`, []string{"docker-compose.yml"}, true}, + {"a list of paths", `{"dockerComposeFile": ["a.yml", "b.yml"]}`, []string{"a.yml", "b.yml"}, true}, + {"not declared", `{"image": "ubuntu:24.04"}`, nil, false}, + { + // The configuration says it is Compose-based, so a caller must not fall back to another + // form, even though there is no path to read. + "declared but not a path", + `{"dockerComposeFile": 42}`, + nil, true, + }, + {"a non-string element is left out", `{"dockerComposeFile": ["a.yml", 42]}`, []string{"a.yml"}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, _, declared := containerdef.ComposeFiles(object(t, tt.src)) + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Errorf("paths mismatch (-want +got):\n%s", diff) + } + if declared != tt.wantDeclared { + t.Errorf("declared = %v, want %v", declared, tt.wantDeclared) + } + }) + } +} + +func TestComposeService(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + src string + want string + ok bool + }{ + {"a named service", `{"service": "app"}`, "app", true}, + {"no service", `{"dockerComposeFile": "docker-compose.yml"}`, "", false}, + {"a non-string service", `{"service": 42}`, "", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, _, ok := containerdef.ComposeService(object(t, tt.src)) + if got != tt.want || ok != tt.ok { + t.Errorf("ComposeService = (%q, %v), want (%q, %v)", got, ok, tt.want, tt.ok) + } + }) + } +} diff --git a/feature/compose.go b/feature/compose.go index 6a237a7..0b41a69 100644 --- a/feature/compose.go +++ b/feature/compose.go @@ -7,6 +7,7 @@ import ( "path/filepath" "strings" + "github.com/bare-devcontainer/decolint/containerdef" "github.com/compose-spec/compose-go/v2/loader" "github.com/compose-spec/compose-go/v2/template" "github.com/compose-spec/compose-go/v2/types" @@ -31,14 +32,15 @@ func composeContributors(ctx context.Context, f *Fetcher, fsRoot *os.Root, confi if !ok { return nil, false, nil } - paths, anchor, ok := composeFilePaths(obj) - if !ok { + paths, decl, declared := containerdef.ComposeFiles(obj) + if !declared { return nil, false, nil } + anchor := decl.KeyOffset if len(paths) == 0 { return nil, true, nil } - service, ok := composeServiceName(obj) + service, _, ok := containerdef.ComposeService(obj) if !ok { return nil, true, nil } @@ -64,46 +66,6 @@ func composeContributors(ctx context.Context, f *Fetcher, fsRoot *os.Root, confi return contribs, true, nil } -// composeFilePaths returns the Compose file paths root declares, relative to the devcontainer.json -// directory, with the byte offset of the "dockerComposeFile" key. The property is a single path or -// an array of paths, later ones overriding earlier ones. ok reports whether the member exists at -// all; a present but unusable value returns ok=true with no paths, so the caller still treats -// Compose as declared and does not fall back to "build" or "image". -func composeFilePaths(obj *hujson.Object) (paths []string, anchor int, ok bool) { - i := findMember(obj, "dockerComposeFile") - if i < 0 { - return nil, 0, false - } - anchor = obj.Members[i].Name.StartOffset - switch v := obj.Members[i].Value.Value.(type) { - case hujson.Literal: - if v.Kind() == '"' { - paths = []string{v.String()} - } - case *hujson.Array: - for _, e := range v.Elements { - if lit, isLit := e.Value.(hujson.Literal); isLit && lit.Kind() == '"' { - paths = append(paths, lit.String()) - } - } - } - return paths, anchor, true -} - -// composeServiceName returns the "service" property of root; ok is false when it is absent or not -// a string. -func composeServiceName(obj *hujson.Object) (string, bool) { - i := findMember(obj, "service") - if i < 0 { - return "", false - } - lit, ok := obj.Members[i].Value.Value.(hujson.Literal) - if !ok || lit.Kind() != '"' { - return "", false - } - return lit.String(), true -} - // loadComposeService reads each Compose file at baseDir and returns the named service resolved by // compose-go, merged across all of them per the Compose specification, with "extends" and "include" // resolved as "docker compose config" would. "${...}" variables are interpolated from env as diff --git a/feature/merge.go b/feature/merge.go index 731e04e..eefb0ee 100644 --- a/feature/merge.go +++ b/feature/merge.go @@ -7,6 +7,7 @@ import ( "path/filepath" "slices" + "github.com/bare-devcontainer/decolint/containerdef" "github.com/tailscale/hujson" ) @@ -85,11 +86,12 @@ func dockerfileContributors(ctx context.Context, f *Fetcher, fsRoot *os.Root, co if !ok { return nil, false, nil } - path, anchor, ok := dockerfilePath(obj) + path, decl, ok := containerdef.Dockerfile(obj) if !ok { return nil, false, nil } - args, target := buildOptions(obj) + anchor := decl.KeyOffset + args, target := containerdef.BuildOptions(obj) src, err := readBounded(fsRoot, filepath.Join(configDir, path), maxDockerfileBytes) if err != nil { return nil, true, err @@ -105,62 +107,6 @@ func dockerfileContributors(ctx context.Context, f *Fetcher, fsRoot *os.Root, co return contribs, true, nil } -// dockerfilePath returns the Dockerfile path root declares, with the byte offset of the declaring -// key. The Dev Container schema defines two mutually exclusive Dockerfile forms: the top-level -// "dockerFile" property and the nested "build.dockerfile". The reference implementation prefers the -// top-level property (getDockerfile: 'dockerFile' in config ? config.dockerFile : -// config.build.dockerfile), so it is checked first; a valid configuration declares only one. -func dockerfilePath(obj *hujson.Object) (string, int, bool) { - if i := findMember(obj, "dockerFile"); i >= 0 { - if lit, ok := obj.Members[i].Value.Value.(hujson.Literal); ok && lit.Kind() == '"' { - return lit.String(), obj.Members[i].Name.StartOffset, true - } - } - if i := findMember(obj, "build"); i >= 0 { - if buildObj, ok := obj.Members[i].Value.Value.(*hujson.Object); ok { - if j := findMember(buildObj, "dockerfile"); j >= 0 { - if lit, ok := buildObj.Members[j].Value.Value.(hujson.Literal); ok && lit.Kind() == '"' { - return lit.String(), buildObj.Members[j].Name.StartOffset, true - } - } - } - } - return "", 0, false -} - -// buildOptions extracts the "args" and "target" of the "/build" object. -func buildOptions(obj *hujson.Object) (args map[string]string, target string) { - i := findMember(obj, "build") - if i < 0 { - return nil, "" - } - buildObj, isObj := obj.Members[i].Value.Value.(*hujson.Object) - if !isObj { - return nil, "" - } - if j := findMember(buildObj, "args"); j >= 0 { - if argsObj, isObj := buildObj.Members[j].Value.Value.(*hujson.Object); isObj { - for _, m := range argsObj.Members { - name, nameOK := m.Name.Value.(hujson.Literal) - val, valOK := m.Value.Value.(hujson.Literal) - if !nameOK || name.Kind() != '"' || !valOK || val.Kind() != '"' { - continue - } - if args == nil { - args = map[string]string{} - } - args[name.String()] = val.String() - } - } - } - if j := findMember(buildObj, "target"); j >= 0 { - if lit, isStr := buildObj.Members[j].Value.Value.(hujson.Literal); isStr && lit.Kind() == '"' { - target = lit.String() - } - } - return args, target -} - // readBounded reads the file at path through fsRoot, so its resolution cannot escape fsRoot's // boundary, rejecting a file larger than maxBytes before reading it into memory. func readBounded(fsRoot *os.Root, path string, maxBytes int64) ([]byte, error) { @@ -186,20 +132,15 @@ func imageContributors(ctx context.Context, f *Fetcher, root *hujson.Value) ([]* if !ok { return nil, nil } - i := findMember(obj, "image") - if i < 0 { - return nil, nil - } - lit, ok := obj.Members[i].Value.Value.(hujson.Literal) - if !ok || lit.Kind() != '"' { + image, decl, ok := containerdef.Image(obj) + if !ok { return nil, nil } - image := lit.String() entries, err := f.FetchImageMetadata(ctx, image) if err != nil { return nil, err } - anchor := obj.Members[i].Name.StartOffset + anchor := decl.KeyOffset contribs := make([]*contributor, 0, len(entries)) for _, md := range entries { contribs = append(contribs, &contributor{ref: image, anchor: anchor, md: md}) diff --git a/go.mod b/go.mod index 91d6c2b..1475b0f 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/opencontainers/image-spec v1.1.1 github.com/spf13/pflag v1.0.10 github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd + go.yaml.in/yaml/v3 v3.0.4 golang.org/x/sys v0.47.0 golang.org/x/term v0.45.0 oras.land/oras-go/v2 v2.6.2 @@ -200,7 +201,6 @@ require ( go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect go.opentelemetry.io/otel/trace v1.44.0 // indirect go.uber.org/automaxprocs v1.5.3 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.4 // indirect gocloud.dev v0.45.0 // indirect golang.org/x/crypto v0.53.0 // indirect diff --git a/rules/compose.go b/rules/compose.go new file mode 100644 index 0000000..1f5c58c --- /dev/null +++ b/rules/compose.go @@ -0,0 +1,183 @@ +package rules + +import ( + "path" + "strings" + + "github.com/bare-devcontainer/decolint/linter" + "go.yaml.in/yaml/v3" +) + +// composeSource is what the Compose service a dev container runs is made from: the image it pulls, +// or the build that produces one. At most one is set; neither is when the service names an image +// this cannot resolve (see [composeServiceSource]). +type composeSource struct { + image string + build *composeBuild +} + +// composeBuild is a Compose service's "build", reduced to what a rule reading its Dockerfile needs. +// Exactly one of dockerfile and inline is set. +type composeBuild struct { + // dockerfile is the Dockerfile's path, relative to the directory being linted. + dockerfile string + // inline is the Dockerfile's content, for a build that gives it as "dockerfile_inline". + inline string + // target is the stage "target" names, empty when it names none. + target string + // args are the build arguments, which a FROM of the Dockerfile is expanded against. + args map[string]string +} + +// composeService is the part of a Compose service definition that says what the service runs, or +// that the definition is not all in this file. +type composeService struct { + Image string `yaml:"image"` + // Build is untyped because Compose writes it two ways: the build context as a string, or an + // object of build options. See [composeServiceBuild]. + Build any `yaml:"build"` + Extends any `yaml:"extends"` +} + +// composeDoc is the part of a Compose file that defines the services, or pulls definitions in from +// files of its own. +type composeDoc struct { + Services map[string]composeService `yaml:"services"` + Include any `yaml:"include"` +} + +// composeServiceSource returns what the named Compose service is made from, reading the files at +// paths in the order they are declared, each later one overriding the earlier ones as Compose merges +// them. +// +// This reads the declared files and nothing else, which is narrower than the resolution the merge +// performs through compose-go (see feature's loadComposeService: it applies "extends" and "include" +// and interpolates variables, reading files outside the linted directory and an environment a rule +// does not have). ok is therefore false for everything this cannot settle from the files +// themselves, so that what it does report is what the full resolution would report too: +// +// - a file that cannot be read (see [readConfigFile]) or does not parse; +// - a file declaring "include", or a service declaring "extends", either of which can define or +// override the service from a file not named here; +// - a service none of the files defines; +// - a service more than one file gives a "build", which Compose merges option by option. +// +// A service whose image or build context is written with a variable resolves to neither an image nor +// a build: the value comes from the environment. The same is true of a build context naming a remote +// repository, which is no path in the linted directory. +func composeServiceSource(dir linter.Dir, paths []string, service string) (composeSource, bool) { + var src composeSource + var found, built bool + for _, p := range paths { + data, ok := readConfigFile(dir, p) + if !ok { + return composeSource{}, false + } + var doc composeDoc + if err := yaml.Unmarshal(data, &doc); err != nil || doc.Include != nil { + return composeSource{}, false + } + svc, ok := doc.Services[service] + if !ok { + continue + } + found = true + if svc.Extends != nil { + return composeSource{}, false + } + if svc.Image != "" { + src.image = svc.Image + } + if svc.Build == nil { + continue + } + if built { + return composeSource{}, false + } + built = true + src.build = composeServiceBuild(svc.Build, path.Dir(p)) + } + if !found { + return composeSource{}, false + } + if src.build != nil { + // The "image" of a service that builds names what the build produces, not what it starts + // from, so the build is the whole answer. + return composeSource{build: src.build}, true + } + // Both "${VAR}" and the bare "$VAR" Compose accepts leave the image unresolved here. + if strings.Contains(src.image, "$") { + src.image = "" + } + return src, true +} + +// composeServiceBuild reads a service's "build" in either of the forms Compose writes it, resolving +// the Dockerfile against baseDir, the directory of the Compose file declaring the build, as Compose +// resolves it against the file it is written in. It returns nil for a build whose Dockerfile is not +// a path in the linted directory. +// +// The Dockerfile defaults to "Dockerfile" in the build context, and the context to the Compose +// file's own directory. +func composeServiceBuild(value any, baseDir string) *composeBuild { + var context, dockerfile, inline, target string + var args map[string]string + switch v := value.(type) { + case string: + // The short form is the build context alone. + context = v + case map[string]any: + context, _ = v["context"].(string) + dockerfile, _ = v["dockerfile"].(string) + inline, _ = v["dockerfile_inline"].(string) + target, _ = v["target"].(string) + args = composeBuildArgs(v["args"]) + default: + return nil + } + + if inline != "" { + return &composeBuild{inline: inline, target: target, args: args} + } + // A context naming a remote repository, or one written as a variable, is no path the Dockerfile + // can be read through. + if strings.Contains(context, "://") || strings.Contains(context, "$") { + return nil + } + if dockerfile == "" { + dockerfile = "Dockerfile" + } + if strings.Contains(dockerfile, "$") { + return nil + } + return &composeBuild{dockerfile: path.Join(baseDir, context, dockerfile), target: target, args: args} +} + +// composeBuildArgs reads a build's "args", which Compose writes as a mapping of names to values or +// as a list of "NAME=value" entries. An entry with no value takes it from the environment, which is +// not the configuration's to give, and is left out along with any value that is not a string. +func composeBuildArgs(value any) map[string]string { + args := map[string]string{} + switch v := value.(type) { + case map[string]any: + for name, raw := range v { + if s, ok := raw.(string); ok { + args[name] = s + } + } + case []any: + for _, raw := range v { + entry, ok := raw.(string) + if !ok { + continue + } + if name, val, found := strings.Cut(entry, "="); found { + args[name] = val + } + } + } + if len(args) == 0 { + return nil + } + return args +} diff --git a/rules/compose_test.go b/rules/compose_test.go new file mode 100644 index 0000000..15551a3 --- /dev/null +++ b/rules/compose_test.go @@ -0,0 +1,211 @@ +package rules + +import ( + "testing" + "testing/fstest" + + "github.com/bare-devcontainer/decolint/linter" + "github.com/google/go-cmp/cmp" +) + +// TestComposeServiceSource covers what a service is read as — an image, a build, or neither — since +// which one it is decides whether the Compose rule or the Dockerfile rules report on it. +func TestComposeServiceSource(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + files map[string]string + paths []string + wantOK bool + wantImage string + wantBuild *composeBuild + }{ + { + name: "an image", + files: map[string]string{"docker-compose.yml": "services:\n app:\n image: ubuntu:24.04\n"}, + paths: []string{"docker-compose.yml"}, + wantOK: true, + wantImage: "ubuntu:24.04", + }, + { + name: "a build in the long form", + files: map[string]string{"docker-compose.yml": "services:\n app:\n build:\n context: .\n dockerfile: Dockerfile\n"}, + paths: []string{"docker-compose.yml"}, + wantOK: true, + wantBuild: &composeBuild{dockerfile: "Dockerfile"}, + }, + { + name: "a build in the short form defaults the Dockerfile", + files: map[string]string{"docker-compose.yml": "services:\n app:\n build: .\n"}, + paths: []string{"docker-compose.yml"}, + wantOK: true, + wantBuild: &composeBuild{dockerfile: "Dockerfile"}, + }, + { + name: "a build resolves against the Compose file's own directory", + files: map[string]string{"compose/docker-compose.yml": "services:\n app:\n build:\n context: ..\n dockerfile: build/Dockerfile\n"}, + paths: []string{"compose/docker-compose.yml"}, + wantOK: true, + wantBuild: &composeBuild{dockerfile: "build/Dockerfile"}, + }, + { + name: "a build carries its args", + files: map[string]string{"docker-compose.yml": "services:\n app:\n build:\n context: .\n args:\n VARIANT: \"24.04\"\n"}, + paths: []string{"docker-compose.yml"}, + wantOK: true, + wantBuild: &composeBuild{dockerfile: "Dockerfile", args: map[string]string{"VARIANT": "24.04"}}, + }, + { + // Compose accepts a list of "NAME=value" entries as readily as a mapping. + name: "a build carries its args written as a list", + files: map[string]string{"docker-compose.yml": "services:\n app:\n build:\n context: .\n args:\n - VARIANT=24.04\n - FROM_ENV\n"}, + paths: []string{"docker-compose.yml"}, + wantOK: true, + wantBuild: &composeBuild{dockerfile: "Dockerfile", args: map[string]string{"VARIANT": "24.04"}}, + }, + { + name: "a build arg that is not a string is left out", + files: map[string]string{"docker-compose.yml": "services:\n app:\n build:\n context: .\n args:\n - 42\n"}, + paths: []string{"docker-compose.yml"}, + wantOK: true, + wantBuild: &composeBuild{dockerfile: "Dockerfile"}, + }, + { + name: "a build carries its target", + files: map[string]string{"docker-compose.yml": "services:\n app:\n build:\n context: .\n target: dev\n"}, + paths: []string{"docker-compose.yml"}, + wantOK: true, + wantBuild: &composeBuild{dockerfile: "Dockerfile", target: "dev"}, + }, + { + name: "an inline Dockerfile is its own content", + files: map[string]string{"docker-compose.yml": "services:\n app:\n build:\n dockerfile_inline: |\n FROM ubuntu:latest\n"}, + paths: []string{"docker-compose.yml"}, + wantOK: true, + wantBuild: &composeBuild{inline: "FROM ubuntu:latest\n"}, + }, + { + name: "a build overrides an image", + files: map[string]string{"docker-compose.yml": "services:\n app:\n image: built:latest\n build: .\n"}, + paths: []string{"docker-compose.yml"}, + wantOK: true, + wantBuild: &composeBuild{dockerfile: "Dockerfile"}, + }, + { + name: "a later file overriding the image wins", + files: map[string]string{ + "a.yml": "services:\n app:\n image: ubuntu:latest\n", + "b.yml": "services:\n app:\n image: ubuntu:24.04\n", + }, + paths: []string{"a.yml", "b.yml"}, + wantOK: true, + wantImage: "ubuntu:24.04", + }, + { + // Compose merges a build option by option across files, which this does not model. + name: "a build declared by two files is not resolved", + files: map[string]string{ + "a.yml": "services:\n app:\n build: .\n", + "b.yml": "services:\n app:\n build:\n target: dev\n", + }, + paths: []string{"a.yml", "b.yml"}, + wantOK: false, + }, + { + name: "a context naming a repository is no path", + files: map[string]string{"docker-compose.yml": "services:\n app:\n build: https://example.invalid/repo.git\n"}, + paths: []string{"docker-compose.yml"}, + wantOK: true, + wantBuild: nil, + }, + { + name: "a context written as a variable is not resolved", + files: map[string]string{"docker-compose.yml": "services:\n app:\n build: ${CONTEXT}\n"}, + paths: []string{"docker-compose.yml"}, + wantOK: true, + wantBuild: nil, + }, + { + name: "a Dockerfile written as a variable is not resolved", + files: map[string]string{"docker-compose.yml": "services:\n app:\n build:\n context: .\n dockerfile: ${DOCKERFILE}\n"}, + paths: []string{"docker-compose.yml"}, + wantOK: true, + wantBuild: nil, + }, + { + // Compose writes a build as its context or as an object of options, and as neither of + // those a build says nothing about a Dockerfile. + name: "a build that is neither form is not resolved", + files: map[string]string{"docker-compose.yml": "services:\n app:\n build:\n - .\n"}, + paths: []string{"docker-compose.yml"}, + wantOK: true, + wantBuild: nil, + }, + { + name: "an image written as a variable is not resolved", + files: map[string]string{"docker-compose.yml": "services:\n app:\n image: ubuntu:$TAG\n"}, + paths: []string{"docker-compose.yml"}, + wantOK: true, + wantImage: "", + }, + { + name: "a service none of the files defines", + files: map[string]string{"docker-compose.yml": "services:\n web:\n image: ubuntu:24.04\n"}, + paths: []string{"docker-compose.yml"}, + wantOK: false, + }, + { + name: "a service extending another", + files: map[string]string{"docker-compose.yml": "services:\n app:\n extends:\n service: base\n"}, + paths: []string{"docker-compose.yml"}, + wantOK: false, + }, + { + name: "a file pulling in others", + files: map[string]string{"docker-compose.yml": "include:\n - other.yml\nservices:\n app:\n image: ubuntu:24.04\n"}, + paths: []string{"docker-compose.yml"}, + wantOK: false, + }, + { + name: "a file that does not parse", + files: map[string]string{"docker-compose.yml": "services:\n app:\n image: [\n"}, + paths: []string{"docker-compose.yml"}, + wantOK: false, + }, + { + name: "a missing file", + files: map[string]string{}, + paths: []string{"docker-compose.yml"}, + wantOK: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + fsys := fstest.MapFS{} + for name, content := range tt.files { + fsys[name] = &fstest.MapFile{Data: []byte(content)} + } + got, ok := composeServiceSource(linter.Dir{FS: fsys}, tt.paths, "app") + if ok != tt.wantOK { + t.Fatalf("composeServiceSource ok = %v, want %v", ok, tt.wantOK) + } + if !ok { + return + } + if got.image != tt.wantImage { + t.Errorf("image = %q, want %q", got.image, tt.wantImage) + } + switch { + case tt.wantBuild == nil && got.build != nil: + t.Errorf("build = %+v, want none", *got.build) + case tt.wantBuild != nil && got.build == nil: + t.Errorf("build = none, want %+v", *tt.wantBuild) + case tt.wantBuild != nil && !cmp.Equal(*got.build, *tt.wantBuild, cmp.AllowUnexported(composeBuild{})): + t.Errorf("build = %+v, want %+v", *got.build, *tt.wantBuild) + } + }) + } +} diff --git a/rules/dockerfile.go b/rules/dockerfile.go new file mode 100644 index 0000000..57c90f0 --- /dev/null +++ b/rules/dockerfile.go @@ -0,0 +1,229 @@ +package rules + +import ( + "bytes" + "slices" + "strconv" + "strings" + + "github.com/moby/buildkit/frontend/dockerfile/instructions" + dflinter "github.com/moby/buildkit/frontend/dockerfile/linter" + "github.com/moby/buildkit/frontend/dockerfile/parser" + "github.com/moby/buildkit/frontend/dockerfile/shell" +) + +// dockerfilePulledImages returns the images a build of the Dockerfile in src pulls when target is +// built with args: the one each stage's FROM builds on, and the ones its COPY and RUN --mount +// instructions read through "--from". They come in the order the instructions name them, one entry +// per instruction. An empty target builds the last stage, as "docker build" does. +// +// A FROM is expanded against the Dockerfile's global ARGs, which args overrides, as BuildKit expands +// it (buildMetaArgs and buildDispatchStates in dockerfile2llb) — so "FROM ubuntu:${VARIANT}" names +// the image the declared VARIANT resolves to. A "--from" is not expanded, BuildKit rejecting a +// variable there outright. +// +// Only the stages the build actually reaches are considered, since a stage nothing depends on is +// never built and its images never pulled. Within them, a reference naming another stage is left +// out, being no image at all, as are the references [isPulledImage] rejects. +// +// It returns nothing for a Dockerfile that does not parse, or a target it does not define, leaving +// a rule with nothing to report rather than a guess. +func dockerfilePulledImages(src []byte, args map[string]string, target string) []string { + result, err := parser.Parse(bytes.NewReader(src)) + if err != nil { + return nil + } + // A Dockerfile may configure buildkit's own linter through a "# check=..." comment, which is + // merged onto the one passed here — a nil one is dereferenced, so pass a linter that reports + // nothing instead. Its zero Config leaves Warn nil, which is what turns the warnings off. + stages, metaArgs, err := instructions.Parse(result.AST, dflinter.New(&dflinter.Config{})) + if err != nil { + return nil + } + + lex := shell.NewLex(result.EscapeToken) + env := buildArgEnv(lex, metaArgs, args) + built := builtStages(stages, target) + var images []string + for i := range stages { + if !built[i] { + continue + } + if _, isStage := stageBase(stages, i); !isStage { + if base, ok := expand(lex, env, stages[i].BaseName); ok && isPulledImage(base) { + images = append(images, base) + } + } + for _, from := range stageFroms(stages, i) { + // A "--from" carrying a variable fails the build ("variable expansion is not supported + // for --from"), so it names no image to report on. + if from.stage < 0 && !strings.Contains(from.ref, "$") && isPulledImage(from.ref) { + images = append(images, from.ref) + } + } + } + return images +} + +// isPulledImage reports whether ref, a reference naming no stage, names an image the build pulls. +// Left out are the empty reference and "scratch", the empty base, which BuildKit recognizes in that +// spelling alone. +func isPulledImage(ref string) bool { + return ref != "" && ref != "scratch" +} + +// buildArgEnv returns the values a FROM is expanded against: the Dockerfile's global ARGs, each +// taking its value from args when that declares one and from its own default otherwise, with a +// default itself expanded against the ARGs before it. An arg args gives but the Dockerfile never +// declares is left out, as it is out of scope for a FROM. +func buildArgEnv(lex *shell.Lex, metaArgs []instructions.ArgCommand, args map[string]string) shell.EnvGetter { + var env []string + for _, cmd := range metaArgs { + for _, arg := range cmd.Args { + if value, ok := args[arg.Key]; ok { + env = append(env, arg.Key+"="+value) + continue + } + if arg.Value == nil { + continue + } + if value, ok := expand(lex, shell.EnvsFromSlice(env), *arg.Value); ok { + env = append(env, arg.Key+"="+value) + } + } + } + return shell.EnvsFromSlice(env) +} + +// expand resolves the variables in word against env. ok is false when a variable has no value there: +// BuildKit expands it to nothing, leaving a reference that names no image, so a rule has nothing to +// report on rather than a truncated reference to report wrongly. +func expand(lex *shell.Lex, env shell.EnvGetter, word string) (string, bool) { + result, err := lex.ProcessWordWithMatches(word, env) + if err != nil || len(result.Unmatched) > 0 { + return "", false + } + return result.Result, true +} + +// builtStages returns the indexes of the stages a build of target reaches: the target stage itself, +// the stages it builds on, and the ones it copies from, transitively. An empty target starts from +// the last stage, as "docker build" does. It returns nothing when target names no stage, since such +// a build does not run at all. +func builtStages(stages []instructions.Stage, target string) map[int]bool { + if len(stages) == 0 { + return nil + } + start := len(stages) - 1 + if target != "" { + // A target names a stage and never a position, and BuildKit lower-cases it before the + // lookup, so "DEV" reaches the stage declared "AS dev". + i, ok := stageNamed(stages, strings.ToLower(target)) + if !ok { + return nil + } + start = i + } + + built := map[int]bool{} + for queue := []int{start}; len(queue) > 0; queue = queue[1:] { + i := queue[0] + if built[i] { + continue + } + built[i] = true + queue = append(queue, stageDeps(stages, i)...) + } + return built +} + +// stageDeps returns the indexes of the stages the stage at i is built from: the one its FROM builds +// on, and the ones its instructions read through "--from", each only when it names a stage rather +// than an image. +func stageDeps(stages []instructions.Stage, i int) []int { + var deps []int + if j, ok := stageBase(stages, i); ok { + deps = append(deps, j) + } + for _, from := range stageFroms(stages, i) { + if from.stage >= 0 { + deps = append(deps, from.stage) + } + } + return deps +} + +// stageFrom is a "--from" value of a COPY or a RUN --mount, resolved against the Dockerfile's +// stages: stage is the index of the stage it names, or -1 for a value naming an image, which the +// build pulls like a FROM base. +type stageFrom struct { + ref string + stage int +} + +// stageFroms returns the "--from" values the instructions of the stage at i read, in the order they +// are written. A value naming neither a stage nor an image — a COPY's position that is out of range, +// which fails the build — is left out. +// +// The two instructions resolve a value differently: a COPY's is a stage position when it parses as +// an integer, while a RUN --mount's is always a name. Both are matched against the stage names +// case-insensitively, and against every stage rather than only the earlier ones, since BuildKit +// resolves them once the whole Dockerfile is read. +func stageFroms(stages []instructions.Stage, i int) []stageFrom { + byName := func(ref string) stageFrom { + if j, ok := stageNamed(stages, strings.ToLower(ref)); ok { + return stageFrom{ref: ref, stage: j} + } + return stageFrom{ref: ref, stage: -1} + } + + var froms []stageFrom + for _, cmd := range stages[i].Commands { + switch c := cmd.(type) { + case *instructions.CopyCommand: + if c.From == "" { + continue + } + if j, err := strconv.Atoi(c.From); err == nil { + if j >= 0 && j < len(stages) { + froms = append(froms, stageFrom{ref: c.From, stage: j}) + } + continue + } + froms = append(froms, byName(c.From)) + case *instructions.RunCommand: + for _, mount := range instructions.GetMounts(c) { + if mount.From == "" { + continue + } + froms = append(froms, byName(mount.From)) + } + } + } + return froms +} + +// stageBase returns the index of the stage the FROM of the stage at i builds on, and reports whether +// it names one rather than an image. BuildKit matches a base name against the stages declared before +// it only, and matches it as written against names the parser has already lower-cased — so +// "FROM Builder" after "AS builder" names an image, as its "repository name must be lowercase" +// failure shows. +func stageBase(stages []instructions.Stage, i int) (int, bool) { + return stageNamed(stages[:i], stages[i].BaseName) +} + +// stageNamed returns the index of the last stage named ref and reports whether one is. Several +// stages may share a name, which BuildKit only warns about, and it keeps one stage per name as it +// registers them in turn, so a reference reaches the last of them. A caller whose reference BuildKit +// lower-cases before the lookup passes it lower-cased; stage names need no folding, the parser +// having lower-cased them already. A name cannot begin with a digit, so no reference written as a +// position reaches a stage here. +func stageNamed(stages []instructions.Stage, ref string) (int, bool) { + for i, stage := range slices.Backward(stages) { + // A stage left unnamed has no name to be reached by, whatever ref is. + if stage.Name != "" && stage.Name == ref { + return i, true + } + } + return 0, false +} diff --git a/rules/images.go b/rules/images.go new file mode 100644 index 0000000..1820e41 --- /dev/null +++ b/rules/images.go @@ -0,0 +1,97 @@ +package rules + +import ( + "fmt" + + "github.com/bare-devcontainer/decolint/containerdef" + "github.com/bare-devcontainer/decolint/linter" + "github.com/tailscale/hujson" +) + +// pulledImage is an image a configuration pulls, wherever it is named. +type pulledImage struct { + // ref is the image reference as written. + ref string + // source locates the image for a finding that continues with the reference, e.g. + // `Dockerfile "Dockerfile": `. It is empty for the "image" property, which the reference alone + // already names. + source string + // offset is the byte offset of the property the finding anchors at, which is the one the + // devcontainer.json declares — the Dockerfile and the Compose file are not the linted file. + offset int +} + +// configImages returns every image a build of obj, a devcontainer.json, pulls, in the order the +// configuration reaches them: +// - the one "image" names; +// - for a Compose-based configuration, the image its service runs, or the ones the Dockerfile that +// service builds from pulls; +// - otherwise the ones the Dockerfile the configuration itself names pulls. +// +// Compose is read before the configuration's own "dockerFile"/"build.dockerfile", as the reference +// implementation resolves the base image in that order; a configuration declaring both is reported +// by [ConflictingContainerDef]. An image this cannot resolve is left out rather than guessed at; see +// [dockerfilePulledImages] and [composeServiceSource] for what each leaves behind. +func configImages(dir linter.Dir, obj *hujson.Object) []pulledImage { + var images []pulledImage + if ref, decl, ok := containerdef.Image(obj); ok { + images = append(images, pulledImage{ref: ref, offset: decl.ValueOffset}) + } + if paths, decl, declared := containerdef.ComposeFiles(obj); declared { + return append(images, composeImages(dir, obj, paths, decl.ValueOffset)...) + } + return append(images, dockerfileImages(dir, obj)...) +} + +// dockerfileImages returns the images the Dockerfile obj names pulls, anchored at the property +// naming it. +func dockerfileImages(dir linter.Dir, obj *hujson.Object) []pulledImage { + path, decl, ok := containerdef.Dockerfile(obj) + if !ok { + return nil + } + src, ok := readConfigFile(dir, path) + if !ok { + return nil + } + args, target := containerdef.BuildOptions(obj) + return locate(dockerfilePulledImages(src, args, target), fmt.Sprintf("Dockerfile %q: ", path), decl.ValueOffset) +} + +// composeImages returns the images the Compose service the dev container runs pulls: the one it +// runs, or the ones the Dockerfile it builds from pulls. +func composeImages(dir linter.Dir, obj *hujson.Object, paths []string, offset int) []pulledImage { + service, _, ok := containerdef.ComposeService(obj) + if !ok { + return nil + } + source, ok := composeServiceSource(dir, paths, service) + if !ok { + return nil + } + if source.build == nil { + if source.image == "" { + return nil + } + return []pulledImage{{ref: source.image, source: fmt.Sprintf("compose service %q: ", service), offset: offset}} + } + + src := []byte(source.build.inline) + where := fmt.Sprintf("compose service %q inline Dockerfile: ", service) + if source.build.dockerfile != "" { + if src, ok = readConfigFile(dir, source.build.dockerfile); !ok { + return nil + } + where = fmt.Sprintf("Dockerfile %q: ", source.build.dockerfile) + } + return locate(dockerfilePulledImages(src, source.build.args, source.build.target), where, offset) +} + +// locate pairs each reference with where it was found and the offset to report it at. +func locate(refs []string, source string, offset int) []pulledImage { + images := make([]pulledImage, 0, len(refs)) + for _, ref := range refs { + images = append(images, pulledImage{ref: ref, source: source, offset: offset}) + } + return images +} diff --git a/rules/no_image_latest.go b/rules/no_image_latest.go index bf39586..9547c2a 100644 --- a/rules/no_image_latest.go +++ b/rules/no_image_latest.go @@ -7,22 +7,27 @@ import ( "github.com/tailscale/hujson" ) -// NoImageLatest reports the "image" property when it references a container image without an -// explicit tag or with the "latest" tag. Such references are not reproducible: the image they -// resolve to changes over time. +// NoImageLatest reports an image the configuration pulls without an explicit tag or with the +// "latest" tag, wherever it is named (see [configImages]). Such references are not reproducible: the +// image they resolve to changes over time. var NoImageLatest = &linter.Rule{ ID: "no-image-latest", Description: `disallow container images without an explicit tag or with the "latest" tag`, LongDescription: `A reference with no tag resolves to "latest", and "latest" is just the tag a publisher moves as they release. Either way the configuration says "whatever is current", so the same devcontainer.json builds a different environment next month, and a build that broke cannot be reproduced from the file alone. Name -the version the project was tested against.`, +the version the project was tested against. + +Every image a container of this configuration pulls is checked, whichever way the configuration names +it: the "image" property, the "FROM" and "COPY --from" of the Dockerfile it builds from, and, for a +Compose-based configuration, the image its service runs or the Dockerfile that service builds from.`, References: []string{ `https://containers.dev/implementors/json_reference/#image-specific`, + `https://containers.dev/implementors/spec/#dockerfile-based`, }, Category: linter.CategoryReproducibility, FileTypes: []linter.FileType{linter.Devcontainer}, - Paths: []string{"/image"}, + Paths: []string{""}, Example: linter.Example{ Bad: linter.Snippet{ Files: []linter.ExampleFile{ @@ -40,29 +45,34 @@ the version the project was tested against.`, `}, }, }, + Note: "A `FROM` written with a variable is resolved against the Dockerfile's `ARG` defaults\n" + + "and the `build.args` the configuration passes, as a build resolves it. One whose value\n" + + "neither declares is left unchecked, naming no image the configuration settles.", }, Check: checkNoImageLatest, } -func checkNoImageLatest(_ *linter.Context, node *linter.Node) []linter.Finding { - lit, ok := node.Value.Value.(hujson.Literal) - if !ok || lit.Kind() != '"' { +func checkNoImageLatest(ctx *linter.Context, node *linter.Node) []linter.Finding { + obj, ok := node.Value.Value.(*hujson.Object) + if !ok { return nil } - image := lit.String() - tag, hasTag := refTag(image) - switch { - case !hasTag: - return []linter.Finding{{ - Message: fmt.Sprintf("image %q has no explicit tag; pin a specific version", image), - Offset: node.Value.StartOffset, - }} - case tag == "latest": - return []linter.Finding{{ - Message: fmt.Sprintf("image %q uses the \"latest\" tag; pin a specific version", image), - Offset: node.Value.StartOffset, - }} + var findings []linter.Finding + for _, image := range configImages(ctx.Dir, obj) { + tag, hasTag := refTag(image.ref) + switch { + case !hasTag: + findings = append(findings, linter.Finding{ + Message: fmt.Sprintf("%simage %q has no explicit tag; pin a specific version", image.source, image.ref), + Offset: image.offset, + }) + case tag == "latest": + findings = append(findings, linter.Finding{ + Message: fmt.Sprintf("%simage %q uses the \"latest\" tag; pin a specific version", image.source, image.ref), + Offset: image.offset, + }) + } } - return nil + return findings } diff --git a/rules/no_image_latest_compose_test.go b/rules/no_image_latest_compose_test.go new file mode 100644 index 0000000..db51f75 --- /dev/null +++ b/rules/no_image_latest_compose_test.go @@ -0,0 +1,156 @@ +package rules_test + +import ( + "testing" + "testing/fstest" + + "github.com/bare-devcontainer/decolint/linter" + "github.com/bare-devcontainer/decolint/rules" +) + +func TestNoImageLatest_Compose(t *testing.T) { + t.Parallel() + + // Every case declares one Compose file, whose path starts at column 23, so the findings all + // anchor there. + const src = `{"dockerComposeFile": "docker-compose.yml", "service": "app"}` + issue := func(message string) []linter.Issue { + return []linter.Issue{{Path: "devcontainer.json", Line: 1, Col: 23, RuleID: "no-image-latest", Message: message}} + } + + tests := []struct { + name string + compose string + want []linter.Issue + }{ + { + "untagged image", + "services:\n app:\n image: ubuntu\n", + issue(`compose service "app": image "ubuntu" has no explicit tag; pin a specific version`), + }, + { + "latest image", + "services:\n app:\n image: ubuntu:latest\n", + issue(`compose service "app": image "ubuntu:latest" uses the "latest" tag; pin a specific version`), + }, + {"pinned tag", "services:\n app:\n image: ubuntu:24.04\n", nil}, + {"pinned digest", "services:\n app:\n image: ubuntu@sha256:abc123\n", nil}, + { + // Only the service the dev container runs in is the container's image. + "another service is not the dev container", + "services:\n app:\n image: ubuntu:24.04\n db:\n image: postgres:latest\n", + nil, + }, + { + // A service that builds names in "image" what the build produces, not what it starts + // from; the Dockerfile rules cover the base image. + "a service that builds its own image reports nothing", + "services:\n app:\n build: .\n image: myapp:latest\n", + nil, + }, + { + "an image written as a variable is not resolved", + "services:\n app:\n image: ubuntu:${TAG}\n", + nil, + }, + { + // Compose accepts the bare form as readily as "${VAR}". + "an image written as a bare variable is not resolved", + "services:\n app:\n image: $IMAGE\n", + nil, + }, + { + // The definition continues in a file this does not read, so what is here may not be the + // image the service ends up running. + "a service extending another reports nothing", + "services:\n app:\n extends:\n file: base.yml\n service: base\n image: ubuntu:latest\n", + nil, + }, + { + "a file pulling in others reports nothing", + "include:\n - other.yml\nservices:\n app:\n image: ubuntu:latest\n", + nil, + }, + {"a service defined in no file reports nothing", "services:\n web:\n image: ubuntu:latest\n", nil}, + {"a service without an image reports nothing", "services:\n app:\n command: sleep infinity\n", nil}, + {"a file that does not parse reports nothing", "services:\n app:\n image: [\n", nil}, + {"an empty file reports nothing", "", nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + dir := linter.Dir{FS: fstest.MapFS{"docker-compose.yml": {Data: []byte(tt.compose)}}} + assertIssuesInDir(t, rules.NoImageLatest, linter.SeverityError, "devcontainer.json", linter.Devcontainer, src, dir, tt.want) + }) + } +} + +func TestNoImageLatest_Compose_ComposeFileList(t *testing.T) { + t.Parallel() + + dir := linter.Dir{FS: fstest.MapFS{ + "docker-compose.yml": {Data: []byte("services:\n app:\n image: ubuntu:latest\n")}, + "docker-compose.override.yml": {Data: []byte("services:\n app:\n image: ubuntu:24.04\n")}, + "command.yml": {Data: []byte("services:\n app:\n command: sleep infinity\n")}, + }} + + tests := []struct { + name string + src string + want []linter.Issue + }{ + { + // Compose applies the files in order, so the last one to name an image wins. + "a later file overriding the image is the one read", + `{"dockerComposeFile": ["docker-compose.yml", "docker-compose.override.yml"], "service": "app"}`, + nil, + }, + { + "a later file leaving the image alone does not clear it", + `{"dockerComposeFile": ["docker-compose.yml", "command.yml"], "service": "app"}`, + []linter.Issue{{Path: "devcontainer.json", Line: 1, Col: 23, RuleID: "no-image-latest", Message: `compose service "app": image "ubuntu:latest" uses the "latest" tag; pin a specific version`}}, + }, + { + "an earlier file overridden by a later one is not reported", + `{"dockerComposeFile": ["docker-compose.override.yml", "docker-compose.yml"], "service": "app"}`, + []linter.Issue{{Path: "devcontainer.json", Line: 1, Col: 23, RuleID: "no-image-latest", Message: `compose service "app": image "ubuntu:latest" uses the "latest" tag; pin a specific version`}}, + }, + { + "no dockerComposeFile property", + `{"image": "ubuntu:latest", "service": "app"}`, + []linter.Issue{{Path: "devcontainer.json", Line: 1, Col: 11, RuleID: "no-image-latest", Message: `image "ubuntu:latest" uses the "latest" tag; pin a specific version`}}, + }, + {"no service property", `{"dockerComposeFile": "docker-compose.yml"}`, nil}, + {"an empty file list reports nothing", `{"dockerComposeFile": [], "service": "app"}`, nil}, + {"a non-string entry reports nothing", `{"dockerComposeFile": [42], "service": "app"}`, nil}, + {"a non-string dockerComposeFile reports nothing", `{"dockerComposeFile": 42, "service": "app"}`, nil}, + {"an object dockerComposeFile reports nothing", `{"dockerComposeFile": {}, "service": "app"}`, nil}, + {"a non-string service reports nothing", `{"dockerComposeFile": "docker-compose.yml", "service": 42}`, nil}, + {"a document that is not an object reports nothing", `["docker-compose.yml"]`, nil}, + {"a missing Compose file reports nothing", `{"dockerComposeFile": "absent.yml", "service": "app"}`, nil}, + { + // Configuration under .devcontainer is read through a root confined to it, so a Compose + // file above that directory is not decolint's to open. + "a path leading outside the directory reports nothing", + `{"dockerComposeFile": "../docker-compose.yml", "service": "app"}`, + nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assertIssuesInDir(t, rules.NoImageLatest, linter.SeverityError, "devcontainer.json", linter.Devcontainer, tt.src, dir, tt.want) + }) + } + + t.Run("unreadable directory reports nothing", func(t *testing.T) { + t.Parallel() + src := `{"dockerComposeFile": "docker-compose.yml", "service": "app"}` + assertIssuesInDir(t, rules.NoImageLatest, linter.SeverityError, "devcontainer.json", linter.Devcontainer, src, linter.Dir{FS: errFS{}}, nil) + }) + + t.Run("nil directory reports nothing", func(t *testing.T) { + t.Parallel() + assertIssues(t, rules.NoImageLatest, linter.SeverityError, `{"dockerComposeFile": "docker-compose.yml", "service": "app"}`, nil) + }) +} diff --git a/rules/no_image_latest_dockerfile_test.go b/rules/no_image_latest_dockerfile_test.go new file mode 100644 index 0000000..7738887 --- /dev/null +++ b/rules/no_image_latest_dockerfile_test.go @@ -0,0 +1,532 @@ +package rules_test + +import ( + "testing" + "testing/fstest" + + "github.com/bare-devcontainer/decolint/linter" + "github.com/bare-devcontainer/decolint/rules" +) + +func TestNoImageLatest_Dockerfile(t *testing.T) { + t.Parallel() + + // Every case declares the Dockerfile at "build.dockerfile", whose value starts at column 26, so + // the findings all anchor there. + const src = `{"build": {"dockerfile": "Dockerfile"}}` + issue := func(message string) []linter.Issue { + return []linter.Issue{{Path: "devcontainer.json", Line: 1, Col: 26, RuleID: "no-image-latest", Message: message}} + } + + tests := []struct { + name string + dockerfile string + want []linter.Issue + }{ + { + "untagged base image", + "FROM ubuntu\n", + issue(`Dockerfile "Dockerfile": image "ubuntu" has no explicit tag; pin a specific version`), + }, + { + "latest base image", + "FROM ubuntu:latest\n", + issue(`Dockerfile "Dockerfile": image "ubuntu:latest" uses the "latest" tag; pin a specific version`), + }, + {"pinned tag", "FROM ubuntu:24.04\n", nil}, + {"pinned digest", "FROM ubuntu@sha256:abc123\n", nil}, + {"scratch is not an image", "FROM scratch\nCOPY app /app\n", nil}, + { + "a later stage building on an earlier one is not an image", + "FROM golang:1.24 AS builder\nRUN go build\n\nFROM builder AS final\n", + nil, + }, + { + // A base name reaching the last stage declared under it leaves the first one unbuilt. + "a base name reaches the last stage declared under it", + "FROM ubuntu:latest AS base\n\nFROM ubuntu:24.04 AS base\n\nFROM base AS final\n", + nil, + }, + { + // The parser lower-cases every stage name, so a "FROM" reaches one only in lower case. + "a stage name is reached in the case the parser gives it", + "FROM golang:1.24 AS Builder\n\nFROM builder\n", + nil, + }, + { + // BuildKit reads a base name it cannot match as an image, which is why "FROM BUILDER" + // fails with "repository name must be lowercase" rather than building on the stage. + "a base name in another case is an image", + "FROM golang:1.24 AS builder\n\nFROM BUILDER\n", + issue(`Dockerfile "Dockerfile": image "BUILDER" has no explicit tag; pin a specific version`), + }, + { + // A stage name cannot begin with a digit, so a "FROM" naming a position names an image; + // the stage at that position is not built and its own base never pulled. + "a base name written as a position is an image", + "FROM golang:latest\n\nFROM 0\n", + issue(`Dockerfile "Dockerfile": image "0" has no explicit tag; pin a specific version`), + }, + { + // BuildKit expands a FROM against the global ARGs, so the image the default names is the + // one the build pulls. + "an ARG default resolves the image", + "ARG VARIANT=latest\nFROM ubuntu:${VARIANT}\n", + issue(`Dockerfile "Dockerfile": image "ubuntu:latest" uses the "latest" tag; pin a specific version`), + }, + { + "a resolved image that is pinned reports nothing", + "ARG VARIANT=24.04\nFROM ubuntu:${VARIANT}\n", + nil, + }, + { + // Nothing declares the variable, so BuildKit expands it to nothing and the reference + // names no image. + "an undeclared variable leaves no image", + "FROM ubuntu:${VARIANT}\n", + nil, + }, + { + // An ARG with no default takes its value from the build, which passes none here. + "an ARG left without a value leaves no image", + "ARG VARIANT\nFROM ubuntu:${VARIANT}\n", + nil, + }, + { + // The first ARG's default cannot be resolved, which leaves the ARGs after it alone. + "an ARG default of its own variable does not settle the ones after it", + "ARG BASE=${UNDECLARED}\nARG VARIANT=latest\nFROM ubuntu:${VARIANT}\n", + issue(`Dockerfile "Dockerfile": image "ubuntu:latest" uses the "latest" tag; pin a specific version`), + }, + { + // A variable in a "--from" fails the build outright. + "a variable in a copy reports nothing", + "FROM ubuntu:24.04\nCOPY --from=$TOOLS /x /x\n", + nil, + }, + { + "each unpinned stage is reported", + "FROM golang:latest AS builder\nRUN go build\n\nFROM ubuntu\nCOPY --from=builder /app /app\n", + []linter.Issue{ + {Path: "devcontainer.json", Line: 1, Col: 26, RuleID: "no-image-latest", Message: `Dockerfile "Dockerfile": image "golang:latest" uses the "latest" tag; pin a specific version`}, + {Path: "devcontainer.json", Line: 1, Col: 26, RuleID: "no-image-latest", Message: `Dockerfile "Dockerfile": image "ubuntu" has no explicit tag; pin a specific version`}, + }, + }, + { + "the same unpinned image in several stages is reported once", + "FROM ubuntu:latest AS a\n\nFROM ubuntu:latest AS b\nCOPY --from=a /x /x\n", + issue(`Dockerfile "Dockerfile": image "ubuntu:latest" uses the "latest" tag; pin a specific version`), + }, + {"a Dockerfile whose instructions do not parse reports nothing", "FROM\n", nil}, + {"a Dockerfile that does not tokenize reports nothing", "FROM ubuntu\nRUN < maxConfigFileBytes { + return nil, false + } + return data, true +} + +// holdsFeatureRefs reports whether pointer names the property that holds Feature references in a +// file of the given type: "features" in a devcontainer.json, "dependsOn" in a Feature. A rule +// declares its paths for every file type it applies to, so one covering both properties is offered +// each of them in each file — including the combinations the specification does not define. +func holdsFeatureRefs(fileType linter.FileType, pointer string) bool { + switch fileType { + case linter.Devcontainer: + return pointer == "/features" + case linter.Feature: + return pointer == "/dependsOn" + default: + return false + } +} + +// featureRef is an OCI Feature reference, as written for a key of a devcontainer.json "features" or +// a Feature's "dependsOn", with the byte offset of that key. +type featureRef struct { + ref string + offset int +} + +// featureRefsOfKind returns the Feature references of the given kind the members of v are keyed by, +// for a v that is an object of them. It returns none for a value that is not one. +// +// References are told apart by [feature.ParseRef], which is what resolves them for the merge, so a +// rule reads the three forms the specification defines and the reference implementation accepts. One +// that parses as none of them names no Feature to report on, and is left out along with the kinds +// not asked for. +// +// A caller reading the version a reference names takes it from the reference as written, not from +// the parsed [feature.Ref]: ParseRef normalizes a reference with no version to the "latest" tag, and +// a rule telling those two apart would lose the distinction. +func featureRefsOfKind(v *hujson.Value, kind feature.RefKind) []featureRef { + obj, ok := v.Value.(*hujson.Object) + if !ok { + return nil + } + var refs []featureRef + for _, m := range obj.Members { + name, ok := m.Name.Value.(hujson.Literal) + if !ok || name.Kind() != '"' { + continue + } + ref := name.String() + parsed, err := feature.ParseRef(ref) + if err != nil || parsed.Kind != kind { + continue + } + refs = append(refs, featureRef{ref: ref, offset: m.Name.StartOffset}) + } + return refs +} + // refTag extracts the tag from an OCI-style reference, e.g. a container image or Feature reference. // A reference pinned by digest (e.g. "ref@sha256:...") is treated as tagged. The colon in a // registry host with a port (e.g. "localhost:5000/img") is not a tag separator. diff --git a/rules/util_test.go b/rules/util_test.go new file mode 100644 index 0000000..016cce0 --- /dev/null +++ b/rules/util_test.go @@ -0,0 +1,143 @@ +package rules + +import ( + "slices" + "strings" + "testing" + "testing/fstest" + + "github.com/bare-devcontainer/decolint/feature" + "github.com/bare-devcontainer/decolint/linter" + "github.com/tailscale/hujson" +) + +// TestFeatureRefsOfKind covers each form the specification defines, and the references that are none +// of them: a rule asks for the kind it can report on and must be handed nothing else. +func TestFeatureRefsOfKind(t *testing.T) { + t.Parallel() + + // One object carrying every form, so each case sees the ones it must leave behind. + const src = `{ + "ghcr.io/devcontainers/features/go:1.3.2": {}, + "localhost:5000/features/foo": {}, + "./local-feature": {}, + "../sibling-feature": {}, + "https://example.invalid/devcontainer-feature.tgz": {}, + "http://example.invalid/devcontainer-feature.tgz": {}, + "/absolute/feature": {}, + "no-slash": {}, + "GHCR.IO/UPPER/CASE": {} +}` + + tests := []struct { + name string + kind feature.RefKind + want []string + }{ + { + // An absolute path, a bare name and an upper-case registry are none of the three forms: + // the specification's local form is a relative path, and an OCI reference needs a + // registry it can be fetched from. + name: "OCI", + kind: feature.KindOCI, + want: []string{"ghcr.io/devcontainers/features/go:1.3.2", "localhost:5000/features/foo"}, + }, + { + name: "local", + kind: feature.KindLocal, + want: []string{"./local-feature", "../sibling-feature"}, + }, + { + // The tarball form is an HTTPS URI; the "http://" spelling is not one. + name: "tarball", + kind: feature.KindTarball, + want: []string{"https://example.invalid/devcontainer-feature.tgz"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + value, err := hujson.Parse([]byte(src)) + if err != nil { + t.Fatalf("parse: %v", err) + } + var got []string + for _, ref := range featureRefsOfKind(&value, tt.kind) { + got = append(got, ref.ref) + } + if !slices.Equal(got, tt.want) { + t.Errorf("featureRefsOfKind(%v) = %q, want %q", tt.kind, got, tt.want) + } + }) + } + + t.Run("a value that is not an object", func(t *testing.T) { + t.Parallel() + + value, err := hujson.Parse([]byte(`"not an object"`)) + if err != nil { + t.Fatalf("parse: %v", err) + } + if got := featureRefsOfKind(&value, feature.KindOCI); got != nil { + t.Errorf("featureRefsOfKind = %v, want none", got) + } + }) +} + +// TestHoldsFeatureRefs covers every file type, including the one no rule declaring these paths +// applies to, since the answer for it is part of the contract rather than a case that cannot arise. +func TestHoldsFeatureRefs(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + fileType linter.FileType + pointer string + want bool + }{ + {"devcontainer features", linter.Devcontainer, "/features", true}, + {"devcontainer dependsOn", linter.Devcontainer, "/dependsOn", false}, + {"feature dependsOn", linter.Feature, "/dependsOn", true}, + {"feature features", linter.Feature, "/features", false}, + {"template features", linter.Template, "/features", false}, + {"template dependsOn", linter.Template, "/dependsOn", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := holdsFeatureRefs(tt.fileType, tt.pointer); got != tt.want { + t.Errorf("holdsFeatureRefs(%q, %q) = %v, want %v", tt.fileType, tt.pointer, got, tt.want) + } + }) + } +} + +// TestReadConfigFile_SizeCap covers the boundary of the size cap: a file at it is read, and one over +// it is refused outright, so the rules reading a Dockerfile or a Compose file report nothing on it +// rather than on the part of it that fit. +func TestReadConfigFile_SizeCap(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + size int + want bool + }{ + {"at the cap", maxConfigFileBytes, true}, + {"over the cap", maxConfigFileBytes + 1, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + dir := linter.Dir{FS: fstest.MapFS{"Dockerfile": {Data: []byte(strings.Repeat("#", tt.size))}}} + src, ok := readConfigFile(dir, "Dockerfile") + if ok != tt.want { + t.Fatalf("readConfigFile of a %d-byte file: ok = %v, want %v", tt.size, ok, tt.want) + } + if ok && len(src) != tt.size { + t.Errorf("read %d bytes, want %d", len(src), tt.size) + } + }) + } +}