Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 63 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,17 @@ layout, and the configuration files it contains are linted:

With no arguments, the current directory is linted.

### Path handling

All file access for a linted directory is confined to it: a
`devcontainer.json` under `.devcontainer` is only read from within
that directory, and a symbolic link resolving outside the boundary
its config file was read through (see above) is treated as
nonexistent rather than followed. The same boundary applies to local
Feature references resolved while [merging
Features](#merging-features) — a reference that would resolve outside
it is an error, even if the target exists elsewhere on disk.

decolint supports the following flags; run `decolint -help` for the full
list.

Expand All @@ -72,6 +83,7 @@ list.
| `-format` | output format: `text` (default), `json`, or `github` (see [Output formats](#output-formats)) |
| `-deny-warnings` | also exit non-zero on `warn`-severity findings (see [Exit codes](#exit-codes)) |
| `-config` | path to a config file overriding rule and category severities (see [Config file](#config-file)) |
| `-merge-features` | fetch the Features referenced in `features` and lint the merged configuration (see [Merging Features](#merging-features)) |
| `-rules` | print the available rules |
| `-init` | write a new `.decolint.jsonc` listing every rule at its default severity (see [Config file](#config-file)) |

Expand All @@ -88,8 +100,48 @@ decolint -platform=vscode,codespaces
```

Target platforms can also be declared in the [config
file](#config-file) with the `platforms` member; the `-platform` flag,
when given, takes precedence.
file](#config-file) with the `platforms` member.

### Merging Features

The [Features](https://containers.dev/implementors/features/) a
`devcontainer.json` references contribute configuration of their own —
`containerEnv`, `mounts`, `capAdd`, `securityOpt`, `privileged`,
`init`, `customizations`, and lifecycle hooks — which the Dev
Container tooling merges into the effective configuration following
the specification's [merge
logic](https://containers.dev/implementors/spec/#merge-logic). By
default decolint lints only the raw file, so an issue introduced by a
Feature (say, one that sets `privileged: true` or bind-mounts the
Docker socket) goes unnoticed.

Pass `-merge-features` (or set `"mergeFeatures": true` in the [config
file](#config-file)) to fetch every referenced Feature, merge the
properties it contributes, and lint the merged configuration instead:

```console
decolint -merge-features -config .decolint.jsonc
```

- OCI references (e.g. `ghcr.io/devcontainers/features/node:1`) are
pulled from the registry with anonymous access, direct HTTP(S)
tarball URIs are downloaded, and relative paths (e.g.
`./my-feature`) are read from disk (see [Path
handling](#path-handling)).
- Features referenced by a Feature's `dependsOn` are resolved
recursively and contribute their properties as well; installation
order follows `dependsOn`, `installsAfter`, and
`overrideFeatureInstallOrder`.
- Findings on merged-in properties are reported at the referencing
entry in `features`, so the usual [suppression
comments](#suppressing-findings) on that line apply to them.
- A Feature that cannot be fetched (network failure, unknown
reference, private registry) is an error (exit code 2).

Fetched metadata is cached in memory for the duration of the run, but
not on disk. Note that Feature `options` never affect the merged
configuration, and variable substitutions (e.g. `${devcontainerId}`)
in contributed values are merged literally.

### Output formats

Expand Down Expand Up @@ -151,8 +203,15 @@ to edit:
rule in a [category](#rule-categories) at once; `rules` sets an
individual rule's severity and takes precedence over its category.
`platforms` lists the [target platforms](#target-platforms) whose
rules run in addition to platform-agnostic ones; the `-platform` flag,
when given, takes precedence over it.
rules run in addition to platform-agnostic ones. `mergeFeatures` set
to `true` enables [merging Features](#merging-features), same as the
`-merge-features` flag.

For a config file member with a corresponding flag (`platforms` /
`-platform`, `mergeFeatures` / `-merge-features`), the flag, when
given explicitly, takes precedence over the config file in either
direction — e.g. `-merge-features=false` disables merging even if the
config file sets `"mergeFeatures": true`.

For the strictest configuration, enable every category:

Expand Down
25 changes: 21 additions & 4 deletions cmd/decolint/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ type Config struct {
// Platforms lists target platforms whose rules are linted in addition to platform-agnostic
// ones. The -platform flag, when given, takes precedence.
Platforms []linter.Platform `json:"platforms"`
// MergeFeatures, when true, fetches the Features referenced in each devcontainer.json and
// lints the merged (effective) configuration. The -merge-features flag can enable it as well.
MergeFeatures bool `json:"mergeFeatures"`
// Categories maps a category name to the severity every rule in that category should be
// overridden to. Per-rule entries in Rules take precedence.
Categories map[string]linter.Severity `json:"categories"`
Expand All @@ -26,8 +29,9 @@ type Config struct {

// MarshalJSONTo encodes cfg with its categories and rules written in sorted key order, for use
// with encoding/json/v2. Map iteration order is otherwise unspecified, so without this, marshaling
// the same Config twice could produce differently ordered output. The "platforms" and "categories"
// members are omitted when empty, so generated configs (see initConfigFile) stay minimal.
// the same Config twice could produce differently ordered output. The "platforms",
// "mergeFeatures", and "categories" members are omitted when empty or false, so generated configs
// (see initConfigFile) stay minimal.
func (cfg Config) MarshalJSONTo(enc *jsontext.Encoder) error {
if err := enc.WriteToken(jsontext.BeginObject); err != nil {
return err
Expand All @@ -40,6 +44,14 @@ func (cfg Config) MarshalJSONTo(enc *jsontext.Encoder) error {
return err
}
}
if cfg.MergeFeatures {
if err := enc.WriteToken(jsontext.String("mergeFeatures")); err != nil {
return err
}
if err := enc.WriteToken(jsontext.Bool(true)); err != nil {
return err
}
}
if len(cfg.Categories) > 0 {
if err := enc.WriteToken(jsontext.String("categories")); err != nil {
return err
Expand Down Expand Up @@ -73,12 +85,17 @@ func writeSeverityMap(enc *jsontext.Encoder, m map[string]linter.Severity) error
return enc.WriteToken(jsontext.EndObject)
}

// mergeConfig returns cfg with any CLI-provided opts fields applied as overrides. Only Platforms
// has a CLI equivalent (-platform); Categories and Rules are config-file only.
// mergeConfig returns cfg with any CLI-provided opts fields applied as overrides. Platforms
// (-platform) and MergeFeatures (-merge-features), when explicitly given, override the config
// file's value in either direction (e.g. "-merge-features=false" disables merging even if the
// config file sets "mergeFeatures": true); Categories and Rules are config-file only.
func mergeConfig(opts Options, cfg Config) Config {
if len(opts.Platforms) > 0 {
cfg.Platforms = opts.Platforms
}
if opts.mergeFeaturesSet {
cfg.MergeFeatures = opts.MergeFeatures
}
return cfg
}

Expand Down
43 changes: 43 additions & 0 deletions cmd/decolint/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,25 @@ func TestConfigMarshalJSONToWithPlatforms(t *testing.T) {
}
}

func TestConfigMarshalJSONToWithMergeFeatures(t *testing.T) {
t.Parallel()

cfg := Config{
MergeFeatures: true,
Rules: map[string]linter.Severity{"no-image-latest": linter.SeverityError},
}

want := `{"mergeFeatures":true,"rules":{"no-image-latest":"error"}}`

got, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
if string(got) != want {
t.Errorf("json.Marshal(cfg) = %s, want %s", got, want)
}
}

func TestParseConfig(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -136,6 +155,12 @@ func TestParseConfig(t *testing.T) {
},
false,
},
{
"mergeFeatures",
`{"mergeFeatures": true}`,
Config{MergeFeatures: true},
false,
},
{"invalid severity", `{"rules": {"no-image-latest": "critical"}}`, Config{}, true},
{"invalid category severity", `{"categories": {"security": "critical"}}`, Config{}, true},
{"unknown platform", `{"platforms": ["intellij"]}`, Config{}, true},
Expand Down Expand Up @@ -252,6 +277,24 @@ func TestMergeConfig(t *testing.T) {
Config{Platforms: []linter.Platform{linter.PlatformCodespaces}},
Config{Platforms: []linter.Platform{linter.PlatformCodespaces}},
},
{
"CLI merge-features flag enables it",
Options{MergeFeatures: true, mergeFeaturesSet: true},
Config{},
Config{MergeFeatures: true},
},
{
"CLI merge-features flag not given falls back to config file mergeFeatures",
Options{},
Config{MergeFeatures: true},
Config{MergeFeatures: true},
},
{
"CLI merge-features=false overrides config file mergeFeatures: true",
Options{MergeFeatures: false, mergeFeaturesSet: true},
Config{MergeFeatures: true},
Config{MergeFeatures: false},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand Down
3 changes: 3 additions & 0 deletions cmd/decolint/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ func initConfigFile(output io.Writer) error {
// "platforms" lists target platforms whose rules run in addition to
// platform-agnostic ones (the -platform flag takes precedence), e.g.:
// "platforms": ["vscode", "codespaces"]
// "mergeFeatures", when true, fetches the Features referenced in each
// devcontainer.json and lints the merged (effective) configuration, e.g.:
// "mergeFeatures": true
`)
if err := json.MarshalWrite(&buf, cfg, jsontext.Multiline(true)); err != nil {
return fmt.Errorf("marshal %s: %w", name, err)
Expand Down
11 changes: 11 additions & 0 deletions cmd/decolint/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"strings"
"syscall"

"github.com/bare-devcontainer/decolint/feature"
"github.com/bare-devcontainer/decolint/linter"
"github.com/bare-devcontainer/decolint/rules"
)
Expand Down Expand Up @@ -232,6 +233,16 @@ func runLint(ctx context.Context, stdout io.Writer, opts Options, cfg Config) (b
if err := rules.RegisterRules(l, cfg.Platforms, overrides); err != nil {
return false, fmt.Errorf("register rules: %w", err)
}
if cfg.MergeFeatures {
// One Fetcher per run, so a Feature shared by several files is fetched at most once.
fetcher := feature.NewFetcher()
l.SetTransform(func(ctx context.Context, fctx *linter.Context) error {
if fctx.Type != linter.Devcontainer {
return nil
}
return feature.Merge(ctx, fetcher, fctx.Dir, fctx.FileDir, fctx.Root)
})
}

var allIssues []linter.Issue
var worstSeverity linter.Severity
Expand Down
103 changes: 103 additions & 0 deletions cmd/decolint/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ func TestRun(t *testing.T) {

// violationsFile is where every firing in the violations fixture is reported.
const violationsFile = "testdata/e2e/violations/.devcontainer/devcontainer.json"
// mergeFile is where every firing in the merge fixture is reported.
const mergeFile = "testdata/e2e/merge/.devcontainer/devcontainer.json"

tests := []struct {
name string
Expand Down Expand Up @@ -146,6 +148,43 @@ func TestRun(t *testing.T) {
want: nil,
wantExitCode: 0,
},
{
// With -merge-features, the local Feature's contributions (privileged mode and a Docker
// socket mount) become part of the effective configuration and trip the security rules
// merge.jsonc enables.
name: "merge features",
args: []string{"-merge-features", "-config=testdata/e2e/merge.jsonc", "testdata/e2e/merge"},
want: []firing{
{mergeFile, "no-docker-socket-mount", linter.SeverityError},
{mergeFile, "no-privileged-container", linter.SeverityError},
},
wantExitCode: 1,
},
{
// Without the flag only the raw file is linted, and the raw file is clean.
name: "merge features disabled",
args: []string{"-config=testdata/e2e/merge.jsonc", "testdata/e2e/merge"},
want: nil,
wantExitCode: 0,
},
{
// merge-on.jsonc enables merging via the config file's "mergeFeatures" member.
name: "merge features enabled by config",
args: []string{"-config=testdata/e2e/merge-on.jsonc", "testdata/e2e/merge"},
want: []firing{
{mergeFile, "no-docker-socket-mount", linter.SeverityError},
{mergeFile, "no-privileged-container", linter.SeverityError},
},
wantExitCode: 1,
},
{
// -merge-features=false, given explicitly, overrides merge-on.jsonc's "mergeFeatures":
// true and disables merging.
name: "merge features disabled by CLI flag overrides config",
args: []string{"-merge-features=false", "-config=testdata/e2e/merge-on.jsonc", "testdata/e2e/merge"},
want: nil,
wantExitCode: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand Down Expand Up @@ -527,6 +566,70 @@ func TestRunLint(t *testing.T) {
})
}

func TestRunMergeFeatures(t *testing.T) {
t.Parallel()

t.Run("findings point at the feature reference", func(t *testing.T) {
t.Parallel()

var stdout, stderr bytes.Buffer
args := []string{"-format=json", "-merge-features", "-config=testdata/e2e/merge.jsonc", "testdata/e2e/merge"}
exitCode := run(t.Context(), args, &stdout, &stderr)
if exitCode != 1 {
t.Fatalf("exit code = %d, want 1; stderr: %s", exitCode, stderr.String())
}

var issues []linter.Issue
if err := json.Unmarshal(stdout.Bytes(), &issues); err != nil {
t.Fatalf("output is not a JSON issue array: %v\noutput: %s", err, stdout.String())
}
// The fixture references "./privileged-feature" on line 5; every merged-in property must be
// reported there.
for _, issue := range issues {
if issue.Line != 5 {
t.Errorf("issue %s reported at line %d, want 5 (the feature reference)", issue.RuleID, issue.Line)
}
}
})

t.Run("unresolvable feature is a runtime error", func(t *testing.T) {
t.Parallel()
dir := writeDevcontainer(t, `{"image": "ubuntu:24.04", "features": {"./missing": {}}}`)

var stdout, stderr bytes.Buffer
exitCode := run(t.Context(), []string{"-merge-features", dir}, &stdout, &stderr)
if exitCode != 2 {
t.Errorf("exit code = %d, want 2", exitCode)
}
if !strings.Contains(stderr.String(), "./missing") {
t.Errorf("stderr = %q, want it to mention the unresolvable feature", stderr.String())
}
})

t.Run("local feature escaping .devcontainer is a runtime error", func(t *testing.T) {
t.Parallel()
dir := writeDevcontainer(t, `{"image": "ubuntu:24.04", "features": {"../sibling-feature": {}}}`)
// sibling-feature exists on disk, but as a project-root sibling of .devcontainer, not inside
// it, so it is outside the boundary local Feature references are confined to.
if err := os.MkdirAll(filepath.Join(dir, "sibling-feature"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "sibling-feature", "devcontainer-feature.json"),
[]byte(`{"id": "sibling-feature"}`), 0o644); err != nil {
t.Fatal(err)
}

var stdout, stderr bytes.Buffer
exitCode := run(t.Context(), []string{"-merge-features", dir}, &stdout, &stderr)
if exitCode != 2 {
t.Errorf("exit code = %d, want 2; stdout: %s", exitCode, stdout.String())
}
if !strings.Contains(stderr.String(), "../sibling-feature") {
t.Errorf("stderr = %q, want it to mention the unresolvable feature", stderr.String())
}
})
}

// mdTableRow finds the row of a Markdown table in out whose first cell, after trimming the padding
// listRules adds for alignment, equals key, and returns its cells trimmed of that padding. It fails
// the test if no such row exists.
Expand Down
Loading
Loading