diff --git a/cmd/decolint/main.go b/cmd/decolint/main.go index a69684b..372be40 100644 --- a/cmd/decolint/main.go +++ b/cmd/decolint/main.go @@ -237,7 +237,7 @@ func runLint(ctx context.Context, stdout io.Writer, opts Options, cfg Config) (b var worstSeverity linter.Severity var lintErr error for _, path := range opts.Paths { - issues, err := l.LintDir(ctx, path) + issues, err := lintPath(ctx, l, path) for _, issue := range issues { if issue.Severity > worstSeverity { worstSeverity = issue.Severity @@ -258,3 +258,15 @@ func runLint(ctx context.Context, stdout io.Writer, opts Options, cfg Config) (b return worstSeverity >= threshold, nil } + +// lintPath lints the devcontainer directory at path. The directory is opened as an os.Root, so +// every file the lint reads is confined to it. It is an error if path is not a directory. +func lintPath(ctx context.Context, l *linter.Linter, path string) ([]linter.Issue, error) { + root, err := os.OpenRoot(path) + if err != nil { + return nil, fmt.Errorf("resolve directory: %w", err) + } + // The root is only read from, so a close error is inconsequential. + defer func() { _ = root.Close() }() + return l.LintDir(ctx, root) +} diff --git a/cmd/decolint/main_test.go b/cmd/decolint/main_test.go index f0e2591..5060d83 100644 --- a/cmd/decolint/main_test.go +++ b/cmd/decolint/main_test.go @@ -498,6 +498,18 @@ func TestRunLint(t *testing.T) { } }) + t.Run("file path is rejected", func(t *testing.T) { + t.Parallel() + dir := writeDevcontainer(t, `{"image": "ubuntu:24.04"}`) + file := filepath.Join(dir, ".devcontainer", "devcontainer.json") + + var stdout bytes.Buffer + hasIssue, runErr := runLint(t.Context(), &stdout, Options{Paths: []string{file}, Format: format.TextFormat{}}, Config{}) + if runErr == nil || hasIssue { + t.Errorf("hasIssue = %v, err = %v, want false, 'not a directory'", hasIssue, runErr) + } + }) + t.Run("override for unselected platform-scoped rule is not an error", func(t *testing.T) { t.Parallel() dir := writeDevcontainer(t, `{"image": "ubuntu:latest"}`) diff --git a/linter/linter.go b/linter/linter.go index 2b10e93..6fc66b9 100644 --- a/linter/linter.go +++ b/linter/linter.go @@ -8,6 +8,7 @@ import ( "context" "errors" "fmt" + "io/fs" "os" "path/filepath" "sort" @@ -29,10 +30,14 @@ func (i Issue) String() string { return fmt.Sprintf("%s:%d:%d: %s: %s (%s)", i.Path, i.Line, i.Col, i.Severity, i.Message, i.RuleID) } -// ConfigFile is a configuration file detected in a directory. -type ConfigFile struct { - Path string - Type FileType +// configEntry is a discovered configuration file and the boundary it must be read through: root is +// the os.Root confining access (the lint root, or its .devcontainer sub-root), and path is relative +// to that root. rel is the path relative to the lint directory, for display. +type configEntry struct { + root *os.Root + path string + rel string + typ FileType } // Linter runs a set of rules against devcontainer configuration files. @@ -59,45 +64,57 @@ func (l *Linter) RegisterRule(r *Rule, severity Severity) { } } -// LintDir determines the kind of devcontainer directory dir is (a dev container definition, a -// Feature, or a Template), and lints every configuration file it contains. It is an error if dir is -// not a directory or contains no configuration. -func (l *Linter) LintDir(ctx context.Context, dir string) ([]Issue, error) { +// LintDir determines the kind of devcontainer directory root is opened on (a dev container +// definition, a Feature, or a Template), and lints every configuration file it contains. It is an +// error if the directory contains no configuration. All file access happens through root, so it is +// confined to that directory; configuration files under its .devcontainer directory are only +// accessed within that directory. Symbolic links are followed only while they resolve inside that +// boundary, and a link escaping it is treated as nonexistent. Issue paths are the files' locations +// joined onto root's name. +func (l *Linter) LintDir(ctx context.Context, root *os.Root) ([]Issue, error) { + dir := root.Name() if err := ctx.Err(); err != nil { return nil, fmt.Errorf("aborted %s: %w", dir, err) } - info, err := os.Stat(dir) - if err != nil { - return nil, fmt.Errorf("resolve directory: %w", err) - } - if !info.IsDir() { - return nil, fmt.Errorf("not a directory: %s", dir) - } - files := FindConfigs(dir) - if len(files) == 0 { - return nil, fmt.Errorf("no devcontainer configuration found in %s", dir) - } var issues []Issue var errs []error - for _, f := range files { + found := false + err := visitConfigs(root, func(f configEntry) error { + found = true if err := ctx.Err(); err != nil { - return issues, errors.Join(append(errs, fmt.Errorf("aborted %s: %w", f.Path, err))...) - } - src, err := os.ReadFile(f.Path) - if err != nil { - errs = append(errs, fmt.Errorf("read config: %w", err)) - continue + return fmt.Errorf("aborted %s: %w", filepath.Join(dir, f.rel), err) } - fileIssues, err := l.Lint(ctx, f.Path, src, f.Type) + fileIssues, err := l.lintConfig(ctx, dir, f) if err != nil { + // A broken file must not stop the remaining files from being linted, so record the + // error and keep visiting. errs = append(errs, err) - continue + return nil } issues = append(issues, fileIssues...) + return nil + }) + if err != nil { + return issues, errors.Join(append(errs, err)...) + } + if !found { + return nil, fmt.Errorf("no devcontainer configuration found in %s", dir) } return issues, errors.Join(errs...) } +// lintConfig reads and lints the single configuration file f, reporting issues under +// filepath.Join(dir, f.rel). The file is read through f.root, so its resolution cannot escape that +// boundary. +func (l *Linter) lintConfig(ctx context.Context, dir string, f configEntry) ([]Issue, error) { + display := filepath.Join(dir, f.rel) + src, err := f.root.ReadFile(f.path) + if err != nil { + return nil, fmt.Errorf("read config %s: %w", display, err) + } + return l.Lint(ctx, display, src, f.typ) +} + // Lint lints src, which is the content of a configuration file of the given type. path is used only // for reporting. func (l *Linter) Lint(ctx context.Context, path string, src []byte, fileType FileType) ([]Issue, error) { @@ -163,61 +180,82 @@ func safeCheck(r *Rule, rctx *Context, node *Node) (findings []Finding) { return r.Check(rctx, node) } -// FindConfigs determines the kind of devcontainer directory dir is and returns the configuration -// files it contains: +// visitConfigs determines the kind of devcontainer directory root is opened on and calls fn once +// for each configuration file it contains: // // - a Feature (dir contains devcontainer-feature.json): that file; // - a Template (dir contains devcontainer-template.json): that file, plus the dev container // configuration the template ships; // - otherwise, a dev container definition: the configuration files at the locations defined by -// the devcontainer specification: .devcontainer/devcontainer.json, .devcontainer.json, and -// .devcontainer//devcontainer.json (one level deep). +// the devcontainer specification: .devcontainer.json, .devcontainer/devcontainer.json, and +// .devcontainer//devcontainer.json (one level deep), in that order. // -// An empty result means dir contains no devcontainer configuration. -func FindConfigs(dir string) []ConfigFile { - if p := filepath.Join(dir, "devcontainer-feature.json"); isFile(p) { - return []ConfigFile{{Path: p, Type: Feature}} +// fn never being called means the directory contains no devcontainer configuration. A non-nil +// error from fn aborts the visit and is returned as is; a per-file problem that should not stop +// the remaining files from being visited must be handled inside fn. The entry's root is only +// valid during the fn call. Everything under the .devcontainer directory is accessed through a +// root confined to that directory: the future Feature/dependsOn resolver receives the same +// boundary, so local Feature references — including Features stored inside the active +// .devcontainer directory — resolve within it. +func visitConfigs(root *os.Root, fn func(configEntry) error) error { + if p := "devcontainer-feature.json"; isFile(root, p) { + if err := fn(configEntry{root, p, p, Feature}); err != nil { + return err + } + return nil } - if p := filepath.Join(dir, "devcontainer-template.json"); isFile(p) { - files := []ConfigFile{{Path: p, Type: Template}} - return append(files, devcontainerConfigs(dir)...) + if p := "devcontainer-template.json"; isFile(root, p) { + if err := fn(configEntry{root, p, p, Template}); err != nil { + return err + } } - return devcontainerConfigs(dir) + return visitDevcontainerConfigs(root, fn) } -// devcontainerConfigs returns the devcontainer.json files under dir at the locations defined by the -// devcontainer specification. -func devcontainerConfigs(dir string) []ConfigFile { - var paths []string - for _, p := range []string{ - filepath.Join(dir, ".devcontainer", "devcontainer.json"), - filepath.Join(dir, ".devcontainer.json"), - } { - if isFile(p) { - paths = append(paths, p) +// devcontainerDir is the directory that holds a dev container definition's configuration, and the +// boundary that access to that configuration is confined to. +const devcontainerDir = ".devcontainer" + +// visitDevcontainerConfigs calls fn for each devcontainer.json under root at the locations defined +// by the devcontainer specification. Files inside the .devcontainer directory are visited with a +// root confined to that directory, opened once for the whole visit. +func visitDevcontainerConfigs(root *os.Root, fn func(configEntry) error) error { + if p := ".devcontainer.json"; isFile(root, p) { + if err := fn(configEntry{root, p, p, Devcontainer}); err != nil { + return err } } - entries, err := os.ReadDir(filepath.Join(dir, ".devcontainer")) - if err == nil { - for _, e := range entries { - if !e.IsDir() { - continue - } - p := filepath.Join(dir, ".devcontainer", e.Name(), "devcontainer.json") - if isFile(p) { - paths = append(paths, p) - } + sub, err := root.OpenRoot(devcontainerDir) + if err != nil { + return nil + } + // The root is only read from, so a close error is inconsequential. + defer func() { _ = sub.Close() }() + if p := "devcontainer.json"; isFile(sub, p) { + if err := fn(configEntry{sub, p, filepath.Join(devcontainerDir, p), Devcontainer}); err != nil { + return err } } - sort.Strings(paths) - files := make([]ConfigFile, 0, len(paths)) - for _, p := range paths { - files = append(files, ConfigFile{Path: p, Type: Devcontainer}) + entries, err := fs.ReadDir(sub.FS(), ".") + if err != nil { + return nil + } + for _, e := range entries { + if !e.IsDir() { + continue + } + p := filepath.Join(e.Name(), "devcontainer.json") + if !isFile(sub, p) { + continue + } + if err := fn(configEntry{sub, p, filepath.Join(devcontainerDir, p), Devcontainer}); err != nil { + return err + } } - return files + return nil } -func isFile(path string) bool { - info, err := os.Stat(path) +func isFile(root *os.Root, path string) bool { + info, err := root.Stat(path) return err == nil && !info.IsDir() } diff --git a/linter/linter_test.go b/linter/linter_test.go index 277552b..fed68dc 100644 --- a/linter/linter_test.go +++ b/linter/linter_test.go @@ -1,110 +1,183 @@ -package linter_test +package linter import ( + "errors" + "fmt" + "os" "path/filepath" "slices" + "strings" "testing" - "github.com/bare-devcontainer/decolint/linter" - "github.com/bare-devcontainer/decolint/rules" + "github.com/tailscale/hujson" ) -func lintSrc(t *testing.T, src string) []linter.Issue { +// noImageLatestRule is a test double for the rules package's no-image-latest rule: it flags an +// "image" property with no tag or the "latest" tag. Reusing its ID keeps testdata's +// decolint-ignore-next-line comments meaningful without this package depending on rules. +var noImageLatestRule = &Rule{ + ID: "no-image-latest", + FileTypes: []FileType{Devcontainer}, + Paths: []string{"/image"}, + Check: func(_ *Context, node *Node) []Finding { + lit, ok := node.Value.Value.(hujson.Literal) + if !ok || lit.Kind() != '"' { + return nil + } + image := lit.String() + tag, hasTag := "", false + if i := strings.LastIndex(image, ":"); i >= 0 { + tag, hasTag = image[i+1:], true + } + switch { + case !hasTag: + return []Finding{{Message: fmt.Sprintf("image %q has no explicit tag", image), Offset: node.Value.StartOffset}} + case tag == "latest": + return []Finding{{Message: fmt.Sprintf("image %q uses the \"latest\" tag", image), Offset: node.Value.StartOffset}} + } + return nil + }, +} + +func lintSrc(t *testing.T, src string) []Issue { t.Helper() - l := linter.New() - // Only the correctness category is enabled by default; enable no-image-latest specifically, - // since tests using this helper rely on it firing. - overrides := rules.Overrides{Rules: map[string]linter.Severity{"no-image-latest": linter.SeverityWarn}} - if err := rules.RegisterRules(l, nil, overrides); err != nil { - t.Fatalf("RegisterRules: %v", err) - } - issues, err := l.Lint(t.Context(), "devcontainer.json", []byte(src), linter.Devcontainer) + l := New() + l.RegisterRule(noImageLatestRule, SeverityWarn) + issues, err := l.Lint(t.Context(), "devcontainer.json", []byte(src), Devcontainer) if err != nil { t.Fatalf("Lint: %v", err) } return issues } -func TestFindConfigs(t *testing.T) { - t.Parallel() +// symlink creates a symbolic link, skipping the test on platforms where symlink creation is not +// permitted (e.g. Windows without the required privilege). +func symlink(t *testing.T, target, link string) { + t.Helper() + if err := os.Symlink(target, link); err != nil { + t.Skipf("symlink unsupported: %v", err) + } +} - tests := []struct { - dir string - want []linter.ConfigFile - }{ - { - "testdata/project", - []linter.ConfigFile{ - {filepath.Join("testdata", "project", ".devcontainer", "devcontainer.json"), linter.Devcontainer}, - {filepath.Join("testdata", "project", ".devcontainer", "go", "devcontainer.json"), linter.Devcontainer}, - }, - }, - { - "testdata/rootfile", - []linter.ConfigFile{ - {filepath.Join("testdata", "rootfile", ".devcontainer.json"), linter.Devcontainer}, - }, - }, - { - "testdata/feature", - []linter.ConfigFile{ - {filepath.Join("testdata", "feature", "devcontainer-feature.json"), linter.Feature}, - }, - }, - { - "testdata/template", - []linter.ConfigFile{ - {filepath.Join("testdata", "template", "devcontainer-template.json"), linter.Template}, - {filepath.Join("testdata", "template", ".devcontainer", "devcontainer.json"), linter.Devcontainer}, - }, - }, - { - "testdata/template-rootfile", - []linter.ConfigFile{ - {filepath.Join("testdata", "template-rootfile", "devcontainer-template.json"), linter.Template}, - {filepath.Join("testdata", "template-rootfile", ".devcontainer.json"), linter.Devcontainer}, - }, - }, - { - "testdata/template-subfolder", - []linter.ConfigFile{ - {filepath.Join("testdata", "template-subfolder", "devcontainer-template.json"), linter.Template}, - {filepath.Join("testdata", "template-subfolder", ".devcontainer", "go", "devcontainer.json"), linter.Devcontainer}, - }, - }, - { - "testdata/template-no-devcontainer", - []linter.ConfigFile{ - {filepath.Join("testdata", "template-no-devcontainer", "devcontainer-template.json"), linter.Template}, - }, - }, - {"testdata", nil}, +// openRoot opens dir as an os.Root, closed when the test ends, to lint or visit configs through. +func openRoot(t *testing.T, dir string) *os.Root { + t.Helper() + root, err := os.OpenRoot(dir) + if err != nil { + t.Fatalf("os.OpenRoot(%q): %v", dir, err) } - for _, tt := range tests { - t.Run(tt.dir, func(t *testing.T) { - t.Parallel() - got := linter.FindConfigs(tt.dir) - if !slices.Equal(got, tt.want) { - t.Errorf("linter.FindConfigs(%q) = %v, want %v", tt.dir, got, tt.want) - } - }) + t.Cleanup(func() { _ = root.Close() }) + return root +} + +func TestLintDirSymlink(t *testing.T) { + t.Parallel() + + // setup creates a dev container definition directory whose .devcontainer/devcontainer.json is a + // symbolic link to target, and returns the directory. + setup := func(t *testing.T, target string) string { + t.Helper() + proj := t.TempDir() + if err := os.MkdirAll(filepath.Join(proj, ".devcontainer"), 0o755); err != nil { + t.Fatal(err) + } + symlink(t, target, filepath.Join(proj, ".devcontainer", "devcontainer.json")) + return proj } + + t.Run("link escaping the lint directory is treated as nonexistent", func(t *testing.T) { + t.Parallel() + tmp := t.TempDir() + if err := os.WriteFile(filepath.Join(tmp, "devcontainer.json"), []byte(`{}`), 0o644); err != nil { + t.Fatal(err) + } + proj := filepath.Join(tmp, "proj") + if err := os.MkdirAll(filepath.Join(proj, ".devcontainer"), 0o755); err != nil { + t.Fatal(err) + } + symlink(t, filepath.Join("..", "..", "devcontainer.json"), + filepath.Join(proj, ".devcontainer", "devcontainer.json")) + + l := New() + if _, err := l.LintDir(t.Context(), openRoot(t, proj)); err == nil { + t.Error("LintDir: got nil error, want 'no devcontainer configuration found'") + } + }) + + t.Run("link leaving .devcontainer is treated as nonexistent", func(t *testing.T) { + t.Parallel() + proj := setup(t, filepath.Join("..", "real.json")) + // The target is inside the lint directory, but outside .devcontainer. + if err := os.WriteFile(filepath.Join(proj, "real.json"), []byte(`{}`), 0o644); err != nil { + t.Fatal(err) + } + + l := New() + if _, err := l.LintDir(t.Context(), openRoot(t, proj)); err == nil { + t.Error("LintDir: got nil error, want 'no devcontainer configuration found'") + } + }) + + t.Run("link with an absolute target is treated as nonexistent", func(t *testing.T) { + t.Parallel() + // The target is inside .devcontainer, but os.Root rejects absolute symlink targets. + proj := t.TempDir() + if err := os.MkdirAll(filepath.Join(proj, ".devcontainer"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(proj, ".devcontainer", "real.json"), []byte(`{}`), 0o644); err != nil { + t.Fatal(err) + } + symlink(t, filepath.Join(proj, ".devcontainer", "real.json"), + filepath.Join(proj, ".devcontainer", "devcontainer.json")) + + l := New() + if _, err := l.LintDir(t.Context(), openRoot(t, proj)); err == nil { + t.Error("LintDir: got nil error, want 'no devcontainer configuration found'") + } + }) + + t.Run("link resolving within .devcontainer is followed", func(t *testing.T) { + t.Parallel() + proj := setup(t, "main.json") + if err := os.WriteFile(filepath.Join(proj, ".devcontainer", "main.json"), []byte(`{}`), 0o644); err != nil { + t.Fatal(err) + } + + l := New() + if _, err := l.LintDir(t.Context(), openRoot(t, proj)); err != nil { + t.Errorf("LintDir: %v", err) + } + }) + + t.Run("link from a subfolder resolving within .devcontainer is followed", func(t *testing.T) { + t.Parallel() + proj := t.TempDir() + if err := os.MkdirAll(filepath.Join(proj, ".devcontainer", "go"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(proj, ".devcontainer", "shared.json"), []byte(`{}`), 0o644); err != nil { + t.Fatal(err) + } + symlink(t, filepath.Join("..", "shared.json"), + filepath.Join(proj, ".devcontainer", "go", "devcontainer.json")) + + l := New() + if _, err := l.LintDir(t.Context(), openRoot(t, proj)); err != nil { + t.Errorf("LintDir: %v", err) + } + }) } func TestLintDir(t *testing.T) { t.Parallel() - l := linter.New() - // Only the correctness category is enabled by default; enable no-image-latest specifically, - // since the fixtures below rely on it firing. - overrides := rules.Overrides{Rules: map[string]linter.Severity{"no-image-latest": linter.SeverityWarn}} - if err := rules.RegisterRules(l, nil, overrides); err != nil { - t.Fatalf("RegisterRules: %v", err) - } + l := New() + l.RegisterRule(noImageLatestRule, SeverityWarn) t.Run("definition with multiple configs", func(t *testing.T) { t.Parallel() - issues, err := l.LintDir(t.Context(), "testdata/project") + issues, err := l.LintDir(t.Context(), openRoot(t, "testdata/project")) if err != nil { t.Fatalf("LintDir: %v", err) } @@ -124,7 +197,7 @@ func TestLintDir(t *testing.T) { t.Run("clean definition", func(t *testing.T) { t.Parallel() - issues, err := l.LintDir(t.Context(), "testdata/rootfile") + issues, err := l.LintDir(t.Context(), openRoot(t, "testdata/rootfile")) if err != nil { t.Fatalf("LintDir: %v", err) } @@ -135,7 +208,7 @@ func TestLintDir(t *testing.T) { t.Run("feature is not checked by devcontainer rules", func(t *testing.T) { t.Parallel() - issues, err := l.LintDir(t.Context(), "testdata/feature") + issues, err := l.LintDir(t.Context(), openRoot(t, "testdata/feature")) if err != nil { t.Fatalf("LintDir: %v", err) } @@ -146,7 +219,7 @@ func TestLintDir(t *testing.T) { t.Run("template lints the shipped devcontainer config", func(t *testing.T) { t.Parallel() - issues, err := l.LintDir(t.Context(), "testdata/template") + issues, err := l.LintDir(t.Context(), openRoot(t, "testdata/template")) if err != nil { t.Fatalf("LintDir: %v", err) } @@ -161,7 +234,7 @@ func TestLintDir(t *testing.T) { t.Run("a broken file does not stop other files in the same directory from being linted", func(t *testing.T) { t.Parallel() - issues, err := l.LintDir(t.Context(), "testdata/broken") + issues, err := l.LintDir(t.Context(), openRoot(t, "testdata/broken")) if err == nil { t.Fatal("got nil error, want a parse error for the broken config") } @@ -174,17 +247,9 @@ func TestLintDir(t *testing.T) { } }) - t.Run("file path is rejected", func(t *testing.T) { - t.Parallel() - file := filepath.Join("testdata", "project", ".devcontainer", "devcontainer.json") - if _, err := l.LintDir(t.Context(), file); err == nil { - t.Error("got nil error, want 'not a directory'") - } - }) - t.Run("directory without config", func(t *testing.T) { t.Parallel() - if _, err := l.LintDir(t.Context(), t.TempDir()); err == nil { + if _, err := l.LintDir(t.Context(), openRoot(t, t.TempDir())); err == nil { t.Error("got nil error, want 'no devcontainer configuration found'") } }) @@ -209,23 +274,20 @@ func TestIssuePosition(t *testing.T) { func TestLintParseError(t *testing.T) { t.Parallel() - l := linter.New() - if err := rules.RegisterRules(l, nil, rules.Overrides{}); err != nil { - t.Fatalf("RegisterRules: %v", err) - } - if _, err := l.Lint(t.Context(), "bad.json", []byte(`{`), linter.Devcontainer); err == nil { + l := New() + if _, err := l.Lint(t.Context(), "bad.json", []byte(`{`), Devcontainer); err == nil { t.Error("Lint on malformed input: got nil error, want parse error") } } // panicRule is a stub Rule whose Check always panics, used to verify that the engine survives a // defective rule instead of letting it abort the whole run. -var panicRule = &linter.Rule{ +var panicRule = &Rule{ ID: "panic-rule", Description: "always panics", - FileTypes: []linter.FileType{linter.Devcontainer}, + FileTypes: []FileType{Devcontainer}, Paths: []string{""}, - Check: func(*linter.Context, *linter.Node) []linter.Finding { + Check: func(*Context, *Node) []Finding { panic("boom") }, } @@ -233,9 +295,9 @@ var panicRule = &linter.Rule{ func TestLintRulePanicIsRecovered(t *testing.T) { t.Parallel() - l := linter.New() - l.RegisterRule(panicRule, linter.SeverityError) - issues, err := l.Lint(t.Context(), "devcontainer.json", []byte(`{}`), linter.Devcontainer) + l := New() + l.RegisterRule(panicRule, SeverityError) + issues, err := l.Lint(t.Context(), "devcontainer.json", []byte(`{}`), Devcontainer) if err != nil { t.Fatalf("Lint: %v", err) } @@ -245,7 +307,108 @@ func TestLintRulePanicIsRecovered(t *testing.T) { if issues[0].RuleID != "panic-rule" { t.Errorf("RuleID = %q, want %q", issues[0].RuleID, "panic-rule") } - if issues[0].Severity != linter.SeverityError { - t.Errorf("Severity = %v, want %v", issues[0].Severity, linter.SeverityError) + if issues[0].Severity != SeverityError { + t.Errorf("Severity = %v, want %v", issues[0].Severity, SeverityError) + } +} + +// configRef identifies a visited configuration file independently of the root it is read through: +// its path relative to the lint directory, and its type. +type configRef struct { + rel string + typ FileType +} + +func TestVisitConfigs(t *testing.T) { + t.Parallel() + + tests := []struct { + dir string + want []configRef + }{ + { + "testdata/project", + []configRef{ + {filepath.Join(".devcontainer", "devcontainer.json"), Devcontainer}, + {filepath.Join(".devcontainer", "go", "devcontainer.json"), Devcontainer}, + }, + }, + { + "testdata/rootfile", + []configRef{ + {".devcontainer.json", Devcontainer}, + }, + }, + { + "testdata/feature", + []configRef{ + {"devcontainer-feature.json", Feature}, + }, + }, + { + "testdata/template", + []configRef{ + {"devcontainer-template.json", Template}, + {filepath.Join(".devcontainer", "devcontainer.json"), Devcontainer}, + }, + }, + { + "testdata/template-rootfile", + []configRef{ + {"devcontainer-template.json", Template}, + {".devcontainer.json", Devcontainer}, + }, + }, + { + "testdata/template-subfolder", + []configRef{ + {"devcontainer-template.json", Template}, + {filepath.Join(".devcontainer", "go", "devcontainer.json"), Devcontainer}, + }, + }, + { + "testdata/template-no-devcontainer", + []configRef{ + {"devcontainer-template.json", Template}, + }, + }, + {"testdata", nil}, + } + for _, tt := range tests { + t.Run(tt.dir, func(t *testing.T) { + t.Parallel() + var got []configRef + err := visitConfigs(openRoot(t, tt.dir), func(f configEntry) error { + if _, err := f.root.Stat(f.path); err != nil { + t.Errorf("entry %q is not accessible through its root: %v", f.rel, err) + } + got = append(got, configRef{f.rel, f.typ}) + return nil + }) + if err != nil { + t.Fatalf("visitConfigs(%q): %v", tt.dir, err) + } + if !slices.Equal(got, tt.want) { + t.Errorf("visitConfigs(%q) visited %v, want %v", tt.dir, got, tt.want) + } + }) + } +} + +func TestVisitConfigsStopsOnError(t *testing.T) { + t.Parallel() + + wantErr := errors.New("stop") + calls := 0 + // testdata/project contains two configs, so a first-call error must prevent a second call. + err := visitConfigs(openRoot(t, "testdata/project"), func(configEntry) error { + calls++ + return wantErr + }) + if !errors.Is(err, wantErr) { + t.Errorf("visitConfigs returned %v, want %v", err, wantErr) + } + if calls != 1 { + t.Errorf("fn called %d times, want 1", calls) } }